直接内存暴涨但堆内存正常,基本可判定为netty directbytebuffer未释放;需开启paranoid级泄漏检测、关闭非核心日志、排查channelread中未release的bytebuf及duplicate/slice后引用计数管理不当等问题。

直接内存暴涨但堆内存正常,先确认是不是Netty泄漏
现象上,rss远超-Xmx、日志频繁出现OutOfDirectMemoryError、failed to allocate X bytes of direct memory,但jstat或VisualVM看堆内存平稳——这基本能排除JVM堆泄漏,重点就是Netty的DirectByteBuffer没释放。
注意:不是所有堆外增长都是Netty泄漏。比如Log4j2同步写控制台(尤其ConsoleAppender未关闭)、gRPC native库、甚至JDK自身Unsafe.allocateMemory调用都可能抢走DirectMemory。先做减法:临时关掉非核心日志输出、确认没混用其他NIO框架,再聚焦Netty。
开启ResourceLeakDetector必须设paranoid级别
Netty的泄漏检测默认是simple(1%抽样),线上问题往往漏检。只有-Dio.netty.leakDetectionLevel=paranoid才100%跟踪每个ByteBuf生命周期,这是定位泄漏的刚性前提。
-
disabled:完全关闭,别用 -
simple:只记录分配栈,不追踪释放,基本无效 -
advanced:记录分配+部分释放路径,适合压测时快速筛查 -
paranoid:每次分配都注册ResourceLeakTracker,性能下降20%~30%,但线上复现阶段值得
顺带加-Dio.netty.allocator.type=unpooled可绕过内存池干扰,让泄漏更“干净”地暴露——但仅限排查期,不能长期开着,否则吞吐暴跌。
看到LEAK日志后,重点盯channelRead里没release()的ByteBuf
典型泄漏代码长这样:
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof ByteBuf) {
ByteBuf buf = (ByteBuf) msg;
// ❌ 忘了 release,或者只在 try 里 release,catch 里没兜住
process(buf); // 假设这里抛异常了
buf.release(); // 这行根本执行不到
}
}
更隐蔽的是duplicate()/slice()后没retain()就传给异步线程:
-
duplicate()和slice()共享底层内存和引用计数,原ByteBuf一release(),副本立刻变freed - 异步任务里用
buf.retain()保活,任务结束再buf.release() - 任何
ctx.write()后,如果没用ctx.writeAndFlush()或后续Handler没处理完,原msg仍需你负责释放
别信“我用了SimpleChannelInboundHandler就安全”
它只在channelRead0()返回后自动release()入参msg,但前提是:你没在方法里把msg存到静态集合、没传给线程池、没在异常分支里提前return。
常见翻车点:
- 在
channelRead0()里做了耗时操作(如DB查询),结果线程被中断,msg没走到自动释放点 - 用
ConcurrentHashMap缓存ByteBuf做重试,忘了release()时机 - Handler链中某个自定义Handler调用了
ctx.fireChannelRead()但自己又保留了msg引用
最稳的做法:关掉自动释放,全程手动管理——在channelRead()里显式if (msg instanceof ReferenceCounted) ((ReferenceCounted) msg).release(),哪怕多写几行,也比半夜被报警叫醒强。
真正难的不是发现LEAK日志,而是确认那个栈帧里的ByteBuf到底被谁持有、为什么没释放。有时候泄漏点在第三方SDK的Netty封装层里,得靠反射监控PlatformDependent.directMemory()实时值,边压测边看哪次请求后数字卡住不降——这种细节,文档从不提,但线上救火时全靠它。










