周一早上九点,我刚坐下,运维同事发来一张截图:推送网关的一个容器,凌晨 3:12 被 OOMKilled,重启了。他很有礼貌地问:“杰哥,你们的服务是不是内存泄漏?”我打开监控,堆使用率曲线像一条睡着的猫——稳稳地趴在 40% 附近,GC 也很悠闲。
我说:“不可能,堆才用了 800MB,Xmx 给了 2G。”他说:“可是容器限制 4G,进程 RSS 已经涨到 4G 了。”我沉默了几秒,然后意识到一个事实:Java 进程的内存,从来不等于堆。这篇记录我们怎么找到那块“看不见的内存”,以及一个忘了 release() 的 ByteBuf 是怎么让容器每两天死一次的。
一、现象:堆平稳,RSS 单边上涨,最后被杀
先确认死因。容器被杀,宿主机和编排系统里都会留下证据:
$ docker inspect push-gateway-1 --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
true 137
# Kubernetes 场景
$ kubectl describe pod push-gateway-7d9c6b7f5-x2k8p | grep -A 6 'Last State'
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
# 宿主机内核日志
$ dmesg -T | grep -i -E 'oom|killed process'
[Mon Sep 28 03:12:07 2026] Memory cgroup out of memory: Killed process 23145 (java) total-vm:9182344kB, anon-rss:4194212kB, file-rss:15320kB, shmem-rss:0kB
Exit Code 137 = 128 + 9(SIGKILL),OOMKilled: true 说明是 cgroup 内存限制触发的内核杀进程,不是 Java 抛出的 OutOfMemoryError。这一点必须分清:JVM 自己 OOM 会有异常栈和(配置了的话)heap dump;被内核 OOM Killer 杀掉,JVM 连遗言都来不及留,应用日志里什么都没有,只有戛然而止。anon-rss:4194212kB 正好是 4GB 容器上限。
再对比“堆”和“进程”:
$ jstat -gc 23145 | awk '{print "heap used(KB) ~", $3+$4+$6+$8}'
heap used(KB) ~ 836412
$ ps -o pid,rss,vsz,cmd -p 23145 | cut -c1-90
PID RSS VSZ CMD
23145 3921408 9182344 java -Xms2g -Xmx2g -XX:+UseG1GC -jar push-gateway.jar
堆用了 0.8GB,进程 RSS 却有 3.9GB。差出来的 3GB 就是“非堆”:元空间、线程栈、JIT 代码缓存、GC 数据结构、直接内存(堆外)、以及 JNI/glibc malloc 的内存。接下来的任务就是:把这 3GB 拆开,看是哪一块在涨。
同时,日志里还有一个此前被忽略的报错,偶尔出现,隔几天一次:
2026-09-26 21:47:33.108 WARN [nioEventLoopGroup-3-2] i.n.channel.DefaultChannelPipeline - An exceptionCaught() event was fired, and it reached at the tail of the pipeline.
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 2147483648, max: 2147483648)
at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:806)
at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:735)
at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:649)
at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:624)
at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:...)
关键信息:used: 2147483648, max: 2147483648——Netty 统计到的堆外内存已经用满 2GB。这个上限从哪来?Netty 的 PlatformDependent 会读取 -Dio.netty.maxDirectMemory;没有设置时,默认取 JVM 的最大直接内存,而 JVM 的 -XX:MaxDirectMemorySize 默认值是 0,官方文档解释为“由 JVM 自动选择”,实践中就约等于 -Xmx。所以我们的 2GB 堆,隐含地授权了 2GB 堆外 + 2GB 堆 + 元空间 + 线程栈,这几个加起来早就超过了 4GB 容器上限。也就是说:堆外没有被自己限住之前,容器限制会先动手。
二、排查路线图
Java 进程的内存拆分,是排查这类问题的地图。下图把 RSS 拆成几块,并给出每块对应的观察手段:
三、动手:给非堆内存做分账
第 1 步:开 NMT,建立基线
NMT(Native Memory Tracking)是 HotSpot 自带的非堆内存追踪。官方文档说明:它追踪的是 JVM 内部的内存使用,不包含非 JVM 代码(比如第三方 JNI 库自己 malloc)的分配;开启后有 5%–10% 的性能损耗,所以生产上建议在“灰度实例”或复现环境打开,而不是全量长期开着。
# 启动参数加一行(需要重启实例才能生效,无法在线打开)
java -Xms2g -Xmx2g -XX:NativeMemoryTracking=summary -jar push-gateway.jar
# 等应用预热、流量稳定后,建立基线
$ jcmd 23145 VM.native_memory baseline
23145:
Baseline succeeded
# 过一段时间(例如 6 小时)后,和基线做差
$ jcmd 23145 VM.native_memory summary.diff
输出(截断,格式与官方文档一致;+ 表示相对基线的增量):
Native Memory Tracking:
Total: reserved=7128331KB +1612844KB, committed=3564210KB +1612880KB
- Java Heap (reserved=2097152KB, committed=2097152KB)
(mmap: reserved=2097152KB, committed=2097152KB)
- Class (reserved=1096320KB +112KB, committed=50560KB +112KB)
(classes #9021 +12)
- Thread (reserved=118210KB, committed=118210KB)
(thread #115)
- Code (reserved=248800KB, committed=41240KB +64KB)
- GC (reserved=225310KB, committed=225310KB)
- Other (reserved=1612102KB +1612102KB, committed=1612102KB +1612102KB)
(malloc=1612102KB +1612102KB #48221 +48221)
读法:Java Heap、Class、Thread、Code、GC 的增量都在 MB 以内,唯独 Other 一项从基线涨了约 1.6GB,且是 malloc 类型、有 4.8 万个分配。NMT 把 Unsafe.allocateMemory(直接内存的底层来源,Netty 的池化直接缓冲区也走这条路)归入 Other。这一步证明:涨的是直接内存,不是元空间,也不是线程。再顺手用 JMX 的 BufferPool 交叉验证一下(注意:它统计的是 java.nio 层面的 DirectByteBuffer,Netty 用的是 allocateDirectNoCleaner,可能不完全计入,所以看 Netty 自己的指标更准):
# JDK 自带:java.nio 直接内存池(需要 JMX 或 Actuator)
$ curl -s localhost:8080/actuator/metrics/jvm.buffer.memory.used?tag=id:direct | head -c 200
{"name":"jvm.buffer.memory.used","measurements":[{"statistic":"VALUE","value":1.3E7}],...}
JDK 视角只有约 13MB,而 NMT 的 Other 涨了 1.6GB——两者对不上,恰恰说明这些内存不是通过 ByteBuffer.allocateDirect 分配的,而是绕过它(Netty 的池化分配器用 Unsafe 直接申请并自己记账)。于是我们改看 Netty 自己的计数器:
// 临时加在一个健康检查接口里,也可以接 Micrometer
PooledByteBufAllocatorMetric m = PooledByteBufAllocator.DEFAULT.metric();
log.info("netty direct used={}MB, heap used={}MB, numDirectArenas={}",
m.usedDirectMemory() / 1024 / 1024, m.usedHeapMemory() / 1024 / 1024, m.numDirectArenas());
// 或者:PlatformDependent.usedDirectMemory()
2026-09-28 10:00:00 INFO netty direct used=612MB, heap used=8MB, numDirectArenas=8
2026-09-28 12:00:00 INFO netty direct used=903MB, heap used=8MB, numDirectArenas=8
2026-09-28 14:00:00 INFO netty direct used=1187MB, heap used=8MB, numDirectArenas=8
每两小时涨约 280MB,而且从不回落。Netty 池化的直接内存 = 已被使用的 Chunk 总量;如果业务代码按时 release(),用完的内存会还回池子,被后续请求复用,总量应该在一个平台上波动,而不是单调上升。单调上升 = 有 ByteBuf 借出去没还。
第 2 步:打开 Netty 泄漏检测,级别调到 PARANOID
Netty 官方文档把泄漏检测分为四级:DISABLED(关闭)、SIMPLE(默认,约 1% 采样,只告诉你“有泄漏”)、ADVANCED(约 1% 采样,附带最近访问记录)、PARANOID(每一个 buffer 都检查,适合测试阶段)。注意 PARANOID 有明显性能开销,不要在全量生产上长期开;正确姿势是:在预发环境或一台灰度实例上打开,用压测流量复现。
# 通过 JVM 参数设置(也可以在代码里 ResourceLeakDetector.setLevel(Level.PARANOID))
java -Dio.netty.leakDetection.level=PARANOID \
-Dio.netty.leakDetection.targetRecords=20 \
-jar push-gateway.jar压测几分钟后,日志里出现了这样的输出(格式与官方文档描述一致,业务类名已脱敏):
2026-09-28 15:21:44.702 ERROR [nioEventLoopGroup-3-4] 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:
#2:
io.netty.buffer.AdvancedLeakAwareByteBuf.readBytes(AdvancedLeakAwareByteBuf.java:495)
com.demo.push.handler.ProtocolHeaderHandler.channelRead(ProtocolHeaderHandler.java:47)
io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
...
#1:
io.netty.buffer.AdvancedLeakAwareByteBuf.readableBytes(AdvancedLeakAwareByteBuf.java:...)
com.demo.push.handler.ProtocolHeaderHandler.channelRead(ProtocolHeaderHandler.java:41)
...
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:349)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:139)
io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:...)
怎么读这份报告:Created at 告诉你 buffer 是谁创建的——这里是 Netty 读事件里分配的入站缓冲区(NioByteUnsafe.read);Recent access records 告诉你最后是谁摸过它:ProtocolHeaderHandler.channelRead 第 47 行 readBytes,之后就没人管了——buffer 被 GC 回收前 release() 从来没有被调用。这与文档里的“最后访问引用计数对象的人,负责释放它”原则对上了:最后访问它的是我们的 handler,所以是我们该释放。
四、根因:入站消息没有 release
public class ProtocolHeaderHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
if (buf.readableBytes() < 4) {
return; // ❌ 分支 1:数据不够,直接 return,buf 没释放
}
int magic = buf.readInt();
if (magic != MAGIC) {
ctx.close(); // ❌ 分支 2:魔数不对,关连接,buf 没释放
return;
}
byte[] body = new byte[buf.readableBytes()];
buf.readBytes(body); // 拷贝到堆上之后,buf 其实就没用了
ctx.fireChannelRead(parse(body)); // ❌ 分支 3:传递的是解析后的对象,原 buf 仍没释放
}
}
三个分支,都是“收到了 ByteBuf,用完没还”。为什么这会变成堆外泄漏而且堆很健康?关键在于 Netty 的两个设计:
- 引用计数:
ByteBuf创建时refCnt = 1,必须有人调用release()把它减到 0,底层内存才会归还。GC 只能回收ByteBuf这个 Java 小对象(几十字节),不会替你归还池里的那块大内存; - 池化分配:默认的
PooledByteBufAllocator从大块 Chunk(大小随 Netty 版本而异,前面日志里申请的 16777216 字节就是一个 16MB 的 Chunk)里切片。没被 release 的切片永远“借出”状态,Chunk 无法释放;池子被迫申请新的 Chunk,于是直接内存只增不减。
再叠加两个放大器:堆使用率低,GC 很少发生,即使是“非池化”的直接缓冲区也要等 GC 触发 Cleaner 才能释放,所以泄漏在压力小的时候被拉长了时间线;而我们的推送网关每天有数千万条心跳包,数据不够的分支 1 命中率很高——于是“每两天死一次”。
五、止血与修复
止血
- 给堆外内存上限,让“死法”可控:加
-XX:MaxDirectMemorySize=1g(java.nio层)和-Dio.netty.maxDirectMemory=1073741824(Netty 自己的计数),让它在到达容器上限前先抛OutOfDirectMemoryError,而不是被内核直接击杀;这样告警和日志里能留下证据; - 把容器内存预算算清楚:4GB 容器 ≈ 堆 2GB + 直接内存 1GB + 元空间(约 256MB)+ 线程栈(约 120MB)+ JIT/GC/其他(约 400MB),留 10% 余量,总量别贴着上限;如果是新版本 JDK,也可以用
-XX:MaxRAMPercentage=50让堆随容器限制伸缩; - 定时滚动重启作为兜底(每 24 小时错峰一台),直到根治发布。注意不要把“重启”当成修复,它只是把泄漏的时间线切短。
根治:谁最后用,谁负责 release
public class ProtocolHeaderHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
if (buf.readableBytes() < 4) return;
int magic = buf.readInt();
if (magic != MAGIC) { ctx.close(); return; }
byte[] body = new byte[buf.readableBytes()];
buf.readBytes(body);
ctx.fireChannelRead(parse(body)); // 转发的是解析后的 POJO,原 buf 由自己释放
} finally {
ReferenceCountUtil.release(msg); // 无论走哪个分支,都会释放一次
}
}
}
// 更省心的写法:继承 SimpleChannelInboundHandler<ByteBuf>,它在 channelRead0 返回后自动 release;
// 如果需要把 ByteBuf 继续往后传,则要 retain() 一次,或改用 ByteToMessageDecoder(它会管理累积缓冲区)。
验证
修复后在预发环境保持 PARANOID 压测 30 分钟,没有再出现 LEAK: 日志;再把 Netty 直接内存指标接入监控,灰度一台正式环境运行 3 天:
时间 netty direct used 进程 RSS
Day 1 10:00 612MB 2.6GB
Day 2 10:00 640MB 2.6GB
Day 3 10:00 618MB 2.6GB ← 平台上下波动,不再单调上升之后把 leakDetection.level 在线上恢复为默认的 SIMPLE(约 1% 采样,开销可忽略)。这样即使以后有人又写出漏 release 的代码,日志里也会有一条 LEAK: 提醒我们。
顺带:NMT 和 glibc 的“另一种涨法”
如果 NMT 的各分类都没涨,但 RSS 仍涨,而且没有用 Netty,就要怀疑 JVM 之外的内存:JNI 库(压缩、图像、加密库)自己 malloc,或 glibc 的多 arena 碎片。官方文档明确说 NMT 不追踪非 JVM 代码的分配,此时可以把 MALLOC_ARENA_MAX=2 放进容器环境变量降低碎片,再用 pmap -x <pid> | sort -k3 -n -r | head 看哪些映射区域大。这属于另一篇的故事,先留个备忘。
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
| 容器内存预算 = 堆 + 堆外 + 元空间 + 线程栈 + 其他,并留 10% 以上余量 | Java 进程 RSS ≠ 堆;只按 Xmx 估算,迟早被 OOM Killer 教育 |
显式设置 -XX:MaxDirectMemorySize 与 -Dio.netty.maxDirectMemory | 默认值约等于 Xmx,不设等于给堆外开了一张空白支票 |
区分死因:docker inspect/kubectl describe 看 OOMKilled,dmesg 看 cgroup OOM | JVM 自己的 OOM 有异常栈和 dump,被内核杀的什么都没有,排查方向完全不同 |
灰度实例开 -XX:NativeMemoryTracking=summary,用 baseline + summary.diff 找增长项 | NMT 把非堆内存分账(Class/Thread/Code/GC/Other),5%–10% 的性能代价只在灰度上付 |
测试/预发用 leakDetection.level=PARANOID,线上用 SIMPLE 或 ADVANCED | PARANOID 检查所有 buffer,能抓到偶发泄漏;线上开销要克制 |
Handler 里所有分支(含异常、提前 return)都要有 release;优先用 SimpleChannelInboundHandler / 解码器 | “最后使用者负责释放”,try/finally 是最便宜的保险 |
| 监控 Netty 直接内存、RSS、容器内存使用率并设“斜率告警” | 泄漏的特征是单调上升,看趋势比看阈值更早发现 |
一句话备忘:堆平稳而 RSS 涨、容器被 OOMKilled → 堆外 →
-XX:NativeMemoryTracking+jcmd VM.native_memory baseline / summary.diff看 Other → Netty 开-Dio.netty.leakDetection.level=PARANOID看 LEAK 报告 → 找到没 release 的 handler;同时用MaxDirectMemorySize给堆外加上限。
事后我在团队群里发了句话:“Java 的内存有 GC 兜底,但只兜堆,不兜你 Netty 的 ByteBuf。”有同事回:“那 GC 就像保洁阿姨,只扫客厅,你把垃圾偷偷塞进阳台,她是不会管的。”我觉得比喻很准确,就是没想到阳台塞了 2GB。
参考资料
- Netty Wiki:Reference counted objects——四级泄漏检测(DISABLED/SIMPLE/ADVANCED/PARANOID)、
-Dio.netty.leakDetection.level设置方式、LEAK 日志含义,以及“最后访问者负责释放”的原则。 - Oracle JDK 17 Troubleshooting Guide:诊断工具(Native Memory Tracking)——NMT 的开启方式、summary/detail 模式、baseline 与 diff 的用法、输出分类,以及 5%–10% 开销和不追踪非 JVM 分配的说明。
- Oracle JDK 17 java 命令手册——
-XX:MaxDirectMemorySize(默认 0,由 JVM 自动选择)、-XX:NativeMemoryTracking各模式、-XX:+DisableExplicitGC的语义。 - JDK 17 API:java.nio.ByteBuffer——直接缓冲区位于常规堆之外、分配与释放成本更高,建议只用于大而长寿的 I/O 缓冲区。
一起聊聊