Netty是如何检测资源泄漏的?

1,448 阅读9分钟

为了减轻GC的压力、以及避免频繁向OS申请和释放内存,Netty基于JeMalloc思想自己实现了一套内存管理方案。不管是堆内存还是直接内存,都可以交给Netty来统一管理,这带来了两个好处,一是可以减轻GC的压力,二是可以避免向OS频繁申请和释放内存,Netty一次性申请一大块内存,然后按需分配。 同时,也带来一个坏处,就是开发者使用完毕后,必须及时释放掉资源,否则会导致内存泄漏。

综上所述,自己管理内存会带来更好的性能,但是也增大了内存泄漏的可能性。为了尽量避免内存泄漏,Netty提供了ResourceLeakDetector资源泄漏探测器,它会对分配的资源进行检测,一旦发生泄漏,它会进行报告,让开发者能及时发现并进行修正。 ​

Netty是如何做到的呢? 拿ByteBuf为例,为了能检测到资源是否泄漏,Netty会为ByteBuf对象创建一个弱引用WeakReference指向它,同时传入一个refQueue,如果ByteBuf被GC回收了而没有调用release释放,则JVM会将WeakReference加入到refQueue中,Netty通过refQueue就可以判断是否发生资源泄漏,一旦检测到泄漏就会调用reportLeak()报告泄漏情况。

笔者花了一个流程图,描述了ResourceLeakDetector的大致工作流程。 在这里插入图片描述

1. ResourceLeakDetector

还是以ByteBuf为例,网络IO的每次读写都需要ByteBuf支撑,为了避免频繁的创建和销毁ByteBuf,Netty通过Recycler来回收对象进行重用。同时为了避免频繁的申请和释放内存,Netty通过JeMalloc技术来管理内存。

ByteBuf不等于内存,ByteBuf是Java对象,它工作需要内存做支撑。ByteBuf本身通过Recycler来实现回收重用,内存通过JeMalloc来进行管理复用。

当Channel有数据可读时,Netty默认会通过PooledByteBufAllocator创建一个ByteBuf,并将数据写入到ByteBuf,然后通过Pipeline将ChannelRead事件传播出去。如下,是一个简单的使用示例:

// VM Args:-Dio.netty.leakDetection.level=PARANOID 100%采样检测
public class LeakDemo {
	public static void main(String[] args) throws InterruptedException {
		ByteBuf buf = PooledByteBufAllocator.DEFAULT.buffer(1024);
		buf = null;
		System.gc();
		Thread.sleep(1000);
		// 再申请一次,此时会检测到泄漏并报告
		PooledByteBufAllocator.DEFAULT.buffer(1024);
	}

	@Override
	protected void finalize() throws Throwable {
		System.out.println("finalize...");
	}
}

运行程序,控制台会报告资源泄漏的情况,输出如下:

16:02:31.409 [main] ERROR io.netty.util.ResourceLeakDetector - LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records: 
Created at:
	io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:402)
	io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:188)
	io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:179)
	io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:116)
	io.netty.example.a.LeakDemo.main(LeakDemo.java:21)

1.1 检测等级

Netty提供了4个检测等级,不同的级别采样率不同,开销也不一样,用户可以根据实际情况选择合适的级别。

检测等级说明
DISABLED禁用检测
SIMPLE简单检测,少量的采样,不报告泄漏的位置
ADVANCED高级检测,少量的采样,会报告泄漏的位置
PARANOID偏执检测,100%采样,会报告泄漏的位置

通过设置JVM参数-Dio.netty.leakDetection.level=PARANOID来调整检测等级。 ​

1.2 源码分析

分析一下源码,看看Netty是如何检测资源泄漏并及时报告用户的。在看ResourceLeakDetecto前,先了解几个比较重要的类。 ​

1.2.1 DefaultResourceLeak

DefaultResourceLeak是默认的资源泄漏追踪器,它继承自WeakReference,它会为追踪对象建立一个弱引用连接,当追踪对象被GC回收后,JVM会将WeakReference加入到refQueue,通过refQueue就能判断是否存在资源泄漏了。 DefaultResourceLeak.png 先看它的属性:

/*
追踪记录的头节点,单向链表。
访问对象时,会记录访问的堆栈信息
 */
