netty中的内存泄漏检测机制ResourceLeakDetector

前言

接上文,好久没写文,一写就停不了。在上文讲解HashedWheelTimer的过程中,我看到了一个东西ResourceLeakDetector,这个东西由于当时没有影响主流程,所以我就略过了。不过我后来有时间看了下,发现有点意思,它也算是netty中的核心组件了。所以这篇文章我就在它的基础上给大家讲讲netty中的内存泄漏检测机制。

背景知识

以下内容有大部分翻译自netty官方文档,有需求的同学可以移步。我们都知道
netty中大量使用了池化技术来减缓IO buffer的创建销毁开销。对于这些内存池管理的对象,从netty 4之后使用了引用计数来对它们进行管理。以ByteBuf为例:

  • 初始化一个对象的时候它的引用计数为1
ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;
  • 当你释放它的时候,它的引用计数会减1,如果引用计数到0了,这个对象就会释放并且它们的资源就能回到内存池中
assert buf.refCnt() == 1;
// release() returns true only if the reference count becomes 0.
boolean destroyed = buf.release();
assert destroyed;
assert buf.refCnt() == 0;

这个机制固然简单,但是有一个弊端,一时间存在了两个回收系统,JVM GC并不知晓netty的存在。那么,如果一个ByteBuf没有执行完自己该做的release,它就已经不可达了,JVM就有可能对他们执行GC。我们知道一旦一个对象被GC了,那就不可能再去调用它的release方法了。那这个ByteBuf就所占用的内存池资源就没法还回去,内存池上可用资源就会越来越少,换言之。这时候我们就产生了内存泄漏。
为了应对这个问题,netty就提供了一个内存泄漏检查机制,使用ResourceLeakDetector,也就是我们今天的主角。

关键技术

经过上面的描述,我们可以明确,netty的内存泄漏检测机制需要完成两个任务:

  • 在泄漏发生的时候,我们需要有个通知机制来知晓被GC的对象没有调用自己该有的release方法来释放池化资源
  • 需要有个记录机制来记录这些泄漏的对象使用的地方,方便溯源

对于这两个问题,netty巧妙地应用了两个java的特性,在介绍这个之前,我们还是先来看看ResourceLeakDetector整个类图结构。
类图结构

从这个类图中,我们看到整个ResourceLeakDetector框架组成,里面包含如下成分:

  • ResourceLeakDetector 是整个框架的api入口,针对每一个类型的资源会有一个实例对这个类型下的所有池化资源进行监控
  • ResourceLeakTracker 资源监控的跟踪接口,每个ResourceLeakTracker的实例都负责跟踪一个具体的资源,比方说一个ByteBuf。它定义了一些跟踪过程中的公共方法
  • DefaultResourceLeak ResourceLeakTracker的实现类,他实现了具体的资源跟踪逻辑,值得注意的是,它继承了WeakReference,这个我后面会讲。
  • Record 代表的是一次使用记录,记载了所跟踪资源的使用地点。它有指针来维护一个单向链表,如果有多个记录的调用就会用链表串起来,同样值得注意的是,他继承自Throwable,这个我后面也会讲。
    讲完了这个,我再来说说netty是如何完成之前我们说的两个任务的。

泄漏通知

我们知道java中存在几种引用,WeakReference是弱引用,当一个对象仅仅被WeakReference指向, 而没有任何其他强引用指向的时候, 这个对象就有可能会被GC回收,不论当前的内存空间是否足够。WeakReference有两种构造函数:

public class WeakReference<T> extends Reference<T> {
    public WeakReference(T referent) {
        super(referent);
    }
    public WeakReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

这两个方法中,referent就是引用所指向的具体对象,而ReferenceQueue<? super T> 可能就很少见了。这个队列的作用是:在弱引用对象所引用的真实对象被回收后,会把弱引用对象,也就是WeakReference对象或者其子类的对象,放入队列ReferenceQueue中。说到这里大家可能就有感觉了,这不就是自然形成了一种对象被GC之后的通知回调机制么?这其实也就是DefaultResourceLeak继承自WeakReference的原因。对象是否在GC之前已经完成了release操作放在了DefaultResourceLeak里面,而通知就依赖于创建DefaultResourceLeak时传入的ReferenceQueue。

使用记录

再来看看怎么记录使用记录,我们对于使用记录的场景无非就是关注资源在何处创建与使用。这里的何处其实就是指的是记录当前的调用栈信息。在java中,获取调用栈有两个途径:

