如何排查和检测 Netty 中的堆外内存泄漏问题?
在 Netty 中,为了提高 I/O 性能和实现零拷贝,通常会大量使用堆外内存(Direct Memory)来分配 ByteBuf。由于堆外内存不受 JVM 垃圾回收器(GC)的直接管理,Netty 引入了引用计数(Reference Counting)机制。如果在代码中忘记调用 release(),就会导致堆外内存泄漏。
排查和检测 Netty 堆外内存泄漏通常分为以下几个阶段:现象确认 -> 开启 Netty 内置检测 -> 使用工具深度排查 -> 代码审查。
第一阶段:确认是否为堆外内存泄漏
在动手排查前,需要确认究竟是堆内泄漏、堆外泄漏,还是其他 Native 内存泄漏。
- 观察现象:
- 操作系统层面:通过
top命令发现 Java 进程的 RES(物理内存)持续飙升。 - JVM 层面:通过
jstat -gc <pid>或监控工具观察,Heap(堆内存)使用平稳,Full GC 后堆内存能正常回收,但进程总内存依然在涨。
- 操作系统层面:通过
- 报错特征:
- 最终系统可能会触发 Linux 的 OOM Killer 杀掉进程(
dmesg -T | grep -i oom)。 - 如果配置了
-XX:MaxDirectMemorySize,应用可能会抛出java.lang.OutOfMemoryError: Direct buffer memory。
- 最终系统可能会触发 Linux 的 OOM Killer 杀掉进程(
第二阶段:使用 Netty 内置的泄漏检测器(最有效的方法)
Netty 提供了一个非常强大的内置工具:ResourceLeakDetector。这是排查 Netty 内存泄漏首选且最有效的手段。
1. 设置泄漏检测级别
通过启动参数 -Dio.netty.leakDetection.level 或者代码配置开启检测。Netty 提供了 4 个级别:
- DISABLED:禁用检测。
- SIMPLE(默认):抽样检测 1% 的 ByteBuf,只报告是否发生泄漏,不提供泄漏位置。
- ADVANCED:抽样检测 1% 的 ByteBuf,报告泄漏并提供发生泄漏时的对象创建和访问的堆栈信息。
- PARANOID:100% 检测所有的 ByteBuf,报告堆栈信息。对性能影响很大,仅限测试/排查环境使用。
操作建议:
在测试环境或复现环境,直接加上启动参数:
-Dio.netty.leakDetection.level=PARANOID
如果在生产环境排查,且流量很大,可以使用:
-Dio.netty.leakDetection.level=ADVANCED
2. 观察日志
开启后,运行业务触发内存分配。如果发生泄漏,Netty 会在日志中打印出 ERROR 级别的警告,关键字为 LEAK: ByteBuf.release() was not called。
日志示例:
[ERROR] io.netty.util.ResourceLeakDetector - LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
# 顺着堆栈往下看,能精准定位到是哪一行代码创建了 ByteBuf 或者最后操作了 ByteBuf 却没有 release
at com.example.MyHandler.channelRead(MyHandler.java:35)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
...
根据这段日志提供的精确堆栈,90% 的 Netty 内存泄漏问题都可以直接定位并修复。
第三阶段:借助外部工具深度排查(当内置工具失效时)
如果 Netty 的检测器没有报警(可能是使用了底层的 JDK ByteBuffer 而非 Netty 的 ByteBuf,或者泄漏的对象一直被强引用着导致 GC 无法触发探测),需要使用 JVM 和系统工具。
1. NMT (Native Memory Tracking)
排查 JVM 进程所有堆外内存分配情况。
- 启动参数加上:
-XX:NativeMemoryTracking=detail - 运行一段时间后打基准:
jcmd <pid> VM.native_memory baseline - 再运行一段时间后对比:
jcmd <pid> VM.native_memory detail.diff - 注意:NMT 主要看
Internal或Direct区域是否有明显增长。
2. 使用 Arthas (阿里开源诊断工具)
Arthas 在生产环境极其好用。
- 查看直接内存占用:查看bash
[arthas@pid]$ memorydirect区域的内存消耗。 - 查看 JMX 中的 DirectBuffer MBean:关注bash
[arthas@pid]$ mbean java.nio:type=BufferPool,name=directMemoryUsed和Count(如果 Count 狂飙说明泄漏)。
3. Heap Dump (堆快照分析)
虽然是堆外内存泄漏,但堆外内存的引用对象(如 DirectByteBuffer 或 Netty 的 PooledUnsafeDirectByteBuf)是分配在堆内的。
- 导出堆快照:
jmap -dump:format=b,file=heap.hprof <pid> - 使用 MAT (Memory Analyzer Tool) 或 VisualVM 打开。
- 搜索
io.netty.buffer.PooledUnsafeDirectByteBuf或java.nio.DirectByteBuffer类的实例。 - 通过 GC Roots (Path to GC Roots) 查看这些对象被谁引用。如果发现大量 ByteBuf 被放入了某个全局
List、Map或队列中忘记清理,这就找到了泄漏源。
第四阶段:常见代码层面的泄漏原因及排查点
在代码审查时,重点关注以下几个容易出错的场景:
在
ChannelInboundHandlerAdapter中忘记释放- 如果重写了
channelRead,并且没有将消息传递给下一个 Handler (ctx.fireChannelRead(msg)),必须手动释放:ReferenceCountUtil.release(msg); - 建议:如果仅仅是处理消息不再传递,继承
SimpleChannelInboundHandler更好,它会在channelRead0执行完毕后自动帮你释放。
- 如果重写了
异常处理导致未释放
- 代码在处理
ByteBuf时抛出了异常,导致后面的release()没有执行。 - 规范写法:java
ByteBuf buf = ...; try { // 处理业务 } finally { ReferenceCountUtil.release(buf); // 务必在 finally 中释放 }
- 代码在处理
异步线程/队列处理遗漏
- 将 Netty 接收到的
ByteBuf丢入自定义的业务线程池或BlockingQueue中异步处理。处理完成后忘记了release()。
- 将 Netty 接收到的
重复的
retain()没有配对的release()- 每次调用
buf.retain()会让引用计数 +1,必须确保有对应次数的release()让计数归零,否则底层堆外内存永远不会归还给 OS/内存池。
- 每次调用
ByteBuf切片 (Slice/Duplicate) 误解buf.slice()或buf.duplicate()返回的新 ByteBuf 与原 ByteBuf 共享底层内存,且不会改变引用计数。如果对派生的 buf 做了retain(),必须要显式释放。
总结排查路径:
- 先看监控,确定是进程内存飙升而 Heap 正常(确诊堆外泄漏)。
- 立刻加上
-Dio.netty.leakDetection.level=PARANOID重启测试应用。 - 压测/跑业务,死盯日志寻找
LEAK:关键字及堆栈。 - 顺藤摸瓜修改代码(加上
try-finallyrelease)。 - 如果没日志,用
jcmd NMT或Arthas查看 DirectBuffer 指标,用 MAT 分析堆内存寻找ByteBuf堆内引用的聚集点。