因为 Netty 大量使用 ByteBuf,如果 ByteBuf 出现泄露,则服务很容易出现 OOM。Netty 中的ResourceLeakDetector就是为解决该问题而生,它记录 Netty 使用的各种 ByteBuf 的使用,能对占用资源的对象进行监控。无论是 Pooled 还是 Unpooled,无论是 Direct 还是 Heap,所有的 ByteBuf 都要被 ResourceLeakDetector 记录起来。从而在开发者出现忘记为 ByteBuf 调用 release 的时候,通过日志告知开发者有泄露,要求开发者来排查问题。
为了解决网卡内核态与应用用户态之间零复制,Netty支持海量连接,高性能而堆外内存是重要贡献之一。它带来的好处有:无GC、不受堆内存大小限制。
堆内内存 | 堆外内存 | |
底层实现 | JVM 内存 | unsafe.allocateMemory(size)分配直接内存 |
大小限制 | -Xms-Xmx 配置的 JVM 内存相关 | 可以通过 -XX:MaxDirectMemorySize 参数从 JVM 层面去限制,同时受到机器虚拟内存(说物理内存不太准确)的限制 |
垃圾回收 | 略 | 当 DirectByteBuffer 不再被使用时,会出发内部 cleaner 的钩子, 保险起见,可以考虑手动回收:((DirectBuffer) buffer).cleaner().clean(); |
拷贝形态 | 用户态<->内核态 | 内核态 |
主要依赖UNSAFE 魔法类实现的:
//无论是堆内和堆外都可以实现内存之间的拷贝
ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);
它首先向Bits类申请额度,Bits类有一个全局的 totalCapacity变量,记录着全部DirectByteBuffer的总大小,每次申请,都先看看是否超限,而堆外内存的限额默认与堆内内存(由-XMX 设定)相仿,可用 -XX:MaxDirectMemorySize 重新设定。
采用Non-direct ByteBuffer的流程:网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络
采用Direct ByteBuffer的流程:网络 –> 应用 Direct ByteBuffer –> 网络
它免去中间交换的内存拷贝, 提升IO处理速度,也就是常说的zero-copy!通常,Zero-Copy技术省去了将操作系统的read buffer拷贝到程序的buffer,以及从程序buffer拷贝到socket buffer的步骤,直接将read buffer拷贝到socket buffer。更多
JDK7开始,DirectByteBuffer分配内存时默认已不做分页对齐,不会再每次分配并清零 实际需要+分页大小(4k)的内存,这对性能应有较大提升,所以Oracle专门写在了Enhancements in Java I/O里。
但存在于堆内的DirectByteBuffer对象很小,只存着基地址和大小等几个属性,和一个Cleaner,但它代表着后面所分配的一大段内存。DirectByteBuffer内部的Cleaner的作用是清理动作(clean方法),清理执行时实际调用的是被绑定的Deallocator类(降低Bits里的totalCapacity,并调用Unsafe调free去释放内存),这个类可被重复执行,释放过了就不再释放 。简单的讲就是,堆内的DirectByteBuffer对象被GC时,它背后的堆外内存也会被回收。
GC历史回顾
当新生代满了,就会发生young gc;如果此时对象还没失效,就不会被回收;撑过几次young gc后,对象被迁移到老生代;当老生代也满了,就会发生full gc。
原来JDK除了StrongReference,SoftReference 和 WeakReference之外,还有一种PhantomReference,Phantom是幻影的意思,Cleaner就是PhantomReference的子类。
带来新问题
因为DirectByteBuffer本身的个头很小,只要熬过了young gc,即使已经失效了也能在老生代里舒服的呆着,不容易把老生代撑爆触发full gc,如果没有别的大块头进入老生代触发full gc,就一直在那耗着,占着一大片堆外内存不释放。
当GC时发现它除了PhantomReference外已不可达(持有它的DirectByteBuffer失效了),就会把它放进 Reference类pending list静态变量里。然后另有一条ReferenceHandler线程,名字叫 "Reference Handler"的,关注着这个pending list,如果看到有对象类型是Cleaner,就会执行它的clean(),其他类型就放入应用构造Reference时传入的ReferenceQueue中,这样应用的代码可以从Queue里拖出这些理论上已死的对象,这是一种比finalizer更轻量更好的机制。
怎么办呢?只能system.gc()来救场,万一设置了-DisableExplicitGC禁止了system.gc(),那就恐怖了。
Netty骄傲的地方就是内存池的管理,从4.0版本开始经常变改,虽是直接内存IO框架的绝配,但直接内存的分配销毁不易,所以使用内存池能大幅提高性能,也告别了频繁的GC。Netty里四种主力的ByteBuf:
注:《Netty权威指南》说 PooledDirectByteBuf 对DirectByteBuffer进行重用性能提升了23倍
Netty ByteBuf需要在JVM的GC机制之外,有自己的引用计数器和回收过程。
在C时代,我们喜欢让malloc和free成对出现,而在Netty里,因为Handler链的存在,ByteBuf经常要传递到下一个Hanlder去而不复还,所以规则变成了谁是最后使用者,谁负责释放。
InBound Message
在AbstractNioByteChannel.NioByteUnsafe.read() 处创建了ByteBuf并调用 pipeline.fireChannelRead(byteBuf) 送入Handler链,因此,每个Handler对消息可能有三种处理方式:
假设每一个Handler都把消息往下传,Handler并也不知道谁是Handler链的最后一员,所以Netty在Handler链的最末补了一个TailHandler,如果此时消息仍然是ReferenceCounted类型就会被release掉。
OutBound Message
要发送的消息由应用所创建,并调用 ctx.writeAndFlush(msg) 进入Handler链。在每个Handler中的处理类似InBound Message,最后消息会来到HeadHandler,再经过一轮复杂的调用,在flush完成后终将被release掉。
异常时如何释放?
各种异常情况下ByteBuf没有成功传递到下一个Hanlder,还在自己地界里的话,一定要进行释放。多层的异常处理机制,可以在释放前加上引用计数大于0的判断避免释放失败,也可以可以循环调用reelase()直到返回true。
所谓内存泄漏,主要是针对池化的ByteBuf,ByteBuf对象被JVM GC掉之前,没有调用release()把底下的DirectByteBuffer或byte[]归还到池里,会导致池越来越大。
它支持的设置参数:
ResourceLeakDetector的版本也一直在更新
该如何设置呢?
建议要对使用的特性进行充足的单元测试,同时将内存泄漏检查级别开到最高,然后每个用例执行完就System.gc()一次,关注的logger有没有输出memory leak信息(log有出现 "LEAK: "字样)。
修复 ResourceLeakDetector 问题的 PR
对 ResourceLeakDetector 性能优化的 PR
Netty官方文档翻译】引用计数对象(reference counted objects)
内存池的算法