  • new Exception().getStackTrace()
  • Thread.getStackTrace() 这个最常见的使用场景就是Thread.currentThread().getStackTrace()
    对于第一种方案,实际上是依托于Exception的父类Throwable,在创建的时候,就把当前线程的调用栈存放了,而对于第二种方案,稍微就复杂了一点。大家可以看看这篇bug报告,这里面描述了为什么Thread.getStackTrace()会比Throwable.getStackTrace()效率慢很多的原因了(在currentThread的情况下已经做了优化)

Throwable.fillInstackTrace() knows it always works with the current thread's stack and so it can walk the stack with no effort. On the other hand the Thread getStackTrace() method makes no assumptions about whether or not the target thread is the current thread, and it simply requests a thread dump for a single thread ('this') which in this case happens to be the current thread (creating a Thread[] and a StackTraceElement[][] in the process). Obtaining the stack trace of an arbitrary thread requires a safepoint so we take a large performance hit compared to the fillInStackTrace approach.

这里我给大家做下翻译

Throwable.fillInstackTrace() 明确的知道它肯定工作在当前线程栈中,所以它可以没有任何花销的浏览当前线程的调用栈。另外一方面Thread.getStackTrace()方法不能对目标线程是否是当前线程做任何假设和保证,所以它只能直接简单的请求dump当前进程中的某些线程(在当前线程场景下,仅仅dump 'this' 线程)。这就需要一个安全检查点来保证这次获取,这就导致了Thread方案比Throwable方案有个严重的性能缺陷

不过这jdk 1.5之后已经做了优化了,具体可以看现在的Thread.getStackTrace()实现:

  public StackTraceElement[] getStackTrace() {
        if (this != Thread.currentThread()) {
            // check for getStackTrace permission
            SecurityManager security = System.getSecurityManager();
            if (security != null) {
                security.checkPermission(
                    SecurityConstants.GET_STACK_TRACE_PERMISSION);
            }
            // optimization so we do not call into the vm for threads that
            // have not yet started or have terminated
            if (!isAlive()) {
                return EMPTY_STACK_TRACE;
            }
            StackTraceElement[][] stackTraceArray = dumpThreads(new Thread[] {this});
            StackTraceElement[] stackTrace = stackTraceArray[0];
            // a thread that was alive during the previous isAlive call may have
            // since terminated, therefore not having a stacktrace.
            if (stackTrace == null) {
                stackTrace = EMPTY_STACK_TRACE;
            }
            return stackTrace;
        } else {
            // Don't need JVM help for current thread
            return (new Exception()).getStackTrace();
        }
    }

可以看到一进来就会对目标线程做了判断,如果是当前线程就直接短路到Throwable.getStackTrace(),不然就直接到dump Thread的方案。
好了,题外话说完,我们就能确定了,如果需要记录一个某个点的调用栈,我们就可以创建一个Throwable的子类,这样调用栈就自动保存好了,这也就是Record类需要继承Throwable的原因。

回到代码

交代了那么多,我们还是来撸码吧,我们会略过一些环境设置和静态变量以及不必要的方法。

ResourceLeakDetector

首先来看ResourceLeakDetector的跟踪资源逻辑


    /**
     * 创建一个ResourceLeakTracker示例来跟踪一个池化资源
     * 它需要在这个资源被释放的时候调用close方法
     */
    public final ResourceLeakTracker<T> track(T obj) {
        return track0(obj);
    }

