钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)

没有被回收的对象

上篇文章介绍了自己写的延迟队列工具。我们提到,延迟队列不需要长久存活,我们使用带有lru功能的LinkedHashMap来淘汰一些不常用的LimitUtil。但是对象有没有真的会回收呢?

简单写了一个测试类,建了三个对象,Lru容量设为1

LRU<String, LimitUtil> map = new LRU<>(1, 0.75f);
@Test
    public void test() throws InterruptedException {

        LimitUtilFactory limitUtilFactory = new LimitUtilFactory();

        for (int i = 0; i < 3; i++) {
            int finalI = i;
            LimitUtil instance = limitUtilFactory.getInstance(String.valueOf(finalI), 10, 10, 10, (x) -> {
                log.info("执行逻辑 {}", x);
            });
            instance.put("LimitUtilTest test" + finalI);
        }

        Thread.sleep(1000000000);
    }

我们看下VisualVm的堆快照,第一个对象是被LinkedHashMap所引用
钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第1张图片
另外两个对象都没有被LinkedHashMap引用
钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第2张图片
但是我们手动点击GC,堆里LimitUtil的数量依然是三个,我们的程序已经无法取到LimitUtil的实例,但是堆里的对象没有被回收,这里发生了内存泄漏。
钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第3张图片

GC Root

我们如果看了垃圾回收的书,我们可以了解到一个对象决定回不回收有两种算法,一种引用计数算法,一种可达性分析算法(GC Root),可达性就是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。这种算法 有一个致命问题,如果两个对象相互引用,那就没法判断它们是否真的需要被回收,所以此算法已被淘汰。

可达性分析算法则换了种思路,就是通过一系列的称为“GC Roots”的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链( Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。GC会收集那些不是GC Roots且没有被GC Roots引用的对象。

那什么可以作为“GC Roots”的对象作为起始点?

  • Class - 由系统类加载器(system class loader)加载的对象,这些类是不能够被回收的,他们可以以静态字段的方式保存持有其它对象。我们需要注意的一点就是,通过用户自定义的类加载器加载的类,除非相应的Java.lang.Class实例以其它的某种(或多种)方式成为roots,否则它们并不是roots
  • Thread - 活着的线程
  • Stack Local - Java方法的local变量或参数
  • JNI Local - JNI方法的local变量或参数
  • JNI Global - 全局JNI引用
  • Monitor Used - 用于同步的监控对象
  • Held by JVM - 用于JVM特殊目的由GC保留的对象,但实际上这个与JVM的实现是有关的。可能已知的一些类型是:系统类加载器、一些JVM知道的重要的异常类、一些用于处理异常的预分配对象以及一些自定义的类加载器等。然而,JVM并没有为这些对象提供其它的信息,因此需要去确定哪些是属于"JVM持有"的了。

讲到这里,是不是觉得LimitUtil即使没有被 LinkedHashMap引用,但是,是否会存在GC Root对象而导致自身无法回收? 我们再回顾VisualVm的堆快照引用分析。 我们可以看见VisualVm把GC Root对象都用蓝色三角修饰了,再展开看引用分析, 我们的两个ThreadPool都被虚拟机当作GC Root对象,而不可回收。

钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第4张图片
在LimitUtil的实现里,我们都用了ThreadPoolExecutor,而且当没有元素put进去的时候,线程会挂起等待,貌似符合gc root对象所定义的活的线程,目标有了,我们需要将这个线程池关闭。对于ThreadPoolExecutor的源码,可以推荐看下面的文章:

【死磕Java并发】—–J.U.C之线程池:ThreadPoolExecutor

shutdownNow方法可以关闭ThreadPoolExecutor内部活跃的线程。我们在LinkedHashMap的removeEldestEntry方法里面写一个关闭方法。保证LinkedHashMap丢弃这个对象的时候可以执行对应的清理代码。

@Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            // 当元素个数大于了缓存的容量, 就移除元素
            if (size() > this.capacity) {
                LimitUtil value = (LimitUtil) eldest.getValue();
                value.close();
            }
            return size() > this.capacity;
        }
    public void close() {
        service.shutdownNow();
        executorService.shutdownNow();
    }

做完之后,我们重新执行程序,在执行gc前,我们看看VisualVm的堆快照。蓝色三角形没有了。这个对象理论上只剩下被回收。
钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第5张图片
我们手动执行一遍gc,LimitUtil只剩下1个。对象被成功回收!
钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(下)_第6张图片

其它收获,Finalizer

在VisualVm堆快照中,我发现有些对象会被referent引用。通过查阅资料,了解到所有覆写了finalize方法的对象在创建时 都会被Finalizer包装并且 在引用不可达时放入ReferenceQueue中,队列会执行对象的finalize方法,所以以前只知道finalize方法是 对象被gc前执行的方法,现在也知道了前因后果。

Finalizer跟WeakReference(弱引用),SoftReference(软引用) 同系一脉,适合当缓存。感兴趣的同学可以深入了解。

你可能感兴趣的:(技术分享)