Netty 引用计数对象

相关链接:

  • 《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?》

一些基本信息与疑问

为什么 Netty 要采用人工操作的引用计数机制?

从 版本4 开始,Netty 中某些对象的生命周期管理是通过它们的引用计数实现的。

既然 JVM 有自己的垃圾回收机制,为什么 Netty 还要再额外采用这种机制来回收对象?

简单地说,为了提高堆外内存的垃圾回收效率。

因为 GC 和 引用队列(ReferenceQueue) 无法保证高效实时的“不可达对象”处理机制。

而通过引用计数机制,一旦这些对象(或它们所占用的共享资源)不再被使用,就可以立刻将其返还给对象池。

 

堆外内存 DirectBuffer 是如何被回收的,为什么效率低?

DirectBuffer 用到了 JVM 堆外内存,这些空间并不是直接由GC回收的。

当它内部字段 Cleaner 被回收(即将进入引用队列)时,会被特殊处理 —— 调用 Cleaner.clean()。

该方法最终调用 Unsafe.freeMemory() 回收这些堆外内存。

这是一个非常迂回的过程,效率当然受影响。

Reference.tryHandlePending():

Java代码

 

  1. // Fast path for cleaners  

  2. if (c != null) {  

  3.   c.clean();  

  4.   return true;  

  5. }  

 

DirectByteBuffer#Deallocator.run():

Java代码

 

  1. unsafe.freeMemory(address);  

  2. 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 的对象池。

悬空引用(Dangling Reference)

如果一个对象的引用计数降到了 0,那么访问该对象会引发异常 IllegalReferenceCountExeception。

 

谁负责销毁“引用计数对象”?

一般的经验法则是:最后访问对象的一方负责销毁对象。

具体来说就是:

  • 假设一个组件(发送方)将一个 引用计数对象 传给另一个组件(接收方),

    那么发送方不需要销毁对象,可以将此操作推迟,交由接收方负责。

  • 如果一个组件消费了一个 引用计数对象,且知道后续不会有任何其它组件访问该对象(当然也不会将对象传给另一个组件),

    那么该组件需要负责销毁它。即,调用对象的 release() 方法。

示例:

Java代码

 

  1. ByteBuf a(ByteBuf input) {  

  2.   input.writeByte(42);  

  3.   return input;  

  4. }  

  5.   

  6. ByteBuf b(ByteBuf input) {  

  7.   try {  

  8.     output = input.alloc().directBuffer(input.readableBytes() + 1);  

  9.     output.writeBytes(input);  

  10.     output.writeByte(42);  

  11.     return output();  

  12.   } finally {  

  13.     input.release();  

  14.   }  

  15. }  

  16.   

  17. void c(ByteBuf input) {  

  18.   System.out.println(input);  

  19.   input.release();  

  20. }  

  21.   

  22. void main() {  

  23.   ...  

  24.   ByteBuf buf = ...;  

  25.   c(b(a(buf)));  

  26.   assert buf.refCnt() == 0;  

  27. }  

 操作 谁应该调用 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() 方法

 

 

派生缓存区(Derived Buffers)

ByteBuf.duplicate()、ByteBuf.slice()、ByteBuf.order(ByteOrder) 等方法会创建一个 “派生”的缓冲区对象,该对象与原对象共享同一块内存区域。“派生缓冲区”并没有自己的引用计数,它与原对象共享同一个引用计数。

相反,ByteBuf.copy() 和 ByteBuf.readBytes(int) 不会“派生”缓冲区。它们返回的 buffer 对象需要另行release。

注意:父Buffer 与 其派生出的Buffer 共享同一个 引用计数,且派生Buffer被创建时引用计数值不会增加。因此,如果你需要将一个 派生Buffer 传给另一个组件,你需要先增加其引用计数 —— 调用 retain() 方法。

因为接收方会认为它是Buffer的最终消费者,调用对象 release() 方法,这样就导致对象过早被回收。

示例:

Java代码

 

  1. ByteBuf parent = ctx.alloc().directBuffer(512);  

  2. parent.writeBytes(...);  

  3.   

  4. try {  

  5.   while (parent.isReadable(16)) {  

  6.     ByteBuf derived = parent.readSlice(16);  

  7.     derived.retain();  

  8.     process(derived);  

  9.   }  

  10. } finally {  

  11.   parent.release();  

  12. }  

  13. ...  

  14.   

  15. void process(ByteBuf buf) {  

  16.   ...  

  17.   buf.release();  

  18. }  

 

ByteBufHolder

有时候,ByteBuf 是包含在一个 ByteBufHolder 对象中的。

如,DatagramPacket、HttpContent、WebSocketFrame 都实现了该接口。

这些 holder 对象与它内部的 ByteBuf 共享引用计数,所以应像 派生Buffer 那样处理它们。

 

ChannelHandler 中的 引用计数