    @SuppressWarnings("unchecked")
    /**
     * 具体开创建ResourceLeakTracker逻辑
     */
    private DefaultResourceLeak<T> track0(T obj) {
        Level level = ResourceLeakDetector.level;
        // 如果跟踪登记是Disable,直接返回null
        if (level == Level.DISABLED) {
            return null;
        }
        // 如果等级小于Paranoid
        if (level.ordinal() < Level.PARANOID.ordinal()) {
            // 如果这次随机触发了采样间隔
            // 就报告现有的泄漏
            // 并返回一个DefaultResourceLeak示例来跟踪当前资源
            // 注意为了性能,这里使用了ThreadLocalRandom
            if ((PlatformDependent.threadLocalRandom().nextInt(samplingInterval)) == 0) {
                reportLeak();
                return new DefaultResourceLeak(obj, refQueue, allLeaks);
            }
            // 否则如果没触发采样间隔
            // 则直接返回null 表示不用跟踪这次资源
            return null;
        }
        // 走到这里说明每次资源创建都需要跟踪
        reportLeak();
        return new DefaultResourceLeak(obj, refQueue, allLeaks);
    }

跟之前的文章一样,大部分描述都放在我写的注释上面,从这里面看,关键逻辑就在reportLeak方法和DefaultResourceLeak的创建中,我们先来看看reportLeak方法:

    /**
     * 无脑循环ReferenceQueue,清空之
     */
    private void clearRefQueue() {
        for (;;) {
            @SuppressWarnings("unchecked")
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            if (ref == null) {
                break;
            }
            ref.dispose();
        }
    }

    /**
     * 这个方法用来判断ReferenceQueue中是否存在需要报告的泄漏
     */
    private void reportLeak() {
        // 如果没有启用error日志
        // 仅仅清空当前ReferenceQueue即可
        if (!logger.isErrorEnabled()) {
            clearRefQueue();
            return;
        }

        // 检查和报告之前所有的泄漏
        for (;;) {
            // 从ReferenceQueue中poll一个对象
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            // 为空说明已经清空了
            if (ref == null) {
                break;
            }
            // 如果这个DefaultResourceLeak对象的dispose方法返回false
            // 说明它所跟踪监控的资源已经被正确释放,不存在泄露
            if (!ref.dispose()) {
                continue;
            }
            // 到这里说明已经产生泄露了
            // 获取这个泄露的相关记录的字符串
            String records = ref.toString();
            // 看看这个泄漏有没有出现过
            if (reportedLeaks.putIfAbsent(records, Boolean.TRUE) == null) {
                if (records.isEmpty()) {
                    // 如果字符串为空说明没有任何记录
                    // 就需要报告为untracked的泄漏
                    // 这个方法就直接记录日志,没什么可看的
                    reportUntracedLeak(resourceType);
                } else {
                    // 否则就是报告为tracked的泄漏
                    // 这个方法就直接记录日志就好,没什么可看的
                    reportTracedLeak(resourceType, records);
                }
            }
        }
    }

这里面我们能确定一件事情,DefaultResourceLeak.dispose()方法很重要。
以上就是ResourceLeakDetector的全部需要讲的内容。我们再来看看下一个重点DefaultResourceLeak

DefaultResourceLeak

我们先来看看构造函数

        /**
         * DefaultResourceLeak构造方法
         * @param referent
         * @param refQueue
         * @param allLeaks
         */
        DefaultResourceLeak(
                Object referent,
                ReferenceQueue<Object> refQueue,
                Set<DefaultResourceLeak<?>> allLeaks) {
            // 调用WeakReference的调用方法
            // 注意传入了ReferenceQueue, 完成GC的通知
            super(referent, refQueue);

            assert referent != null;

            // 这里生成了我们引用指向的资源的hashCode
            // 注意这里我们存储了hashCode而非资源对象本身
            // 因为如果存储资源对象本身的话我们就形成了强引用,导致资源不可能被GC
            trackedHash = System.identityHashCode(referent);
            // 将当前的DefaultResourceLeak示例加入到allLeaks集合里面
            // 这个集合是由它跟踪的资源所属的ResourceLeakDetector管理
            // 这个集合在后面判断资源是否正确释放扮演重要角色
            allLeaks.add(this);
            // 初始化设置当前DefaultResourceLeak所关联的record
            headUpdater.set(this, new Record(Record.BOTTOM));
            this.allLeaks = allLeaks;
        }

再来看看记录调用点的方法:

        /**
         * 单纯记录一个调用点,没有任何额外提示信息
         */
        @Override
        public void record() {
            record0(null);
        }

