Netty源码分析——泄露检测

Netty源码分析——泄露检测

前言

Netty泄露检测依赖于JDK原生的弱引用和引用队列,在早些的版本里,Netty使用的是幻影引用,这篇文章我会基于最新的Netty版本来做一个分析。这篇文章涉及的知识点除了Netty的泄露检测实现,还有一些JDK的知识点,主要是幻影引用和弱引用在被回收时的一些知识点。

幻影引用和弱引用的回收

JAVA中存在四种引用,分别是:

  1. 强引用,最普遍的引用,类似Object obj = new Object()这类的引用。只要强引用还存在,垃圾回收器就不会回收掉被引用的对象。当内存空间不足,JVM宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
  2. 软引用(SoftReference类),如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它,而如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的缓存。
  3. 弱引用(WeakReference类),弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。垃圾回收器进行对象扫描时,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
  4. 虚/幻影引用(PhantomReference类),虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。而且也无法通过虚引用来取得一个对象实例。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

这四种引用是老生常谈的问题。需要注意的是,软引用、弱引用和幻影引用都可以在被垃圾回收时,收到一个系统通知,通知方式就是这个引用将会被放入一个引用队列。软引用和弱引用的区别我们已经说过了,幻影引用和二者的区别是:幻影引用必须配合引用队列(ReferenceQueue类)一起使用,它只有一个构造函数,而且ReferenceQueue是入参。

那么我们可以猜一下Netty的思路,我在之前的文章中说过:JVM并不知道Netty实现了引用计数,所以如果JVM把引用计数大于0的对象回收掉,那么这个对象将永远不会被归还到对象池中,从而导致内存泄露。

那么我们可以这么设计,当我们创建一个对象时,我们就把这个对象维护在一个容器中。当我们release这个对象时(这里的release指的是最终release,也就是引用计数归0时的release),就把这个对象从容器中移除。也就是说,这个容器维护的是引用计数大于0的对象,也就是不应该被回收(release)的对象。

ReferenceQueue和容器中,同时出现了一个对象,则说明这个对象泄露了,因为ReferenceQueue中存放的是JVM打算回收的对象,而容器中存放的是不应该被回收的对象,如果这个对象被JVM回收掉,就会出现对象池内存泄露。

泄露分析源码

我们从分配ByteBuf的地方开始看起,这里提前说明一下,泄漏检测不光是只针对ByteBuf,这里选用ByteBuf作为例子只是比较有代表性。而且使用ByteBuf作为分析的对象,也可以看一下Record记录的流程,比如这个ByteBuf什么时候进行过什么操作。

另外一个泄露分析的例子是HashedWheelTimer,针对HashedWheelTimer的泄露分析就无法记录什么时候操作过之类的信息,只能记录这个HashedWheelTimer是否一直没有被关闭。这个在分析完之后会带着大家再看一下。

回到ByteBuf的分配,我们看下PooledByteBufAllocator,这里会分配池化的ByteBuf,有两个方法:newHeapBuffer分配底层是堆内存的ByteBufnewDirectBuffer则分配堆外内存的ByteBuf。这两个方法最终都会执行AbstractByteBufAllocator#toLeakAwareBuffer(ByteBuf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ResourceLeakTracker<ByteBuf> leak;
switch (ResourceLeakDetector.getLevel()) {
case SIMPLE:
// SIMPLE使用SimpleLeakAwareByteBuf
leak = AbstractByteBuf.leakDetector.track(buf);
if (leak != null) {
buf = new SimpleLeakAwareByteBuf(buf, leak);
}
break;
case ADVANCED:
case PARANOID:
// ADVANCED和PARANOID都使用AdvancedLeakAwareByteBuf
leak = AbstractByteBuf.leakDetector.track(buf);
if (leak != null) {
buf = new AdvancedLeakAwareByteBuf(buf, leak);
}
break;
default:
break;
}
return buf;