@SuppressWarnings("unused")
private volatile TraceRecord head;
@SuppressWarnings("unused")
private volatile int droppedRecords;

// 活跃的资源集合
private final Set<DefaultResourceLeak<?>> allLeaks;

// 追踪对象的一致性哈希码,确保关闭对象和追踪对象一致
private final int trackedHash;

再看构造函数:

/**
 * @param referent 引用本身,ByteBuf
 * @param refQueue 弱引用队列
 * @param allLeaks 活跃的资源集合
 */
DefaultResourceLeak(
        Object referent,
        ReferenceQueue<Object> refQueue,
        Set<DefaultResourceLeak<?>> allLeaks) {
    super(referent, refQueue);

    assert referent != null;

    // 计算追踪对象的一致性哈希,close时判断追踪对象和关闭对象是同一个
    trackedHash = System.identityHashCode(referent);
    // 将当前DefaultResourceLeak加入到活跃资源集合中
    allLeaks.add(this);
    // 记录追踪的堆栈信息,TraceRecord.BOTTOM代表链尾
    headUpdater.set(this, new TraceRecord(TraceRecord.BOTTOM));
    this.allLeaks = allLeaks;
}

DefaultResourceLeak的创建过程还是比较简单的,重要的是TraceRecord的创建,它才是记录追踪堆栈的功能类。 ​

1.2.2 TraceRecord

TraceRecord记录着追踪对象访问的堆栈轨迹,它继承自Throwable,这样它就可以通过调用Throwable.getStackTrace()获取堆栈跟踪的元素数组了。 ​

TraceRecord类本身不复杂,重要的是它的toString()方法,它会把追踪对象的访问堆栈信息给构建出来。 ​

属性如下:

// 额外的提示信息
private final String hintString;
// 下一个节点
private final TraceRecord next;
private final int pos;

构造函数:

/**
 * @param next 下一个节点
 * @param hint 额外的提示信息
 */
TraceRecord(TraceRecord next, Object hint) {
    // This needs to be generated even if toString() is never called as it may change later on.
    hintString = hint instanceof ResourceLeakHint ? ((ResourceLeakHint) hint).toHintString() : hint.toString();
    this.next = next;
    this.pos = next.pos + 1;
}

toString()方法,用来构建追踪对象的堆栈信息:

// 构建跟踪的堆栈信息
@Override
public String toString() {
    StringBuilder buf = new StringBuilder(2048);
    if (hintString != null) {
        buf.append("\tHint: ").append(hintString).append(NEWLINE);
    }

    // 获取堆栈信息
    StackTraceElement[] array = getStackTrace();
    // 跳过前三个元素,前三个堆栈信息是ResourceLeakDetector相关,报告出来无意义
    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())) { // lgtm[java/index-out-of-bounds]
                continue out;
            }
        }
        buf.append('\t');
        buf.append(element.toString());
        buf.append(NEWLINE);
    }
    return buf.toString();
}

1.2.3 DefaultResourceLeak

回到DefaultResourceLeak,以PooledByteBufAllocator.newDirectBuffer()申请池化的直接内存为例,它创建完ByteBuf后不会立即返回,它需要在ByteBuf发生泄漏时感知到,因此需要对ByteBuf做一个包装。

@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
    // 申请一个池化的,基于直接内存的ByteBuf,这里的细节先不管
    PoolThreadCache cache = threadCache.get();
    PoolArena<ByteBuffer> directArena = cache.directArena;
    final ByteBuf buf;
    if (directArena != null) {
        buf = directArena.allocate(cache, initialCapacity, maxCapacity);
    } else {
        buf = PlatformDependent.hasUnsafe() ?
            UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
        new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
    }

    // 尝试感知Buf的资源泄漏
    return toLeakAwareBuffer(buf);
}

toLeakAwareBuffer()会判断是简单检测还是高级检测,返回不同的包装类,这个包装类后面会说。

