相关链接:
《Reference counted objects》
《Why do we need to manually handle reference counting for Netty ByteBuf if JVM GC is still in place?》
《Are Java DirectByteBuffer wrappers garbage collected?》
从 版本4 开始,Netty 中某些对象的生命周期管理是通过它们的引用计数实现的。
既然 JVM 有自己的垃圾回收机制,为什么 Netty 还要再额外采用这种机制来回收对象?
简单地说,为了提高堆外内存的垃圾回收效率。
因为 GC 和 引用队列(ReferenceQueue) 无法保证高效实时的“不可达对象”处理机制。
而通过引用计数机制,一旦这些对象(或它们所占用的共享资源)不再被使用,就可以立刻将其返还给对象池。
DirectBuffer 用到了 JVM 堆外内存,这些空间并不是直接由GC回收的。
当它内部字段 Cleaner 被回收(即将进入引用队列)时,会被特殊处理 —— 调用 Cleaner.clean()。
该方法最终调用 Unsafe.freeMemory() 回收这些堆外内存。
这是一个非常迂回的过程,效率当然受影响。
Reference.tryHandlePending():
Java代码
// Fast path for cleaners
if (c != null) {
c.clean();
return true;
}
DirectByteBuffer#Deallocator.run():
Java代码
unsafe.freeMemory(address);
Bits.unreserveMemory(size, capacity);
内核无法直接读写堆内存。所以常规 IO 操作中 数据需要在堆内存和堆外内存之间拷贝(用户态与内核态的切换)。
Netty 直接使用堆外内存(Direct Memory)的最大好处就是避免此类拷贝操作。
因为分配堆外内存的开销比堆内存高,所以 Netty 引入了“池”的概念。(堆内存也是个池。)
类似于线程池:因为创建销毁线程的成本太高,所以搞了个“池”。
其实“对象池”在Java中一直是个有争议的话题。尤其是对于那些“只占用内存,不占用外部资源”的对象。
根据实际情况来看,Netty 采用池化机制是值得的,尤其是在分配较大的缓冲区时。
但是 Netty 的“池化+引用计数”机制带来高性能的同时,也带来一些不便。
如果未正确设置引用计数,还会引发内存泄漏。
如,某个对象占用了一块堆外内存,且代码中未正确设置其引用计数;
那么当该对象不可达时,GC会将其回收,但不回收对应的堆外内存,
因为 GC 感知不到 Netty 的这套引用计数机制;
而相应的引用计数直接大于0,无法触发 Netty 回收此堆外内存,这就引发内存泄漏。
因此我们需遵守一些最佳实践来规避错误,并结合 Netty 的缓冲区泄漏检查机制进行排障。
Netty 中的 引用计数对象 都实现了接口 ReferenceCounted。为了方便,下文用“对象”指代它们。
新创建的对象,其引用计数值为 1。
调用对象的 retain() 方法一次,计数值会 增加 1。
调用对象的 release() 方法一次,计数值会 减 1。
当该数值降到 0 时,相应的资源会被回收到 Netty 的对象池。
如果一个对象的引用计数降到了 0,那么访问该对象会引发异常 IllegalReferenceCountExeception。
一般的经验法则是:最后访问对象的一方负责销毁对象。
具体来说就是:
假设一个组件(发送方)将一个 引用计数对象 传给另一个组件(接收方),
那么发送方不需要销毁对象,可以将此操作推迟,交由接收方负责。
如果一个组件消费了一个 引用计数对象,且知道后续不会有任何其它组件访问该对象(当然也不会将对象传给另一个组件),
那么该组件需要负责销毁它。即,调用对象的 release() 方法。
示例:
Java代码
ByteBuf a(ByteBuf input) {
input.writeByte(42);
return input;
}
ByteBuf b(ByteBuf input) {
try {
output = input.alloc().directBuffer(input.readableBytes() + 1);
output.writeBytes(input);
output.writeByte(42);
return output();
} finally {
input.release();
}
}
void c(ByteBuf input) {
System.out.println(input);
input.release();
}
void main() {
...
ByteBuf buf = ...;
c(b(a(buf)));
assert buf.refCnt() == 0;
}
操作 | 谁应该调用 release | 谁调用了 release |
1. main() 创建了 buf | main() 负责 release buf | |
2. main() 调用 a(),参数为 buf | a() 负责 release buf | |
3. a() 只是返回了 buf | main() 负责 release buf | |
4. main() 调用了 b(),参数为 buf | b() 负责 release buf | |
5. b() 返回了 buf 的副本 | b() 负责 release buf, main() 负责 release 副本 |
b() 调用了 buf.release() |
6. main() 调用了 c(),参数为副本 | c() 负责 release 副本 | |
7. c() 吞掉了副本 | c() 负责 release 副本 | c() 调用了副本的 release() 方法 |
ByteBuf.duplicate()、ByteBuf.slice()、ByteBuf.order(ByteOrder) 等方法会创建一个 “派生”的缓冲区对象,该对象与原对象共享同一块内存区域。“派生缓冲区”并没有自己的引用计数,它与原对象共享同一个引用计数。
相反,ByteBuf.copy() 和 ByteBuf.readBytes(int) 不会“派生”缓冲区。它们返回的 buffer 对象需要另行release。
注意:父Buffer 与 其派生出的Buffer 共享同一个 引用计数,且派生Buffer被创建时引用计数值不会增加。因此,如果你需要将一个 派生Buffer 传给另一个组件,你需要先增加其引用计数 —— 调用 retain() 方法。
因为接收方会认为它是Buffer的最终消费者,调用对象 release() 方法,这样就导致对象过早被回收。
示例:
Java代码
ByteBuf parent = ctx.alloc().directBuffer(512);
parent.writeBytes(...);
try {
while (parent.isReadable(16)) {
ByteBuf derived = parent.readSlice(16);
derived.retain();
process(derived);
}
} finally {
parent.release();
}
...
void process(ByteBuf buf) {
...
buf.release();
}
有时候,ByteBuf 是包含在一个 ByteBufHolder 对象中的。
如,DatagramPacket、HttpContent、WebSocketFrame 都实现了该接口。
这些 holder 对象与它内部的 ByteBuf 共享引用计数,所以应像 派生Buffer 那样处理它们。
当一个 EventLoop 将数据读取到 ByteBuf,触发 channelRead() 事件时,相应管道(Pipeline)中的 ChannelHandler 负责释放该 Buffer。也就是说,消费这些入站数据的 handler 应该在其 channelRead() 方法中调用 release() 方法:
Java代码
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
...
} finally {
buf.release();
}
}
但是正如前文“谁负责销毁”中所说,如果你的 handler 将 buffer 之类的引用计数对象 传给了下一个 handler,那么当前 handler 就不需要调用 release()。
注意:ByteBuf 并不是 Netty 中唯一的 引用计数对象 类型。
如果你无法确定一个对象是否为 引用计数对象,或简化相关判断代码,可以直接调用帮助类方法:
ReferenceCountUtil.release():
Java代码
public static boolean release(Object msg) {
if (msg instanceof ReferenceCounted) {
return ((ReferenceCounted) msg).release();
}
return false;
}
当然,你也可以让你的 handler 继承自 SimpleChannelHandler,这个类会针对所有收到的 message 对象调用上述帮助类方法。
与 入站消息 不同,出站消息 是你的应用程序创建的,它们由 Netty 负责在完成消息发送后进行释放。
但是中间进行拦截处理的 自定义Handler 需要自行释放中间对象。
Java代码
// 直接写消息
void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
System.err.println("Writing: " + message);
ctx.write(message, promise);
}
// 把原始数据转换后再写
void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
if (message instanceof HttpContent) {
// 转换后再写
HttpContent content = (HttpContent) message;
try {
ByteBuf transformed = ctx.alloc().buffer();
...
ctx.write(transformed, promise);
} finally {
// 释放原缓存区
content.release();
}
} else {
// 对于其它类型的内容,直接写
ctx.write(message, promise);
}
}
手动维护引用计数的实践难度比较高。
对初学者而言会比 C/C++ 的主动释放内存更麻烦。CompositeByteBuf 中的对 分片Component 的释放处理就比较复杂。
如果处理不好就可能引发内存泄漏。
Netty 的设计也考虑到了这些问题,它提供了一套应对方案。
默认情况下,Netty 会对 1% 的缓冲区对象进行采样,并输出内存泄漏情况的日志。如:
LEAK: ByteBuf.release() was not called before it's garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option '-Dio.netty.leakDetectionLevel=advanced' or call ResourceLeakDetector.setLevel()
如果以下指定 JVM 启动参数,则可以得到更详细的检测日志输出,告诉你
最近访问目标对象的代码行 和 最后访问目标对象的Handler。(可能就是在该 Handler 中忘了调用 release() 方法)
Shell代码
-Dio.netty.leakDetectionLevel=advanced
Netty 一共有 4个 泄漏检测等级,可参照上述示例中的 JVM 启动参数进行设置。
Disabled:禁用泄漏检测。不推荐使用。
Simple:对 1% 的缓存区对象进行检测。默认设置。
Advanced:对 1% 的缓存区对象进行检测,并输出访问代码行。
Paranoid:对 所有 缓存区对象进行检测,并输出访问代码行。一般在自动化测试阶段使用。
【测试】单元测试 和 集成测试 时 Paranoid 和 Simple 检测等级都要执行。
【发布】用 Simple 检测等级进行 金丝雀发布,并持续运行合理的较长时间。
【排障】如果检测到泄漏,则在 Advanced 等级下重新金丝雀发布,以获得更多异常提示。
【不要一把梭】不要将存在泄漏问题的程序完全发布。即,使用金丝雀发布,不要 all in。
在编写单元测试时很容易忘记释放缓冲区。这会产生泄漏警告,但并不意味着被测程序存在泄漏。虽然可以用 try-finally 包揽释放所有缓存区对象,但不够优雅。
你可以使用帮助类方法 ReferenceCountUtil.releaseLater() 来处理这些测试专用对象。
releaseLater() 方法会将 释放目标对象的操作 包装成一个 Task;
当前线程 和 此Task 会被注册到 Netty 的一个后台线程中;
该后台线程会轮询这些注册项;如果检测到 注册的线程已终止,则执行相应的Task,释放相应的对象。
两次轮询操作 间隔1秒;当然是按需执行;当不存在注册项或所有注册项被处理完后,后台线程会退出。
详见 ThreadDeathWatcher
使用示例:
Java代码
@Test
public void testSomething() {
ByteBuf dummyBuf = Unpooled.directBuffer(512);
ByteBuf buf = ReferenceCountUtil.releaseLater(dummyBuf);
...
}