关于AdvancedLeakAwareByteBufSimpleLeakAwareByteBuf我们后面说Record记录的时候再说,先看看leak = AbstractByteBuf.leakDetector.track(buf);这一句,leakDetector是一个泄露探测器ResourceLeakDetector,我们看看它的track方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取检测级别
Level level = ResourceLeakDetector.level;
if (level == Level.DISABLED) {
// 禁用泄漏检测就直接返回null,上层不会做任何处理
return null;
}


if (level.ordinal() < Level.PARANOID.ordinal()) {
// 如果泄露检测级别小于PARANOID,就随机取一个,samplingInterval默认128,nextInt(samplingInterval)) == 0这一句说明默认抽样是128分之一,差不多是百分之一。
if ((PlatformDependent.threadLocalRandom().nextInt(samplingInterval)) == 0) {
// 如果需要抽样,则进行泄露报告,然后返回一个DefaultResourceLeak
reportLeak();
return new DefaultResourceLeak(obj, refQueue, allLeaks);
}
return null;
}
// 如果级别是PARANOID,每次都进行泄露报告,即分配一个对象的时候就行一次泄露报告,同样返回一个DefaultResourceLeak
reportLeak();
return new DefaultResourceLeak(obj, refQueue, allLeaks);

上述流程可以看到:

  1. 如果是PARANOID级别,则每次分配对象时都进行泄露检测。
  2. 如果是SIMPLE或者ADVANCED,则默认情况下每分配128个对象进行一次泄露检测(注意不是一定,而是概率)。

