Java / JVM 排查 列表全部文章
Java / JVM 排查

堆明明没满,容器却被 OOMKilled:一个 ByteBuf 忘了 release 的堆外内存漏洞

消息推送网关的堆使用率一直只有 40%,容器 RSS 却每天涨一截,最后被 OOMKilled,日志里还偶发 Direct buffer memory。用 NMT、Netty 泄漏检测 PARANOID 找到忘记 release 的 ByteBuf,并给堆外内存加上限。

更多

周一早上九点,我刚坐下,运维同事发来一张截图:推送网关的一个容器,凌晨 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 拆成几块,并给出每块对应的观察手段:

容器内存限制 4GB(cgroup)≥ 整个 Java 进程的 RSS,超过即被 OOM Killer 击杀 Java 堆-Xmx2gjstat / GC 日志 / MAT 直接内存(堆外)DirectByteBuffer / NettyNMT: Other;BufferPool MXBean 元空间Class + 压缩类空间NMT: Class 线程栈每线程约 1MBNMT: Thread JIT / GC / 其他Code、GC、InternalNMT: Code / GC … 判断顺序 ① 堆平稳而 RSS 涨 → 非堆 ② 开 NMT baseline + summary.diff,看是哪一类在涨 ③ 直接内存涨 → Netty 开 PARANOID 泄漏检测 / 用 MaxDirectMemorySize 限流 ④ NMT 无变化 → 怀疑 glibc malloc / JNI 库

三、动手:给非堆内存做分账

第 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 的两个设计:

  1. 引用计数:ByteBuf 创建时 refCnt = 1,必须有人调用 release() 把它减到 0,底层内存才会归还。GC 只能回收 ByteBuf 这个 Java 小对象(几十字节),不会替你归还池里的那块大内存;
  2. 池化分配:默认的 PooledByteBufAllocator 从大块 Chunk(大小随 Netty 版本而异,前面日志里申请的 16777216 字节就是一个 16MB 的 Chunk)里切片。没被 release 的切片永远“借出”状态,Chunk 无法释放;池子被迫申请新的 Chunk,于是直接内存只增不减。

再叠加两个放大器:堆使用率低,GC 很少发生,即使是“非池化”的直接缓冲区也要等 GC 触发 Cleaner 才能释放,所以泄漏在压力小的时候被拉长了时间线;而我们的推送网关每天有数千万条心跳包,数据不够的分支 1 命中率很高——于是“每两天死一次”。

Netty 读事件池里借出 ByteBufrefCnt = 1 Handler.channelRead解析 / 转发谁是“最后使用者”? release() → refCnt 0内存还回池,可复用 直接 return / 转发后忘记refCnt 仍为 1 GC 只回收 ByteBuf 小对象Chunk 仍被占用,堆外只增不减 原则:最后访问引用计数对象的人负责释放它 转发给下一个 handler → 由下一个负责;自己消费 / 丢弃 / 出异常 → 自己 release(推荐 try/finally)

五、止血与修复

止血

  1. 给堆外内存上限,让“死法”可控:加 -XX:MaxDirectMemorySize=1g(java.nio 层)和 -Dio.netty.maxDirectMemory=1073741824(Netty 自己的计数),让它在到达容器上限前先抛 OutOfDirectMemoryError,而不是被内核直接击杀;这样告警和日志里能留下证据;
  2. 把容器内存预算算清楚:4GB 容器 ≈ 堆 2GB + 直接内存 1GB + 元空间(约 256MB)+ 线程栈(约 120MB)+ JIT/GC/其他(约 400MB),留 10% 余量,总量别贴着上限;如果是新版本 JDK,也可以用 -XX:MaxRAMPercentage=50 让堆随容器限制伸缩;
  3. 定时滚动重启作为兜底(每 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 OOMJVM 自己的 OOM 有异常栈和 dump,被内核杀的什么都没有,排查方向完全不同
灰度实例开 -XX:NativeMemoryTracking=summary,用 baseline + summary.diff 找增长项NMT 把非堆内存分账(Class/Thread/Code/GC/Other),5%–10% 的性能代价只在灰度上付
测试/预发用 leakDetection.level=PARANOID,线上用 SIMPLE 或 ADVANCEDPARANOID 检查所有 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。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人