// 尝试感知Buf的资源泄漏
protected static ByteBuf toLeakAwareBuffer(ByteBuf buf) {
    ResourceLeakTracker<ByteBuf> leak;
    // 获取检测等级
    switch (ResourceLeakDetector.getLevel()) {
        case SIMPLE:// 简单检测,返回SimpleLeakAwareByteBuf包装类
            leak = AbstractByteBuf.leakDetector.track(buf);
            if (leak != null) {
                buf = new SimpleLeakAwareByteBuf(buf, leak);
            }
            break;
        case ADVANCED:
        case PARANOID:// 高级检测,返回AdvancedLeakAwareByteBuf包装类
            leak = AbstractByteBuf.leakDetector.track(buf);
            if (leak != null) {
                // 将ByteBuf包装成AdvancedLeakAwareByteBuf,
                buf = new AdvancedLeakAwareByteBuf(buf, leak);
            }
            break;
        default:// 禁用检测,不包装直接返回
            break;
    }
    return buf;
}

AbstractByteBuf.leakDetector.track(buf)方法比较核心,它会返回一个buf的泄漏追踪器,当buf被正常释放时,包装类会自动关闭追踪器,反之资源泄漏时,追踪器可以感知到,并发出报告。

public final ResourceLeakTracker<T> track(T obj) {
    return track0(obj);
}

转交给track0()处理了,它主要做了两件事:创建追踪器、报告泄漏情况。

// 创建一个obj的泄漏追踪器
@SuppressWarnings("unchecked")
private DefaultResourceLeak track0(T obj) {
    // 获取检测等级
    Level level = ResourceLeakDetector.level;
    if (level == Level.DISABLED) {
        // 禁用检测
        return null;
    }

    // 小于PARANOID等级,需要判断是否采样
    if (level.ordinal() < Level.PARANOID.ordinal()) {
        if ((PlatformDependent.threadLocalRandom().nextInt(samplingInterval)) == 0) {
            reportLeak();
            return new DefaultResourceLeak(obj, refQueue, allLeaks);
        }
        return null;
    }
    // 报告泄漏
    reportLeak();
    // 等级为PARANOID,100%检测
    return new DefaultResourceLeak(obj, refQueue, allLeaks);
}

这里用到了几个属性,说明下:

/*
ByteBuf被检测后,会创建一个弱引用指向它,GC时如果ByteBuf没有强引用被回收,
则JVM会将WeakReference放入到refQueue中,通过refQueue就可以判断是否发生内存泄漏。
 */
private final ReferenceQueue<Object> refQueue = new ReferenceQueue<Object>();

// 已经报告的泄漏对象集合
private final Set<String> reportedLeaks =
    Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());


// 采样的间隔,默认128
private final int samplingInterval;

// 活跃的资源集合
private final Set<DefaultResourceLeak<?>> allLeaks =
    Collections.newSetFromMap(new ConcurrentHashMap<DefaultResourceLeak<?>, Boolean>());

说完了DefaultResourceLeak的创建,再看看它是如何报告泄漏的。

1.2.3.1 reportLeak()

reportLeak()的功能是报告资源的泄漏情况,前面说过当追踪对象被GC回收掉后,JVM会将WeakReference加入到refQueue中,因此这里会遍历refQueue,取出泄漏对象后,调用它的toString()来获取堆栈信息,reportUntracedLeak()就很简单了,只是通过logger进行输出。

// 报告泄漏情况
private void reportLeak() {
    if (!needReport()) {
        // 不需要报告,从refQueue中取出引用被clear掉
        clearRefQueue();
        return;
    }

    // 遍历refQueue,报告泄漏的情况
    for (;;) {
        DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
        if (ref == null) {
            // 没有泄漏的对象了,退出循环
            break;
        }

        // 先将自己清理掉
        if (!ref.dispose()) {
            continue;
        }

        // toString()就是泄漏的具体信息
        String records = ref.toString();
        // 添加到已报告的对象集合中
        if (reportedLeaks.add(records)) {
            // 调用logger.error()报告资源泄漏的情况
            if (records.isEmpty()) {
                reportUntracedLeak(resourceType);
            } else {
                reportTracedLeak(resourceType, records);
            }
        }
    }
}

DefaultResourceLeak.toString()用来构建堆栈信息,让用户感知到资源在哪里发生了泄漏。也比较简单,就是对TraceRecord做遍历,拼接方法调用的堆栈信息。