我们继续先看一下DefaultResourceLeak的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 弱引用(老版本用的是欢迎引用)
class DefaultResourceLeak<T>
extends WeakReference<Object> implements ResourceLeakTracker<T>, ResourceLeak {

// 存储DefaultResourceLeak的容器
private final ConcurrentMap<DefaultResourceLeak<?>, LeakEntry> allLeaks;

// Record的头结点
private volatile Record head;

DefaultResourceLeak(
Object referent,
ReferenceQueue<Object> refQueue,
ConcurrentMap<DefaultResourceLeak<?>, LeakEntry> allLeaks) {
// WeakReference的构造函数
super(referent, refQueue);

assert referent != null;

trackedHash = System.identityHashCode(referent);
// 这里放了一个LeakEntry.INSTANCE进去,是为了不要一直维护referent,如果put(this, referent)进去会导致allLeaks -> referent的引用链一直存在导致WeakReference无法被回收
allLeaks.put(this, LeakEntry.INSTANCE);
// 初始化一个“创建”记录,“创建”表示这个DefaultResourceLeak的生命周期中的最开始的也是最底层的那个记录。
headUpdater.set(this, new Record(Record.BOTTOM));
this.allLeaks = allLeaks;
}

这里其实包含了非常多的信息,我们看回去track0方法中创建DefaultResourceLeak的部分:return new DefaultResourceLeak(obj, refQueue, allLeaks);,其中refQueue就是一个new ReferenceQueue<Object>();,而allLeaks是一个ConcurrentHashMap。注意这两个属性都是全局的,也就是说同一个ResourceLeakDetector创建的所有DefaultResourceLeak将共享一个ReferenceQueue并把自己放到一个叫allLeaks的map里。

注意这里allLeaks中放的value是一个LeakEntry.INSTANCE,不是我们需要检测的对象,这里给的说明是,如果put(this, referent)会导致allLeaks -> referent(这里的referent在我们的场景下就是一个ByteBuf)一直存在,从而影响WeakReference的回收。

说回来,我们总结一下track方法和DefaultResourceLeak做的事情:

  1. track方法进行采样,并且初始化一个DefaultResourceLeak
  2. DefaultResourceLeak就是一个弱引用的实例,和一个全局的ReferenceQueue绑定,并且把自己维护在一个map中。

我们看回SimpleLeakAwareByteBuf,其中就维护了这个返回的DefaultResourceLeak

1
2
3
4
5
6
7
8
9
private final ByteBuf trackedByteBuf;
// 这就track方法返回的那个DefaultResourceLeak
final ResourceLeakTracker<ByteBuf> leak;

SimpleLeakAwareByteBuf(ByteBuf wrapped, ByteBuf trackedByteBuf, ResourceLeakTracker<ByteBuf> leak) {
super(wrapped);
this.trackedByteBuf = ObjectUtil.checkNotNull(trackedByteBuf, "trackedByteBuf");
this.leak = ObjectUtil.checkNotNull(leak, "leak");
}

那么目前我们先顺一遍allocator分配的流程,以newHeapBuffer为例:

  1. newHeapBuffer分配一个heapByteBuf
  2. 把这个heapByteBuf丢到toLeakAwareBuffer中,这个heapByteBuf会和一个弱引用(弱引用其实就是DefaultResourceLeak)关联起来。
  3. 然后这个heapByteBuf和这个弱引用一起被维护在SimpleLeakAwareByteBuf中。
  4. newHeapBuffer最终返回的就是一个SimpleLeakAwareByteBuf,这个ByteBuf是一个包装的ByteBuf

newHeapBuffer返回的ByteBuf就可以被上层使用了,通过引用计数我们可以进行retainrelease

我们关注泄漏检测,当然要看release的时候做了什么,看下SimpleLeakAwareByteBuf#release()干了啥:

1
2
3
4
5
6
7
// 调用relase,release只有在引用计数归0并且这个对象被回收了才会返回true
if (super.release()) {
// 这句话是关键
closeLeak();
return true;
}
return false;

注意,release实际上是委托给了SimpleLeakAwareByteBuf的父类WrappedByteBuf进行,但是release的含义是一样的:只有在引用计数对象计数为0且对象被真正释放的时候才会返回true。

当我们真正releaseByteBuf时,就会执行closeLeak,这里会执行leak.close(trackedByteBuf);,其中trackedByteBuf就是真正的heapByteBuf,追进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean close(T trackedObject) {
// 保证要释放的对象就是当初自己维护的那个对象
assert trackedHash == System.identityHashCode(trackedObject);

try {
return close();
} finally {
// 保证对象的可见性,请关注'后记2'章节
reachabilityFence0(trackedObject);
}
}

public boolean close() {
// 把自己从allLeaks移除
if (allLeaks.remove(this, LeakEntry.INSTANCE)) {
// 这是父类的Reference的方法,接触引用
clear();
// 把head record设置为null
headUpdater.set(this, null);
return true;
}
return false;
}

这里涉及到的方法我列出来了,主要是把自己从allLeaks中移除,然后接触Reference的引用,最后把head record设置为null,这个地方是因为这个对象不会泄露,所以不需要record信息了。

说到这很多人可能有疑问,为啥这样就说明这个对象不会泄露了呢?

带着这个问题我们看看之前漏掉的一个关键方法——reportLeak,这个方法就是打印泄露信息并且报告泄露,我们看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Netty认为泄露是error级别,如果没有开启error级别日志,就不报告,直接清理ReferenceQueue
if (!logger.isErrorEnabled()) {
clearRefQueue();
return;
}

// 报告最近的一次泄露
for (;;) {
// 从队列中取一个DefaultResourceLeak出来
DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
// 如果取不出来,说明没有泄露
if (ref == null) {
break;
}

// 清理引用并且从allLeaks移除自己
if (!ref.dispose()) {
continue;
}

// 能走到这里说明可能泄露了,这里要打印报告
String records = ref.toString();
if (reportedLeaks.putIfAbsent(records, Boolean.TRUE) == null) {
if (records.isEmpty()) {
reportUntracedLeak(resourceType);
} else {
reportTracedLeak(resourceType, records);
}
}
}

这里是泄露检测的关键,我们一点一点分析。

直接看到DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();这里,这里是从ReferenceQueue中取一个DefaultResourceLeak出来,再说一次DefaultResourceLeak其实就是一个弱引用,只有在JVM打算垃圾回收掉它关联的对象的时候,才会被放到和它关联的ReferenceQueue中。

取一个DefaultResourceLeak出来,如果有,说明这个DefaultResourceLeak对应的ByteBuf即将被回收。然后调用ref.dispose()方法,这个方法很简单不贴了,主要是执行allLeaks.remove(this, LeakEntry.INSTANCE);,把自己从allLeaks移除,如果移除失败,则continue,否则就说明这个ByteBuf内存泄露。

allLeaks中维护的是什么,之前说了,是那些引用计数没到0,也就是Netty认为不该被回收的对象,如果ReferenceQueue中取出来的对象在allLeaks中也存在,就说明:这个对象即将被JVM回收,但是Netty认为它的引用计数不为0,不应该被回收

谜底揭开!

为什么SimpleLeakAwareByteBuf执行release的时候,要把这个引用从allLeaks中移除,因为这时候引用计数归零,也就是Netty认为这个对象应该被回收了。

如果这个对象出现在ReferenceQueue中,在不泄露的情况下,Netty应该已经release了这个对象,那么它就不应该出现在allLeaks中,也就是说ref.dispose在执行allLeaks.remove(this, LeakEntry.INSTANCE);这句的时候就会返回false,因为这个对象引用计数归零就会从这里被移除,remove当然移除不掉一个不存在的值。

至此,泄露检测的分析已经结束,我们回看整个流程:

  1. 分配ByteBuf的时候,这个ByteBuf会和一个弱引用(DefaultResourceLeak)关联。
  2. DefaultResourceLeak被放到allLeaks中,说明这个对象Netty认为不该被回收。
  3. 当我们分配对象时,就会采样并且进行泄露检测(reportLeak)。
  4. ReferenceQueue中取DefaultResourceLeak,如果取不出来说明没有JVM认为该回收的对象,当然也不存在泄露。
  5. 如果取的出来,就尝试从allLeaks中移除这个从ReferenceQueue取出来的引用。
  6. 如果能从allLeaks中移除,说明这个DefaultResourceLeak还在allLeaks中,即Netty认为不该被释放,但是JVM认为这个DefaultResourceLeak关联的对象应该被回收,这时候就可能出现泄露,打印Record
  7. 如果不能从allLeaks中移除,说明JVM认为这个对象该被回收的时候,Netty也已经释放了这个对象,皆大欢喜!

为什么说可能发生泄露

这里并不是说上述情况一定发生了泄露,试想一种情况,Netty刚刚执行完成release,还没来的及把这个DefaultResourceLeakallLeaks中移除,JVM就检测到这个DefaultResourceLeak关联的对象需要回收了,这时候如果另外一个线程正在执行reportLeak,那么依然可能把这个已经被Netty释放并且马上要被JVM回收的对象列为泄露对象。

Netty在reportLeak也注释了:检测并报告最近一次泄露,说明报告是有时效性的,如果一个对象一直存在在我们的泄露报告中,那我们需要关注这种对象的泄漏问题。

Record

说这个类的时候我们要看一下SimpleLeakAwareByteBuf和它的子类AdvancedLeakAwareByteBuf。先看SimpleLeakAwareByteBuf,它的大部分数据操作(readgetset等方法)都使用的是父类的方法,而一些派生方法(duplicateslice等)进行了特殊处理,我们看一下duplicate方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public ByteBuf duplicate() {
// 把super.duplicate返回的ByteBuf传给newSharedLeakAwareByteBuf方法
return newSharedLeakAwareByteBuf(super.duplicate());
}

private SimpleLeakAwareByteBuf newSharedLeakAwareByteBuf(
ByteBuf wrapped) {
// 把duplicate出来的ByteBuf二次封装成一个SimpleLeakAwareByteBuf再返回
return newLeakAwareByteBuf(wrapped, trackedByteBuf, leak);
}

protected SimpleLeakAwareByteBuf newLeakAwareByteBuf(
ByteBuf buf, ByteBuf trackedByteBuf, ResourceLeakTracker<ByteBuf> leakTracker) {
// 二次封装成SimpleLeakAwareByteBuf
return new SimpleLeakAwareByteBuf(buf, trackedByteBuf, leakTracker);
}

这里我们二次封装用的是全局的ResourceLeakTracker<ByteBuf> leak,也就是说其实duplicate出来的对象没有放到allLeaks中(全局的leak说明leak是旧的,不是用track方法创建出来的)。slice方法也一样,说明duplicateslice方法返回的对象和原来的对象是共用同一个泄露检测过程的,而不是独立的,这也符合duplicateslice的设定——共享原缓冲区的数据,所以也共享了泄漏检测的流程。

unwrappedDerived流程会在一些特殊情况下返回新的被泄露检测的ByteBuf,这里不展开说了,有兴趣的朋友可以看下这个方法。

我们可以看到,SimpleLeakAwareByteBufRecord基本没有关系,操作ByteBuf读写的时候也不会记录任何信息。我们如果需要详细记录一个ByteBuf的生命周期、读写操作,这时候就会使用AdvancedLeakAwareByteBuf,看名字我们也可以知道是高级的泄露检测ByteBuf

我们看下它的slice方法:

1
2
3
4
5
6
7
8
9
10
11
public ByteBuf slice() {
recordLeakNonRefCountingOperation(leak);
return super.slice();
}

// 这个方法中会调用ResourceLeakTracker.record方法记录一个Record
static void recordLeakNonRefCountingOperation(ResourceLeakTracker<ByteBuf> leak) {
if (!ACQUIRE_AND_RELEASE_ONLY) {
leak.record();
}
}

我们看到了本小节的重点,就是record,我们需要追踪一个ByteBuf完整的生命周期,必须要在他的各个时期做一些记录,ResourceLeakTracker.record是关键。我们先不急着看record方法做了什么,我们再看看读写操作,以writeBytes为例:

1
2
recordLeakNonRefCountingOperation(leak);
return super.writeBytes(src);

又看到了recordLeakNonRefCountingOperation操作,其余方法类似,都进行了记录。追进去看record方法,最终会到DefaultResourceLeak#record0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private void record0(Object hint) {
// TARGET_RECORDS规定可以记录的最大Record数量
if (TARGET_RECORDS > 0) {
Record oldHead;
Record prevHead;
Record newHead;
boolean dropped;
do {
// 如果head == null 说明已经关闭了,我们在close方法中说过,close时会把head设置为null
if ((prevHead = oldHead = headUpdater.get(this)) == null) {
return;
}
final int numElements = oldHead.pos + 1;
if (numElements >= TARGET_RECORDS) {
final int backOffFactor = Math.min(numElements - TARGET_RECORDS, 30);
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的方式设置新的头节点
} while (!headUpdater.compareAndSet(this, oldHead, newHead));
if (dropped) {
droppedRecordsUpdater.incrementAndGet(this);
}
}
}

Netty为了防止有些对象的操作非常频繁,可能Record链会非常多,Netty会抛弃一些Record。我们从上面的代码可以很明显看出来,Record会形成一个链,Record类实际上是Throwable的子类,我们可以把一条Record理解成一个调用栈帧,将来整个Record链会形成一个类似堆栈的记录。DefaultResourceLeak#toString方法中可以看到整个Record链组装的方式。

这篇文章更多的注意力集中在泄漏检测的实现和思路上,对于Record部分就不多做解读了,有兴趣的朋友可以去DEBUG一下即可,代码并不是很复杂。

HashedWheelTimer的泄露检测

之前说过,泄漏检测不光针对ByteBuf,可以针对任何对象,包括没实现引用计数的对象。

我们简单看下在HashedWheelTimer中的使用。

先看构造函数中初始化部分:leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;

leakDetection是构造函数的入参,表示是否开启。可以看到,如果我们选择开启或者workerThread是守护线程的时候,就会开启泄露检测。如果workerThread是守护线程,那么在程序结束后守护线程是不会立即关闭的,所以如果是守护线程就开启检测,防止程序不断重启结束,产生很多一直没有关闭的workerThread

这个leak我们可以全局搜一下,只有在构造函数和stop中使用了leak,看下stop,我截取一些有用的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try {
boolean interrupted = false;
// 中断workerThread
while (workerThread.isAlive()) {
workerThread.interrupt();
try {
workerThread.join(100);
} catch (InterruptedException ignored) {
interrupted = true;
}
}

if (interrupted) {
Thread.currentThread().interrupt();
}
} finally {
INSTANCE_COUNTER.decrementAndGet();
if (leak != null) {
// 关闭泄漏检测
boolean closed = leak.close(this);
assert closed;
}
}

流程其实很简单,只不过在HashedWheelTimer中我们关心的是一个HashedWheelTimer是否关闭。如果一个HashedWheelTimer一直不关闭,我们认为产生了泄露,但是泄露报告里只有HashedWheelTimer的初始化Record,并不会记录什么时候添加了任务之类的记录。

其实ByteBuf也好,HashedWheelTimer也好,泄露检测检测的就是资源的释放。如果一个HashedWheelTimer一直启动没有被stop,以至于到JVM都打算回收它了仍然没有stop,则依然产生泄露。

后记

之前我们在说allLeaks的时候,可能有些同学会发现,我们放进去的value其实是一个毫无意义的值,而我们使用时也不关心值,只关心键,这种情况下其实使用Set是更符合场景的。但是问题是我们如何构建一个线程安全的Set,jdk原生没有提供ConcurrentHashSet这个类,但是线程安全的Set是有使用场景的。我们可以通过以下两种方式解决这个问题:

  1. Guava提供的线程安全SetSet<String> s = Sets.newConcurrentHashSet();
  2. 我们用jdk支持的Collections方法来构建:Set<String> s = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());

