本文是线上问题实战录 系列的第 10 篇 叙事框架:
现象 → 排查过程 → 根因 → 修复 → 预防
问题现象
实战分享:一个让加内存都解决不了的 OOM 问题。月初接了一个奇怪的告警------网关服务进程退出,堆设了 256MB 才用了 42%,但 RES 到了 1.2GB。第一次 OOM,加堆到 512MB,以为解决了。结果 5 天后又崩了。内存到底去哪了?排查分成四个阶段:pmap 看到大量 64MB 匿名块 → JMX 确认 direct buffer 超标 → NMT(Native Memory Tracking)开起来跟踪原生内存 → 最后用 gperftools 抓到分配栈。罪魁祸首是 Netty 的 ByteBuf:收到 WebSocket 消息时连续调用了两次 buffer() 分配 DirectByteBuf,但只有一次 release()。Netty 使用引用计数管理 DirectByteBuffer,引用计数没归零就不会释放。修复后 RES 降到 300MB,再也没 OOM 过。堆外内存的排查比堆内麻烦得多,但掌握 pmap + NMT + gperftools 这三板斧,基本够用了。
排查过程
第一步:确认异常------RES 远大于堆
登录 gw-prod-02 查看进程状态:

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

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

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

- Java Heap:256MB committed(正常)
- DirectBuffer:890MB committed(远超 256MB 的 MaxDirectMemorySize)
加上 summary.diff 观察增长趋势------比上次采样多了 267MB,还在涨。
第四步:Netty LeakDetector 定位到 Handler
bash
$ grep -c 'LEAK' /opt/gateway/logs/app.log
2847
28 小时内 2847 次泄漏。每条泄漏日志都指向同一个 Handler:
bash
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(...)

根因分析
为什么泄漏
java
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()。
java
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:
bash
-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
bash
-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 | grep anon | awk '{s+=$2} END {print s}'` | 单进程 > 1GB |
4. 代码审查清单
凡自定义 ChannelInboundHandlerAdapter / ChannelOutboundHandlerAdapter,必须检查:
channelRead中的 msg 是否在某个路径上 release 了write或writeAndFlush之后ByteBuf是否不需要再 release(Netty 会帮你 release)- 异常路径是否也 release 了(用
try-finally兜底)
附:完整命令清单
进程内存检查
bash
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
bash
jcmd <pid> VM.native_memory summary scale=MB # NMT 总览
jcmd <pid> VM.native_memory summary.diff scale=MB # 对比上次变化
Netty LeakDetector
bash
# 启动参数
-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 检查
bash
jstat -gcutil <pid> 1000 5 # GC 统计(确认堆内是否正常)