堆外内存泄漏排查:Netty DirectByteBuffer 导致的服务宕机
本文是线上问题实战录系列的第 10 篇 叙事框架:
现象 → 排查过程 → 根因 → 修复 → 预防
问题现象
某周三上午 10:17,告警群弹出网关服务 gateway-service 进程退出的告警。这已经是本月第三次——每次重启后撑 2-3 天就会 OOM,加堆内存也只是从 3 天延长到 5 天。

更让人不安的是堆内存只用了 40% 多,但 RES 远超堆大小。
排查过程
第一步:确认异常——RES 远大于堆
登录 gw-prod-02 查看进程状态:

堆设了 256MB,MaxDirectMemorySize 也是 256MB。但 top 看到 RES 已经 1.2GB:

jstat 更清晰:老年代 O 区 42.17%,FullGC 才 3 次——堆内根本没问题。内存肯定在堆外。
第二步:pmap 查内存映射——64MB 匿名块成片
$ pmap -x 28765 | head -40

15 块 64MB 匿名映射,每块都是 rw--- [anon] 且 RSS ≈ 65MB,加起来 960MB。典型 DirectByteBuffer 特征——JVM 每次分配 DirectBuffer 都会 mmap 一段地址空间。
第三步:NMT 锁定 DirectBuffer
$ jcmd 28765 VM.native_memory summary scale=MB

- Java Heap:256MB committed(正常)
- DirectBuffer:890MB committed(远超 256MB 的 MaxDirectMemorySize)
加上 summary.diff 观察增长趋势——比上次采样多了 267MB,还在涨。
第四步:Netty LeakDetector 定位到 Handler
$ grep -c 'LEAK' /opt/gateway/logs/app.log
2847
28 小时内 2847 次泄漏。每条泄漏日志都指向同一个 Handler:
LEAK: ByteBuf.release() was not called before it's garbage-collected.
Recent access records:
#2: com.opencao.gateway.handler.RequestTransformHandler.channelRead(...)
Created at:
io.netty.buffer.AbstractByteBufAllocator.ioBuffer(...)
com.opencao.gateway.handler.RequestTransformHandler.channelRead(...)

根因分析
为什么泄漏
public class RequestTransformHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg; // ← Netty 传入的 ByteBuf
ByteBuf transformed = transform(buf);
ctx.fireChannelRead(transformed); // ← 继续传播
} // ← buf 从未 release()!
}

Netty 的 ByteBuf 使用引用计数管理生命周期(类似 C++ shared_ptr)。每 channelRead 收到的 ByteBuf 引用计数为 1,Handler 消费完后必须 release() 归还给内存池。
这个 Handler 只调用了 input.readBytes(bytes) 读取数据,然后创建了一个新的 DirectByteBuf result 传下去——但原始的 buf 引用计数从未减 1。
每次请求泄漏一个 DirectByteBuf(平均 4KB-32KB),28 小时累积 2847 次,890MB 堆外内存就这么被吃光了。
为什么堆内存监控没报警
因为泄漏在内存在堆外。Heap 监控显示老年代 42%、FullGC 正常、YGC 平稳——所有指标都是绿色的。但 RES 在悄悄爬升,等你发现时已经 1.2GB 了。
为什么加堆内存没用
因为泄漏在 Direct Memory,不在堆内。加 -Xmx 只是让堆内回收变慢,堆外的 DirectByteBuffer 该漏还是漏。你从 256M 加到 512M 甚至 1G,只是推迟了堆外打满的时间。
为什么测试环境没发现
测试流量小,DirectByteBuffer 即使泄漏也能被 GC 连带回收——JVM 在 GC 时会顺便回收不再引用的 DirectByteBuffer。但生产流量大,泄漏速度远超 GC 回收速度,积少成多。
修复方案
核心修复只有两行:在 finally 中调用 buf.release()。
public class RequestTransformHandlerFixed extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
ByteBuf transformed = transform(buf);
ctx.fireChannelRead(transformed);
} finally {
buf.release(); // ← 关键:确保 release()
}
}
}

验证结果
修复后开启 Netty LeakDetector:
-Dio.netty.leakDetectionLevel=advanced
观察 24 小时,grep -c 'LEAK' 结果为 0。RES 稳定在 380MB 左右(堆 256MB + 正常 DirectBuffer 开销)。
避坑建议
1. 所有 ChannelHandler 必须遵循 Release 规则
| 场景 | 操作 | 示例 |
|---|---|---|
| 消费 msg 后不继续传播 | ReferenceCountUtil.release(msg) |
终端 Handler |
| 消费 msg 后继续传播 | msg.release() + 创建新 msg 或 ctx.fireChannelRead(msg) |
转换 Handler |
已通过 pipeline.addLast() 注册 |
检查每个 channelRead 是否有 release() |
代码审查 |
2. 生产环境开启 LeakDetector
-Dio.netty.leakDetectionLevel=advanced
默认是 disabled,生产上至少开到 simple(1% 采样),advanced 会记录完整调用栈用于定位。
3. 堆外内存也要监控
| 指标 | 命令 | 告警阈值 |
|---|---|---|
| RES / 堆比值 | top -p <pid> |
RES > 堆 × 2 |
| DirectBuffer | jcmd <pid> VM.native_memory summary |
DirectBuffer > MaxDirectMemorySize × 70% |
| 匿名内存 | pmap -x <pid> \| grep anon \| awk '{s+=$2} END {print s}' |
单进程 > 1GB |
4. 代码审查清单
凡自定义 ChannelInboundHandlerAdapter / ChannelOutboundHandlerAdapter,必须检查:
- channelRead 中的 msg 是否在某个路径上 release 了
- write 或 writeAndFlush 之后 ByteBuf 是否不需要再 release(Netty 会帮你 release)
- 异常路径是否也 release 了(用 try-finally 兜底)
附:完整命令清单
进程内存检查
top -p <pid> -b -n 1 # 查 RES / 堆内存比值
pmap -x <pid> | head -40 # 查看匿名内存块(64MB → DirectBuffer)
pmap -x <pid> | grep anon | awk '{s+=$2} END {print s/1024 " MB"}' # 匿名内存总量
cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable' # 系统内存
free -m # 系统内存概览
Native Memory Tracking
jcmd <pid> VM.native_memory summary scale=MB # NMT 总览
jcmd <pid> VM.native_memory summary.diff scale=MB # 对比上次变化
Netty LeakDetector
# 启动参数
-Dio.netty.leakDetectionLevel=advanced
# 查询泄漏次数
grep -c 'LEAK' /opt/gateway/logs/app.log
# 查看泄漏详情
grep -i 'LEAK\|ByteBuf.release' /opt/gateway/logs/app.log | head -20
GC 检查
jstat -gcutil <pid> 1000 5 # GC 统计(确认堆内是否正常)
📖 完整版带可复现 Demo → opencao.cn 📺 公众号「Ai拆代码的曹操」 🌟 知识星球「源阅会」(82877104)
复现环境:JDK 17 / Spring Boot 3.2 / Netty (via WebFlux) 复现时间:2025-08-13 截图生成工具:tools/server-mockup.html + tools/chat-mockup.html + tools/code-mockup.html