关于这个问题我已经提交了PR修复了(使用第二种方式),最新的4.1.x版本中已经没有LeakEntry.INSTANCE这个类了,ConcurrentHashMap也已经替换成了Set

后记2

这个部分的后记就非常高端了,设计JIT编译优化和解除内联优化。也涉及到前面我讲的内容有一些错误。

我在为什么说可能发生泄露中降到,如果GC线程把一个正准备(还没)从allLeaks中移除的对象提前GC放到了ReferenceQueue中,这时候另外一个线程恰好在执行reportLeask方法并且从ReferenceQueue中把这个对象poll了出来,这时候按照我们的分析,执行reportLeak时会继续尝试从allLeaks中移除这个对象,如果移除成功则泄露。

那么之前正准备(还没)从allLeaks中移除这个对象的线程其实马上就要进行remove操作了,但是这个时候这个对象已经被reportLeak给误报告为泄露对象。

在某些JIT编译优化的场景下,GC线程认为SimpleLeakAwareByteBuf#release执行之后,也就是彻底释放这个对象之后,就不会存在这个对象的引用了,所以GC线程就在SimpleLeakAwareByteBuf#release之前提前堆这个对象进行了回收,导致这个对象提前出现在ReferenceQueue中。说白了就是,经过JIT优化,JVM未卜先知了,我们还没release申请的那个ByteBuf的时候,JVM就提前帮我们把它回收掉了。