入站消息(Inbound Messages)

当一个 EventLoop 将数据读取到 ByteBuf,触发 channelRead() 事件时,相应管道(Pipeline)中的 ChannelHandler 负责释放该 Buffer。也就是说,消费这些入站数据的 handler 应该在其 channelRead() 方法中调用 release() 方法:

Java代码

 

  1. public void channelRead(ChannelHandlerContext ctx, Object msg) {  

  2.   ByteBuf buf = (ByteBuf) msg;  

  3.   try {  

  4.     ...  

  5.   } finally {  

  6.     buf.release();  

  7.   }  

  8. }  

 

但是正如前文“谁负责销毁”中所说,如果你的 handler 将 buffer 之类的引用计数对象 传给了下一个 handler,那么当前 handler 就不需要调用 release()。

注意:ByteBuf 并不是 Netty 中唯一的 引用计数对象 类型。

如果你无法确定一个对象是否为 引用计数对象,或简化相关判断代码,可以直接调用帮助类方法:

ReferenceCountUtil.release():

Java代码

 

  1. public static boolean release(Object msg) {  

  2.   if (msg instanceof ReferenceCounted) {  

  3.     return ((ReferenceCounted) msg).release();  

  4.   }  

  5.   return false;  

  6. }  

 

当然,你也可以让你的 handler 继承自 SimpleChannelHandler,这个类会针对所有收到的 message 对象调用上述帮助类方法。

 

出站消息(Outbound Messages)

与 入站消息 不同,出站消息 是你的应用程序创建的,它们由 Netty 负责在完成消息发送后进行释放。

但是中间进行拦截处理的 自定义Handler 需要自行释放中间对象。

Java代码

 

  1. // 直接写消息  

  2. void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {  

  3.   System.err.println("Writing: " + message);  

  4.   ctx.write(message, promise);  

  5. }  

  6.   

  7. // 把原始数据转换后再写  

  8. void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {  

  9.   if (message instanceof HttpContent) {  

  10.     // 转换后再写  

  11.     HttpContent content = (HttpContent) message;  

  12.     try {  

  13.       ByteBuf transformed = ctx.alloc().buffer();  

  14.       ...  

  15.       ctx.write(transformed, promise);  

  16.     } finally {  

  17.       // 释放原缓存区  

  18.       content.release();  

  19.     }  

  20.   } else {  

  21.     // 对于其它类型的内容,直接写  

  22.     ctx.write(message, promise);  

  23.   }  

  24. }  

 

缓存区泄漏 故障排查

手动维护引用计数的实践难度比较高。

对初学者而言会比 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代码

 

  1. -Dio.netty.leakDetectionLevel=advanced  

 

“泄漏检测”的等级

Netty 一共有 4个 泄漏检测等级,可参照上述示例中的 JVM 启动参数进行设置。

  • Disabled:禁用泄漏检测。不推荐使用。

  • Simple:对 1% 的缓存区对象进行检测。默认设置。

  • Advanced:对 1% 的缓存区对象进行检测,并输出访问代码行。

  • Paranoid:对 所有 缓存区对象进行检测,并输出访问代码行。一般在自动化测试阶段使用。

 

防止泄漏的最佳实践

  • 【测试】单元测试 和 集成测试 时 Paranoid 和 Simple 检测等级都要执行。

  • 【发布】用 Simple 检测等级进行 金丝雀发布,并持续运行合理的较长时间。

  • 【排障】如果检测到泄漏,则在 Advanced 等级下重新金丝雀发布,以获得更多异常提示。

  • 【不要一把梭】不要将存在泄漏问题的程序完全发布。即,使用金丝雀发布,不要 all in。

 

单元测试中的泄漏

在编写单元测试时很容易忘记释放缓冲区。这会产生泄漏警告,但并不意味着被测程序存在泄漏。虽然可以用 try-finally 包揽释放所有缓存区对象,但不够优雅。

你可以使用帮助类方法 ReferenceCountUtil.releaseLater() 来处理这些测试专用对象。

releaseLater() 的原理

  • releaseLater() 方法会将 释放目标对象的操作 包装成一个 Task;

  • 当前线程 和 此Task 会被注册到 Netty 的一个后台线程中;

  • 该后台线程会轮询这些注册项;如果检测到 注册的线程已终止,则执行相应的Task,释放相应的对象。

  • 两次轮询操作 间隔1秒;当然是按需执行;当不存在注册项或所有注册项被处理完后,后台线程会退出。

详见 ThreadDeathWatcher

 

使用示例:

Java代码

 

  1. @Test  

  2. public void testSomething() {  

  3.   ByteBuf dummyBuf = Unpooled.directBuffer(512);  

  4.   ByteBuf buf = ReferenceCountUtil.releaseLater(dummyBuf);  

  5.   ...  

  6. }  

 

你可能感兴趣的:(Java,Netty)