        /**
         * 记录一个调用点,并附上额外信息
         */
        @Override
        public void record(Object hint) {
            record0(hint);
        }

         /**
         * 这个函数非常有意思
         * 有一个预设的TARGET_RECORDS字段
         * 这里有个问题,如果这个资源会在很多地方被记录,
         * 那么这个跟踪这个资源的DefaultResourceLeak的Record就会有很多
         * 但并不是每个记录都需要被记录,否则就会对内存和运行都会造成压力
         * 因为每个Record都会记录整个调用栈
         * 因此需要对记录做取舍
         * 这里有几个原则
         * 1. 所有record都会用一根单向链表来保存
         * 2. 最新的record永远都会被记录
         * 3. 小于TARGET_RECORDS数目的record也会被记录
         * 4. 当数目大于等于TARGET_RECORDS的时候,就会根据概率选择是用最新的record替换掉
         *    当前链表中头上的record(保证链表长度不会增加),还是仅仅添加到头上的record之前
         *    (也就是增加链表长度),当链表长度越大时,替换的概率也越大
         * @param hint
         */
        private void record0(Object hint) {
            // 如果TARGET_RECORDS小于等于0 表示不记录
            if (TARGET_RECORDS > 0) {
                Record oldHead;
                Record prevHead;
                Record newHead;
                boolean dropped;
                do {
                    // 如果链表头为null,说明已经close了
                    if ((prevHead = oldHead = headUpdater.get(this)) == null) {
                        // already closed.
                        return;
                    }
                    // 获取当前链表长度
                    final int numElements = oldHead.pos + 1;
                    // 如果当前链表长度大于等于TARGET_RECORDS
                    if (numElements >= TARGET_RECORDS) {
                        // 获取是否替换的概率,先获取一个因子n
                        // 这个n最多为30,最小为链表长度 - TARGET_RECORDS
                        final int backOffFactor = Math.min(numElements - TARGET_RECORDS, 30);
                        // 这里有 1 / 2^n的概率来添加这个record而不丢弃原有的链表头record,也就是不替换
                        if (dropped = PlatformDependent.threadLocalRandom().nextInt(1 << backOffFactor) != 0) {
                            prevHead = oldHead.next;
                        }
                    } else {
                        dropped = false;
                    }
                    newHead = hint != null ? new Record(prevHead, hint) : new Record(prevHead);
                    // cas 更新record链表
                } while (!headUpdater.compareAndSet(this, oldHead, newHead));
                // 增加丢弃的record数量
                if (dropped) {
                    droppedRecordsUpdater.incrementAndGet(this);
                }
            }
        }

以上就是记录的相关代码,这里主要的点就是如果调用次数过多就会通过随机概率来舍弃某些记录,保证记录链条不会太长。
接下来我们来看看close方法,这个方法会在ByteBuf.release()将引用计数减为0的时候被调用,保证DefaultResourceLeak监控对象正常关闭。

        @Override
        public boolean close() {
            // 从allLeaks 集合中去除自己
            // allLeaks中是否包含自己就作为是否正确release和GC的标准
            if (allLeaks.remove(this)) {
                // 如果成功去除自己,说明是正常流程
                // 清除掉对资源对象的引用
                clear();
                // 设置链表头record到null
                headUpdater.set(this, null);
                // 返回关闭成功
                return true;
            }
            // 说明自己已经被去除了,可能是重复close,或者是存在泄露,返回关闭失败
            return false;
        }

        @Override
        public boolean close(T trackedObject) {
            // 保证释放和跟踪的是同一个对象
            assert trackedHash == System.identityHashCode(trackedObject);

            try {
                // 调用真正close的逻辑
                return close();
            } finally {
                // 保证在这个方法调用之前跟踪的资源对象不会被GC
                // 具体原因可参见这个方法的注释,这里只需要注意
                // 如果在这个方法之前对象就被GC,就不能保证close是否正常
                // 因为如果GC之后再close,就有可能导致泄漏的误判
                reachabilityFence0(trackedObject);
            }
        }