R大在知乎有一篇精彩的回答,不光我描述的场景会出现对象提前回收的问题,极端情况下,this都可能被提前回收!我这里贴一下地址供有需要的小伙伴看一下。

说回来,我们怎么保证在执行release之前,想要释放的ByteBuf不被提前回收呢?之前版本中的close方法不是现在这样的,而是这样的:

1
2
3
public boolean close() {
return allLeaks.remove(this, LeakEntry.INSTANCE);
}

后来进行了第一次修改,增加了一个close方法,而且都是用新的close方法进行关闭泄露检测操作:

1
2
3
4
5
6
7
8
public boolean close(T trackedObject) {
assert trackedHash == System.identityHashCode(trackedObject);
// We need to actually do the null check of the trackedObject after we close the leak because otherwise
// we may get false-positives reported by the ResourceLeakDetector. This can happen as the JIT / GC may
// be able to figure out that we do not need the trackedObject anymore and so already enqueue it for
// collection before we actually get a chance to close the enclosing ResourceLeak.
return close() && trackedObject != null;
}

新增了一个入参,这个入参就是当时我们要追踪的那个对象,比如一个ByteBuf。这里进行了一个trackedObject != null操作,就是告诉JIT,我们在进行SimpleLeakAwareByteBuf#release操作之后,还需要用到这个被追踪的ByteBuf,防止JIT优化这里的编译,从而防止release方法执行之前,ByteBuf就被提前回收导致错误的泄露报告。