@Override
public String toString() {
    TraceRecord oldHead = headUpdater.getAndSet(this, null);
    if (oldHead == null) {
        // Already closed
        return EMPTY_STRING;
    }

    final int dropped = droppedRecordsUpdater.get(this);
    int duped = 0;

    int present = oldHead.pos + 1;
    StringBuilder buf = new StringBuilder(present * 2048).append(NEWLINE);
    buf.append("Recent access records: ").append(NEWLINE);

    int i = 1;
    Set<String> seen = new HashSet<String>(present);
    // 遍历TraceRecord,拼装堆栈信息
    for (; oldHead != TraceRecord.BOTTOM; oldHead = oldHead.next) {
        String s = oldHead.toString();
        if (seen.add(s)) {
            if (oldHead.next == TraceRecord.BOTTOM) {
                buf.append("Created at:").append(NEWLINE).append(s);
            } else {
                buf.append('#').append(i++).append(':').append(NEWLINE).append(s);
            }
        } else {
            duped++;
        }
    }

    if (duped > 0) {
        buf.append(": ")
            .append(duped)
            .append(" leak records were discarded because they were duplicates")
            .append(NEWLINE);
    }

    if (dropped > 0) {
        buf.append(": ")
            .append(dropped)
            .append(" leak records were discarded because the leak record count is targeted to ")
            .append(TARGET_RECORDS)
            .append(". Use system property ")
            .append(PROP_TARGET_RECORDS)
            .append(" to increase the limit.")
            .append(NEWLINE);
    }

    buf.setLength(buf.length() - NEWLINE.length());
    return buf.toString();
}

至此,创建资源泄漏追踪器和泄漏报告 的流程就全部结束了。 ​

2. 简单检测和高级检测

创建完ResourceLeakTracker资源泄漏追踪器后,Netty还需要将ByteBuf进行包装,这里用到了「装饰者模式」,装饰类的功能有两个:

  1. 追踪对象被release后,关闭追踪器。
  2. 追踪对象被访问后,记录堆栈。

对于第二个功能点,只有高级检测才需要,因此Netty提供了两个包装类:SimpleLeakAwareByteBuf和AdvancedLeakAwareByteBuf,简单检测和高级检测。它俩的区别就是简单检测不会记录追踪对象访问的堆栈信息,只会单纯的报告发生了泄漏,这样的好处是开销较小,坏处是无法确定泄漏的位置。 AdvancedLeakAwareByteBuf.png 装饰类需要依赖一个原生的ByteBuf,所有的操作都委托给ByteBuf去执行,它会在需要增强的方法前后插入一些扩展功能。 ​

篇幅原因,代码就不全贴了,只贴增强后的release()方法吧。

// 对象释放的增强
@Override
public boolean release() {
    if (super.release()) {// 对象成功释放
        closeLeak();// 关闭追踪器
        return true;
    }
    return false;
}

closeLeak()会调用DefaultResourceLeak.close()关闭追踪:

// 关闭追踪
@Override
public boolean close() {
    // 从活跃资源集合中移除自己
    if (allLeaks.remove(this)) {
        // 清除弱引用
        clear();
        // 清空TraceRecord
        headUpdater.set(this, null);
        return true;
    }
    return false;
}

对于AdvancedLeakAwareByteBuf,它还需要记录访问的堆栈信息,大量的方法调用都需要记录堆栈,这里拿touch()方法为例:

/**
* @param hint 追踪的额外信息
* @return
*/
@Override
public ByteBuf touch(Object hint) {
    leak.record(hint);
    return this;
}

leak.record()会调用DefaultResourceLeak.record0()方法记录堆栈信息,创建一个TraceRecord加入到追踪记录的链表中,代码就不贴了。 ​

3. 总结

Netty根据WeakReference弱引用来判断对象是否发生内存泄漏,通过创建一个追踪对象的装饰类来进行增强,当追踪对象被release后,自动关闭追踪器,否则在发生泄漏时进行报告。 ​

如果开启了资源泄漏检测,Netty会为追踪对象创建一个泄漏追踪器ResourceLeakTrackerResourceLeakTracker包含一个单向链表,链表由一系列TraceRecord组成,它代表的是对象访问的堆栈记录,如果发生了资源泄漏,Netty会根据这个链表构建资源泄漏的位置信息并写入日志。 ​

Netty提供了两种检测机制,分别是简单的和高级的,对于高级检测,Netty还会记录追踪对象的访问堆栈信息,在报告时可以快速定位到资源泄漏的具体位置,缺点是这会带来较大的额外开销,不建议在线上使用。