Netty 的资源泄露探测机制
2017/11/11
原文地址 https://ylgrgyq.github.io/2017/11/11/netty-resource-leack-detector/
这里资源泄露主要是指某个对象占用有某些资源,比如连接、内存等,在这个对象被 GC 之前,必须主动执行一个方法,如 close、release 之类的,将其占用的资源释放出来,该对象才能被安全的 GC。否则就会出现资源泄露,比如连接没有关闭,内存没有释放。随着服务的运行,泄露的资源由于无法被释放,整个服务占用的资源就会越来越多,最终让服务无法分配新资源,导致服务异常。
Netty 中有个 ResourceLeakDetector,能对占用资源的对象进行监控,如果对象被 GC 之前没有主动释放资源,则 ResourceLeakDetector 会发现这个泄露,并会以打印日志的方式告知给开发者。ResourceLeakDetector 可以保护任何一个可能出现泄露的资源,不过在 Netty 中 ResourceLeakDetector 最主要的使用场所还是去保护、记录 Netty 使用的各种 ByteBuf。无论是 Pooled 还是 Unpooled,无论是 Direct 还是 Heap,所有的 ByteBuf 都要被 ResourceLeakDetector 记录起来,从而在开发者出现忘记为 ByteBuf 调用 release 的时候,通过日志告知开发者有泄露,要求开发者来排查问题。如果出现泄露,就可能会出现比如 Pooled ByteBuf 对象没有放入 Pool 中就被 GC,或者 Direct ByteBuf 没有执行释放内存的方法就被 GC 的情况。因为 Netty 大量使用 ByteBuf,如果 ByteBuf 出现泄露,则服务很容易出现 OOM。
老版本的 ResourceLeakDetector 的使用
在大概 2016 年 12 月的时候,Netty 对 ResourceLeakDetector 做了改动,修复了一个隐藏很久的 Bug。对于这个稍后再说,新老版本在使用上差别不大,所以我们还是先看老版本的 ResourceLeakDetector,先看看它是怎么使用的,之后再说说这个 Bug 是怎么回事,怎么被修复的。
首先,你需要有个待保护的 Resource,这个 Resource 可以有各种功能,但肯定要有个 close 或者 release 方法,需要在不再使用这个 Resource 的时候记得执行一下,释放资源。忘记释放就会造成资源泄露。
interface Resource {
boolean close();
}
为了提醒我们要记得释放资源,我们用 ResourceLeakDetector 协助我们做检查。比如我们有个资源 DefaultResource:
class DefaultResource implements Resource {
static final ResourceLeakDetector<Resource> leakDetector = new ResourceLeakDetector<Resource>(Resource.class);
public boolean close() {
// .... 有一堆释放资源的工作
}
}
我们需要在申请出资源之后,将资源交给 DefaultResource 内的 leakDetector 做登记:
// 比如我们就这么拿到一个资源
DefaultResource rsc = ResourceFactory.get();
// 交给 leakDetector 做登记
ResourceLeak leak = DefaultResource.leakDetector.open(rsc)
相当于每申请一个资源后,就将资源交给 leakDetector 做登记,并且在释放资源的时候,不但要执行 rsc.close() 清理资源,还要执行 leak.close() 将 leakDetector 登记的记录销毁,所以最好是不直接使用 DefaultResource 而是对其用 Decorator 包装一下,从而将这两步 close 操作封装为一步操作,用户使用 Decorator 去执行 close 时就将 leak 和资源都 close,从而完成资源清理和 leakDetector 的注销。
class LeakAwareResource implements Resource {
Resource rsc;
ResourceLeak leak;
LeakAwareResource(Resource rsc, ResourceLeak leak){
this.rsc = rsc;
this.leak = leak
}
@Override
public boolean close() {
boolean closed = this.rsc.close()
if (closed){
// 需要 rsc 释放完了之后再 close leak
this.leak.close();
}
return closed;
}
}
用户使用的时候直接使用 LeakAwareResource 即可,释放资源时执行它的 close 方法,将资源释放,也将 leakDetector 登记的记录清理。忘记执行 LeakAwareResource 的 close 的话,该对象在被 GC 的时候,leakDetector 会有一定概率能发现这个泄露并打印日志。一定概率是因为 ResourceLeakDetector 记录资源泄露是有开销的,所以是抽样的做记录,不是所有泄露都能被抓住,但基本上如果有泄露使用久了总能被发现。
老版本的 ResourceLeakDetector 实现
ResourceLeakDetector 使用虽然是有一些额外工作需要做,但总体来看不算太麻烦,再看看其实现方法。上面 DefaultResource.leakDetector.open(rsc)
返回的 ResourceLeak 是一个 PhantomReference,它指向被检查的资源 rsc,也就是一个 DefaultResource 对象。如下图:
PhantomReference 不影响 DefaultResource 的 GC,DefaultResource 被 GC 后,指向它的 PhantomReference (即 ResourceLeak) 会被塞到一个指定队列里,消费这个队列就能拿到 PhantomReference 。如果按照上面 LeakAwareResource 的实现,DefaultResource 总是和 PhantomReference 一起创建,并且 DefaultResource close 时也会执行与其绑定的 PhantomReference 的 close。就能在这个 PhantomReference (ResourceLeak) 内记录有没有执行过 close 这个事情,等 DefaultResource 被 GC 后,从队列中依次读取所有 PhantomReference,并判断里面 close 标识是否置位,发现没置位的 close 就说明该 PhantomReference 曾经指向的 DefaultResource 没有执行 close 就被 GC 了,就存在泄漏,需要打日志报警。
从 PhantomReference 拿不到 DefaultResource 状态,但是在 DefaultResource 被 GC 后,队列内取到 PhantomReference 内部状态是全的。为了在 DefaultResource 出现泄露时候报警打日志,可以在 PhantomReference 里记录所有操作 DefaultResource 的 Stack,从而在有泄露的时候将这些 Stack 打印出来,以追踪问题。
打印堆栈开销很大,并且这个 Leak Detector 是即使服务工作正常,最好也开着以捕获意外的泄露,所以上面提到了 Netty 内部以采样的方式为 ByteBuf 设置 Detector,并提供了很多 Detection 级别,默认是 SIMPLE,还有 ADVANCE,PARANOID,级别越高采样比率越大,记录 Stack 的开销也越大。调整方法是调整 io.netty.leakDetection.level
这个参数,或者老版本 Netty 是这个 -Dio.netty.leakDetectionLevel
。这个参数一般没有泄露不会调整。
每次操作 Resource 留下的 Stack 记录叫做 record,除了采样比率之外,Netty 还能调整 Record 数量。比如一个 ByteBuf 操作次数比较多,默认的最大 record 数量 4 个不够,就需要调整 record 数,从而排查问题。调整的参数是 -Dio.netty.leakDetection.maxRecords
。这个参数比 detection level 更少调整,只有在发现有泄露,并且提示说 record 数量不足,不足以定位问题的时候才需要调整,正常情况下完全不用动。
保证 PhantomReference 不会提前被 GC
上面提到,DefaultReference 被 GC 后,Phantom Reference 队列能取到指向它的 PhantomReference,并且 PhantomReference 如果内部有其它字段、状态的话此时都是能读取到的。但这个保证是需要有前提的,前提就是有 GC Root 能指向这个 PhantomReference,不能出现 DefaultReference 还未被 GC 或者 DefaultResource 被 GC 还未来得及处理 Phantom Reference 队列时,PhantomReference 自己就因为没有 GC Root 指向而被 GC 掉。
所以,ReferenceDetector 内是有个 static 的链表,每次执行 DefaultResource.leakDetector.open(rsc)
构造出新的 PhantomReference 的时候,会将这个 Reference 塞到这个 static 的链表当中,只有调用 leak.close() 时,才会将 PhantomReference 从链表上摘下,才能被 GC。从而保证了如果出现泄露忘记执行 LeakAwareResource 的 close,则 PhantomReference 不会在其指向的 DefaultResource 被 GC 前 GC。从而实现 ReferenceDetector 的功能。
老版本 ResourceLeakDetector 的 Bug
这个 Bug 藏的非常隐晦,所以很多很多年都没有被发现。Bug 的现象是即使记得调用了 LeakAwareResource 的 close,释放了 Resource,但 Netty 的 ReferenceDetector 还是会错误报出发现内存泄露。也就是说 DefaultReference 执行了 close,但在 leak.close()
执行之前,DefaultResource 就被 GC 了,且指向它的 PhantomReference 被从队列中拿了出来,发现该 PhantomReference (其实是 ResourceLeak) 内的 closed 标识未置位,从而错误报出有内存泄露。
来看看之前 LeakAwareResource 的实现。LeakAwareResource 是 Netty 内 SimpleLeakAwareByteBuf 的简化版本,和 Netty 的实现逻辑是一样的。
class LeakAwareResource implements Resource {
// ..... 和前面一样
@Override
public boolean close() {
boolean closed = this.rsc.close()
if (closed){
// 需要 rsc 释放完了之后再 close leak
this.leak.close();
}
return closed;
}
}
看上去只要 this.rsc.close()
成功,则一定会执行 this.leak.close()。但是由于 JIT/GC 的存在,在 this.rsc.close()
执行后,JVM 会推算出代码不再需要 this.rsc 所指的对象了,所以在执行 this.leak.close()
之前就将 this.rsc
指向的对象 DefaultResource 直接 GC 掉,此时如果有另外的线程在执行处理 PhantomReference 队列的逻辑,就会从队列中拿到指向刚被 GC 掉的 DefaultResource 的 PhantomReference。此时 this.leak.close()
还未执行,所以会报出内存泄露,而实际上 this.leak.close()
终究是会被执行的。并且 DefaultReference 也已经成功执行过 close()
方法。
这种 Bug 感觉就是传说中的 Heisenbug,如果你手工调试,或者在执行 this.leak.close()
之前检查 this.rsc 的状态,你一定无法发现这个 Bug,因为会影响 JIT/GC 的工作。你的探测会导致程序行为变化,使 Bug 隐藏起来,不探测的时候 Bug 又会出现。想眼睁睁的看着 Bug 一步一步的复现是不可能的。这个 Bug 修复之后 Netty 也增加了测试,测试也只是调整线程数,调整 ReferenceDetector 探测级别,让一堆线程不断的重复构造 Resource 又释放,看看是否出现误报。
新版本的 ResourceLeakDetector
该怎么修上面那个 Bug 呢?修复就是要保证 this.rsc.close()
执行完,还要骗过 JVM 让它误认为你还需要 this.rsc
指向的对象,不要将其 GC 掉。需要再说明一下,我这里所说的老版本代码基于 v4.0.28,新版本代码基于 v4.1.9。在 v4.1.9 后,ResourceLeakDetector 又做了很多性能上的优化,只是这里为了不引入太多东西看着复杂,所以不再说后来优化的事情了,在最下边参考里有列出性能优化的 PR,有兴趣的可以看看。
老版本 ResourceLeakDetector 内 ResourceLeak 的 close 实现大致如下:
public boolean close() {
if (markFreed()) {
synchronized (link) {
removeThisReferenceFromLink(link)
}
return true;
}
return false
}
即先标记 ResourceLeak 为 free,再将其从 ResourceLeakDetector 内 static 的链表上移除。移除后 ResourceLeak 这个 PhantomReference 就能被安全 GC 了。
新版本 ResourceLeakDetector 的实现大致是:
public boolean close() {
allLeaks.remove(this, LeakEntry.INSTANCE);
}
public boolean close(T trackedObject) {
close() && trackedObject != null;
}
一方面 ResourceLeakDetector 内存放 PhantomReference 的不再是链表,而是一个 ConcurrentHashMap。并发时清理 PhantomReference 效率有所提升。
再一个就是对使用者来说,都要使用带参数的 close,即需要将被保护的 Resource 传入 close。LeakAwareResource 需要改为:
class LeakAwareResource implements Resource {
// .... 与之前一致
@Override
public boolean close() {
boolean closed = this.rsc.close()
if (closed){
// 只改了这里
this.leak.close(this.rsc);
}
return closed;
}
}
从而保证在执行 boolean closed = this.rsc.close()
后,this.rsc
不能被清理,因为 this.leak.close(this.rsc)
还要用到 this.rsc
。并且在 ResourceLeak 内, 执行完 allLeaks.remove(this, LeakEntry.INSTANCE);
后因为还要检查 trackedObject != null
,所以在 ResourceLeak 从 ResourceLeakDetector 的 static 的 ConcurrentHashMap 移除之前,被保护的 Resource 也就是 trackedObject 不能被 GC 掉。
是不是有点感觉不可靠?因为这个修复最关键的点就是 close() && trackedObject != null;
即 close()
之后通过再检查一下 trackedObject 是不是 null 来保证 trackedObject 不会在 close()
执行之前被 GC。有没有可能 JVM 一开始就判断 trackedObject != null 并将结果记录下来,再去执行 close() 从而又会出现 close() 执行之前就能判断出 trackedObject 再也用不上了,从而再次出现之前的 Bug。
个人理解是因为 close() && trackedObject != null;
这里用了 &&,JVM 不确定 && 后面的计算到底有多复杂,按照短路执行原则,应该先执行 close()
从而根据 close()
执行结果判断要不要再执行 trackedObject != null
。因为有短路执行机制存在,有可能执行完 close()
不用再执行 trackedObject != null
,省一次操作,所以 JVM 会先执行 close()
再执行 trackedObject != null
。
但是 trackedObject != null
毕竟是个非常简单的语句,不知道未来 JVM 有没有可能发现这个语句很简单而直接先执行了,好处是能将 trackedObject 提前 GC 掉。
参考
- 修复 ResourceLeakDetector 问题的 PR: Fix false-positives when using ResourceLeakDetector. by normanmaurer · Pull Request #6087 · netty/netty · GitHub
- 对 ResourceLeakDetector 性能优化的 PR,虽然本文没有说跟它相关的事情,但是确实挺值得看看的: Reduce performance overhead of ResourceLeakDetector by normanmaurer · Pull Request #7217 · netty/netty · GitHub
Copyright © 2015 Powered by MWeb, Theme used GitHub CSS.