后来又进行了一次修改,我们现在的close方法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
assert trackedHash == System.identityHashCode(trackedObject);

try {
return close();
} finally {
// This method will do `synchronized(trackedObject)` and we should be sure this will not cause deadlock.
// It should not, because somewhere up the callstack should be a (successful) `trackedObject.release`,
// therefore it is unreasonable that anyone else, anywhere, is holding a lock on the trackedObject.
// (Unreasonable but possible, unfortunately.)
reachabilityFence0(trackedObject);
}

private static void reachabilityFence0(Object ref) {
if (ref != null) {
// Empty synchronized is ok: https://stackoverflow.com/a/31933260/1151521
synchronized (ref) { }
}
}

我们可以看到有一个reachabilityFence0方法,这里我也保留了一些注释。注释大意是说,我们需要在执行reachabilityFence0之前,保留对象的强引用,至少在调用此方法之前,引用的对象不能通过垃圾收集回收。R大的回复中也说了一种在JDK9中可以采用的方法,就是使用java.lang.ref.Reference#reachabilityFence方法可以保证入参对象不被提前回收,这个方法在1.9之前是没有的,我这里为了给大家一个更直观的感受,直接把这个方法的实现贴出来:

1
2
3
4
5
6
@DontInline
public static void reachabilityFence(Object ref) {
// Does nothing, because this method is annotated with @DontInline
// HotSpot needs to retain the ref and not GC it before a call to this
// method
}