        /**
         * 这个方法看上去很莫名,只在跟踪对象上调用了一个空synchronized块
         * 这里其实引申出来一个很奇葩的问题,就是JVM GC有可能会在一个对象的方法正在执行的时候
         * 就判定这个对象已经不可达,并把它给回收了,具体可以看这个帖子
         * https://stackoverflow.com/questions/26642153/finalize-called-on-strongly-reachable-object-in-java-8
         * 针对这个问题,Java 9 提供了Reference.reachabilityFence这个方法作为解决方案
         * 出于兼容性考虑,这里实现了一个netty版本的reachabilityFence方法,在ref上调用了空synchronized块
         * 来保证在这个方法调用前,JVM是不会对这个对象进行GC,(synchronized保证了不会出现指令重排)
         * 当然引入synchronized块就有可能会引入死锁,这个需要调用者来避免这个事情
         * 还有一个注意的就是这个方法一定要在finally块中使用,保证这个方法的调用会在整个流程的最后,从而保证GC不会执行
         * @param ref
         */
        private static void reachabilityFence0(Object ref) {
            if (ref != null) {
                synchronized (ref) {
                    // 空synchronized块没问题,参见: https://stackoverflow.com/a/31933260/1151521
                }
            }
        }

具体描述都在注释当中,重点如下

  • 一般来说外部调用的都是带参数的close方法
  • 在close方法的finally块中调用了reachabilityFence方法,保证close调用结束之前JVM都不会对资源对象进行GC,否则就会造成泄漏的误判或者逻辑错误
  • 关闭是否成功或者是否存在泄露的关键点就是allLeaks集合中是否还存在this

record和close咱们都看了,还有一个重点函数dispose,不知道大家还记不记得,dispose是ResourceLeakDetector.reportLeak中用来判断泄露是否发生的关键,我们来看看代码:

        /**
         * 判断是否存在泄漏的关键
         * @return false 代表已经正确close
         *         true 代表并未正确close
         */
        boolean dispose() {
            // 清理对资源对象的引用
            clear();
            // 直接使用allLeaks.remove(this) 的结果来
            // 如果remove成功就说明之前close没有调用成功
            // 也就说明了这个监控对象并没有调用足够的release来完成资源释放
            // 如果remove失败说明之前已经完成了close的调用,一切正常
            return allLeaks.remove(this);
        }

大概的逻辑其实在注释当中已经清楚了,整个判断的核心就是allLeaks集合是否还存在this。到了这里我有个疑问,就是为何要用一个脱胎于ConcurrentHashMap的set来作为判断的依据呢?只用一个AtomicBoolean也可以,性能上应该还会更好。这里我唯一想到的原因就是需要这个set来保证对DefaultResourceLeak的强引用,保证这个对象会在资源对象GC之后才能释放。
PS:我后来给netty提了个issue,作者也给了我同样的答复
其他的函数都没什么看点了,toString()就是委托给了record链表里面的每个record对象的toString()。那我们就来看看Record类。

Record

Record类中除了toString可以看看,其他的都是一些单链表的操作。


        @Override
        public String toString() {
            StringBuilder buf = new StringBuilder(2048);
            if (hintString != null) {
                buf.append("\tHint: ").append(hintString).append(NEWLINE);
            }

            // 依托于Throwable的getStackTrace方法,获取创建时候的调用栈
            StackTraceElement[] array = getStackTrace();
            // 跳过最开始的三个栈元素,因为它们就是record方法的那些栈信息,没必要显示了
            out: for (int i = 3; i < array.length; i++) {
                StackTraceElement element = array[i];
                // 去除一些不必要的方法信息
                String[] exclusions = excludedMethods.get();
                for (int k = 0; k < exclusions.length; k += 2) {
                    if (exclusions[k].equals(element.getClassName())
                            && exclusions[k + 1].equals(element.getMethodName())) {
                        continue out;
                    }
                }
                // 格式化,就不用说了
                buf.append('\t');
                buf.append(element.toString());
                buf.append(NEWLINE);
            }
            return buf.toString();
        }

通过注释大家基本就能理解了。

结语

这篇文章对netty中的内存泄漏检测机制做了一个深入浅出的讲解。如果大家有什么疑问欢迎留言讨论!

推荐阅读更多精彩内容