可以看到注释上说,这个方法是空方法,但是因为有DontInline注解(不要做内联优化),可以让GC在执行这个方法之前都不要GC入参的那个对象。

但是Netty实际使用的是synchronized (ref) { }这种方式,这种方式也可以让GC在执行这个同步块之前,不回收ref这个对象,但是这种方法兼容性更好(Reference#reachabilityFence在1.9中才存在),所以Netty使用这种方式。而且在reachabilityFence0上Netty给了很明确的注释,由于Netty锁住了这个对象,所以调用方有义务保证不产生死锁。这里也给我们一个小TIPS,尽量不要对ByteBuf加锁,因为大部分情况下没有人知道ByteBufrelease的时候还会被锁住。

可能有人要问,空的synchronized块会不会导致一些问题,这里stackOverFlow上也给出了一个答案,有兴趣的同学可以去看一下。

总结

我写这篇文章前后折腾了将近一个周,没有借鉴其他人的文章,查阅了大量资料,内容全都来自自己对泄漏检测的解读,如果有错误的地方还请大家指正,代码都基于4.1.32,也是Netty最新的版本,希望大家喜欢!

附录

this被提前回收的情况及避免措施
老版本ResourceLeakDetector的BUG
第一次修复JIT编译优化导致错误的泄露检测的PR
第二次优化JIT编译优化导致错误的泄露检测的PR
关于空的synchronized块能否正常工作