Java 内存泄漏排查实战:从 MAT 到 jmap——堆外内存暴增的元凶
引言
"凌晨 2 点网关告警内存 85%,3 点 92%,4 点容器被 OOMKilled,重启后一切正常,然后 6 小时后再来一遍。" 值班的同事第一反应是堆内存泄漏,jmap、jstat、堆 dump 全做了,发现 Xmx 2G 的堆只用了 800M,Young GC 规律、Old 区平稳,MAT 里 Dominator Tree 翻了三遍没有任何大对象——但容器 RSS 实实在在涨到了 3.7G(limit 4G)。
堆没涨,进程内存涨了——这就是堆外内存泄漏的典型特征。 它比堆内泄漏阴险得多:jmap 看不见、堆 dump 里找不到、OOM 时连 hprof 都不会生成(因为是内核 OOM Killer 直接杀的进程),监控图上只有一条让你绝望的平滑上涨曲线。
这篇文章按那次事故的真实排查路径,走完六个台阶:jmap 看堆 → RSS 对不上账 → NMT 给本地内存分类 → MAT 从 DirectByteBuffer 小对象顺藤摸瓜 → Netty LEAK 检测打出 Created at 堆栈 → jemalloc 做最终归因。元凶是一个 WebSocket 推送 Handler 在异常分支上漏调了 ByteBuf.release()。文末附全套可直接抄的排查命令和一张排查决策树——记住一句话:堆外泄漏的内存虽然分配在堆外,但"谁申请的"这张借条(DirectByteBuffer 对象)往往还躺在堆里,排查堆外问题,最终常常要回到堆里找证据。
一、全景:Java 进程的内存到底花在哪
1.1 RSS 的构成
很多人以为"JVM 内存 = 堆 + Metaspace",实际一个 Java 进程的 RSS(常驻物理内存)是这样的:
进程 RSS(top/ps 看到的)
├── Java Heap -Xmx,最熟悉的部分,jmap 能看见
├── Metaspace 类元数据,-XX:MaxMetaspaceSize
├── Thread Stacks 每个线程 1MB(-Xss),500 个线程就是 500M
├── Code Cache JIT 编译后的本地代码
├── GC 内部结构 Card Table、Remembered Set、标记位图等
├── Symbol / String Table 符号表、常量池
└── ── 以上 NMT 大多能记账,以下是事故高发区 ──
├── Direct Memory ★ java.nio.DirectByteBuffer
│ (Netty/Lettuce/gRPC/NIO 文件拷贝的主力)
├── Native 分配 ★ JNI 库、压缩库、Unsafe.allocateMemory
│ (Netty 高版本自己 malloc 的池化内存也算这里)
└── malloc 碎片 + 竞技场 glibc ptmalloc 释放后不归还 OS 的空洞
排查的本质就是给 RSS 对账:把上面每一项的数字填出来,哪一项的数字和 RSS 涨幅对得上,问题就在哪。
1.2 排查路线图
容器/机器内存告警
│
├─① 确认"谁在涨":top/ps 看 RSS,cgroup 看容器计数,dmesg 确认是否 OOM Killer
│
├─② 排除堆内:jmap -heap / jstat -gcutil
│ 堆随 GC 平稳下降、Old 区不涨 → 不是堆内泄漏,转向堆外
│
├─③ NMT 分类:jcmd VM.native_memory(需提前开 -XX:NativeMemoryTracking=detail)
│ 看 Internal / Other 项是否持续增长 → JVM 视角的直接内存
│
├─④ 查 Direct ByteBuffer:
│ JMX BufferPool direct 计数持续增长 → 直接内存泄漏实锤
│
├─⑤ 堆 dump + MAT:DirectByteBuffer 对象在堆里
│ OQL 筛出所有 DirectByteBuffer → 看 incoming references → 定位持有者
│
├─⑥ 开 Netty LEAK=PARANOID:日志直接打印 "Created at" 分配堆栈
│ 若 NMT/MAT 都指不清第三方 native 分配 → jemalloc + jeprof 终极归因
│
└─ 修复:按引用计数所有权规则补 release + 预防措施
工具选型一句话:能在测试/预发复现的,直接开 LEAK=PARANOID 最快;只能在线上观察的,NMT + JMX + MAT 三件套;怀疑第三方 JNI 库或 glibc 碎片的,上 jemalloc。
二、第一步:确认现象,别一上来就 dump
2.1 先分清三件事
| 现象 | 说明 | 查看方式 |
|---|---|---|
| 容器内存涨 | cgroup 记账,到达 limit 被 OOMKilled | memory.current / docker stats / kubectl top pod |
| 进程 RSS 涨 | 操作系统视角的实际物理页 | top -p、ps -o rss |
| JVM 堆涨 | GC 分代视角 | jstat -gcutil、jmap -heap |
三者必须同时采样对比,这一步能排掉大量误报:有的告警是 cgroup 统计口径问题,有的是 page cache(文件映射)被算进容器,还有的是堆确实涨了但跟堆外无关。
那次事故的第一组证据(容器 limit 4G):
# 1) 进程视角:RSS 3.7G,且每小时稳定上涨约 500M
$ ps -o pid,rss,vsz,etime,cmd -p 1
PID RSS VSZ ELAPSED CMD
1 3854336 5316xxx 04:12:00 java -Xms2g -Xmx2g ...
# 2) cgroup 视角:容器记账 3.75G,和 RSS 基本吻合,说明不是统计误差
$ cat /sys/fs/cgroup/memory.current # cgroup v2,单位字节
3980890112
# v1 的机器上是:
$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes
# 3) 确认是被内核杀的(重启前的宿主机上)
$ dmesg -T | grep -i -A2 "killed process"
[Sat Feb 21 04:03:11] Memory cgroup out of memory: Killed process 1 (java)
total-rss:3892316kB anon-rss:3870004kB file-rss:...
anon-rss 占绝对大头、file-rss 很小,说明不是 mmap 文件/页缓存,是匿名内存——堆和堆外都是匿名内存,继续往下分。
2.2 再排除堆内泄漏
# 每 1 秒打印一次 GC 情况,看 10 分钟
$ jstat -gcutil 1 1000 600
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 93.22 41.50 38.11 94.72 91.05 12580 82.3 3 1.8 84.1
... # O 列(Old 区占用率)始终在 37~39% 之间波动,不随时间上涨
# FGC 只有 3 次,没有频繁 Full GC
# 堆配置与实际使用
$ jmap -heap 1 | head -30
Heap Configuration:
MaxHeapSize = 2147483648 (2048.0MB)
Heap Usage:
New Generation (Eden + 1 Survivor Space):
used = 560214440 (≈534MB)
Old Generation:
used = 805306368 (≈768MB)
结论很清晰:Old 区稳定在 768M、Full GC 次数不增长,但 RSS 3.7G 且仍在涨。 堆用了 1.3G,RSS 比堆多出 2.4G——这 2.4G 就是堆外(含 Metaspace、线程栈等正常开销和泄漏部分)。
提示:
jmap -heap在 JDK9+ 已废弃,用jcmd 1 GC.heap_info替代,输出信息等价。
一个容易误判的点:堆外泄漏有时会伴随频繁 Full GC。因为 DirectByteBuffer 对象本身很小(在堆里),但它通过 Cleaner(虚引用)持有堆外内存的释放逻辑;当直接内存接近 -XX:MaxDirectMemorySize 上限时,JDK 的 Bits.reserveMemory 会主动触发 Full GC 试图回收堆外内存。所以"Full GC 频繁但堆很空"反而是直接内存泄漏的强信号,别只看 Old 区。
三、第二步:NMT 给 JVM 的本地内存记账
3.1 什么是 NMT
NMT(Native Memory Tracking)是 HotSpot 自带的本地内存追踪器,能追踪 JVM 自己分配的本地内存:堆、类、线程、代码、GC、内部(含部分 Direct 分配)。它有两个硬限制,提前知道免得误判:
- 必须进程启动时开启,运行中无法补开:
-XX:NativeMemoryTracking=detail(约 5%~10% 性能开销,容量充足的网关/中间件服务可以常开summary)。 - 追踪不到第三方 JNI 库通过 malloc 直接分配的内存,也追踪不到 Netty 高版本绕过 DirectByteBuffer 的 Unsafe 分配——这部分要靠第七步的 jemalloc。
建议所有服务的启动参数里默认加上:
-XX:NativeMemoryTracking=detail
3.2 三个核心命令
# 1) 当前总账(scale 统一单位,数字才好读)
$ jcmd 1 VM.native_memory summary scale=MB
Native Memory Tracking:
Total: reserved=4682345KB, committed=3712304KB # committed ≈ JVM 实际向 OS 拿的
- Java Heap (reserved=2097152KB, committed=2097152KB)
(mmap: reserved=2097152KB, committed=2097152KB)
- Class (reserved=1056896KB, committed=49280KB)
...
- Thread (reserved=524800KB, committed=47800KB)
(thread #512) # 500 个线程栈预留,实际驻留 47M
- Code (reserved=249856KB, committed=41200KB)
- GC (reserved=201334KB, committed=201334KB)
- Internal (reserved=2285400KB, committed=2285400KB) # ← 重点嫌疑
(mmap: reserved=...)
(malloc=2285400KB #890231) # ← malloc 2.18G,89 万次分配!
- Symbol (reserved=14560KB, committed=14560KB)
看到 Internal (malloc=2285400KB #890231) 这一行时基本可以锁定:2.18G 全部来自 malloc 类内部分配,且分配次数 89 万持续增长——在纯 Java 业务服务里,这种量级的 Internal malloc 几乎都是直接内存(DirectByteBuffer 的底层页或 Netty 的池化竞技场)。
# 2) 打基线(在内存正常时打,例如刚启动后)
$ jcmd 1 VM.native_memory baseline
Baseline succeeded
# 3) 两小时后对比差异——这是发现"持续增长"最有力的方式
$ jcmd 1 VM.native_memory summary.diff scale=MB
Total: committed=3212304KB +1034240KB # 两小时 JVM 本地内存涨了约 1G
- Internal (reserved=...)
(malloc=...) # malloc=... +1014784KB ← 增量几乎全在这里
(mmap ...)
+1014784KB 与 RSS 两小时的涨幅(约 1G)严丝合缝,账对上了。
3.3 NMT 的 JDK 版本差异(容易踩坑)
| 运行时 | Netty/JDK 直接内存分配方式 | NMT 里归到哪 |
|---|---|---|
| JDK 8 | Netty 反射创建 DirectByteBuffer(带 Cleaner),走 Bits 记账 | Internal,且受 -XX:MaxDirectMemorySize 限制 |
| JDK 9+ | JDK 封死了 DirectByteBuffer 反射构造,Netty 改用 Unsafe.allocateMemory 自建池/自管释放 | Internal(统计为 Other/Internal),不受 MaxDirectMemorySize 限制 |
这张表解释了两个线上怪象:为什么 JDK11 上 -XX:MaxDirectMemorySize 好像"失灵"了(Netty 的池化直接内存不经过 Bits);为什么 NMT 能看到增长量但 detail 看不到 Java 调用栈(Unsafe 分配的调用方对 NMT 是黑盒,需要 jemalloc 补上)。
四、第三步:锁定 DirectByteBuffer——证据在 JMX 和堆里
4.1 看直接内存的官方计数
JVM 把直接内存的使用情况暴露成了标准 JMX 指标,不用 dump 就能看趋势:
java.nio:type=BufferPool,name=direct
├─ Count # 当前 DirectByteBuffer 个数
├─ MemoryUsed # 直接内存总字节
└─ TotalCapacity
命令行三种取法:
# 方式一:JMX 客户端(JConsole/VisualVM/Mission Control)连上去看 MBean
# java.nio → BufferPool → direct,重点看 Count 曲线是否只升不降
# 方式二:线上用 Arthas(不用重启、不用开端口)
$ java -jar arthas-boot.jar 1
[arthas@1]$ mbean java.nio:type=BufferPool,name=direct
count : 289123
memoryUsed : 2156433408 # 2.0G
totalCapacity : 2156433408
# 隔 30 分钟再执行一次
count : 304561 # +15438
memoryUsed : 2390753280 # +224M,只涨不跌
Count 和 MemoryUsed 单调递增、GC 后不回落,直接内存泄漏实锤。如果 Full GC 后数字短暂回落又继续涨,说明 Cleaner 回收机制在工作,但申请速度大于回收速度,依然是泄漏(或容量配置问题)。
4.2 为什么"堆外泄漏"要回到堆里查
DirectByteBuffer 是一个经典的双料对象,理解它的结构是整个排查的关键:
堆内(小,几十字节) 堆外(大,可能 16KB~16MB)
┌──────────────────────┐
│ DirectByteBuffer 对象 │ ──持有地址──→ ┌────────────────────┐
│ long address 0x7f..│ │ 真正的数据页(malloc)│
│ int capacity 16384 │ └────────────────────┘
│ Cleaner(虚引用) │
└──────────────────────┘
↑ GC 回收这个对象时,
Cleaner 才会调用 Unsafe.freeMemory(address) 释放堆外页
释放靠的是 Cleaner(PhantomReference 的子类):只有 DirectByteBuffer 堆内对象被 GC 回收,堆外内存才会被释放。正常代码里显式调 ((DirectBuffer)buf).cleaner().clean()(Netty 会主动调);漏掉时只能干等 GC——而 DirectByteBuffer 对象太小,Young 区一旦存活晋升到 Old 区,就要等下一次 Full GC 才释放,期间堆外内存只涨不回收。
所以排查思路反转过来:去堆 dump 里数 DirectByteBuffer 对象、看它们被谁引用着,引用链的顶端就是泄漏代码的入口。
4.3 不 dump 也能先粗筛一把
# 类直方图(不加 :live 不触发 GC;加 :live 会触发 Full GC,生产慎用!)
$ jcmd 1 GC.class_histogram | grep -i -E "DirectByteBuffer|ByteBuffer"
2: 289123 45034840 java.nio.DirectByteBuffer
8: 288900 34668000 java.nio.DirectByteBuffer$Deallocator
近 29 万个 DirectByteBuffer 实例,而正常服务通常只有几百到几千个(Netty 池化后更稳定)——数量级异常本身就是证据。
五、第四步:堆 dump + MAT 顺藤摸瓜
5.1 安全地 dump
# 推荐:jcmd dump(JDK8u+ 都支持),heap dump 是 safepoint 操作,会有短暂停顿
$ jcmd 1 GC.heap_dump /tmp/gateway-$(date +%H%M).hprof
# 老写法(效果相同)
$ jmap -dump:format=b,file=/tmp/gateway.hprof 1
# 注意:
# 1) dump 文件大小≈堆大小,确保磁盘有 2G+ 空间,别写满容器盘
# 2) 生产环境避开高峰,停顿时间与堆大小正相关(2G 堆通常数百毫秒~数秒)
# 3) 容器里 dump 出来后,用 kubectl cp / docker cp 拷到本地用 MAT 分析
5.2 MAT 里的四步操作
第一步:别只看 Dominator Tree。 堆外泄漏在堆里的表现是"几十万个小对象",Dominator Tree 按 retained heap 排序,它们单个都很小,会淹没在列表里。事故第一次排查就是在这卡住的。
第二步:用 OQL(Object Query Language)直接点名 DirectByteBuffer:
SELECT s FROM java.nio.DirectByteBuffer s
结果 28 万条,capacity 大多是 16384(16KB,正是 Netty 聚合帧的常用大小)。
第三步:按 capacity 聚合并抽一条看引用链。 选中样本 → List Objects → with incoming references(谁引用着它),再 Path To GC Roots → exclude weak/soft references,典型链路:
java.nio.DirectByteBuffer
← io.netty.buffer.PooledByteBuf$PooledUnsafeDirectByteBuf (buf)
← io.netty.channel.socket.DatagramPacket / BinaryWebSocketFrame (content)
← java.util.ArrayList$Itr / Object[] (消息批处理列表)
← com.xxx.push.WsFrameAggregator.pendingFrames ← ★ 业务类!
← cn.dev33... DefaultHandlerPipeline
引用链顶端停在自己的业务类 WsFrameAggregator 上——这就是"持有者"。该类用一个 List<Object> pendingFrames 聚合 WebSocket 分片帧,正常路径处理完会 release,但下游推送失败的 catch 分支只打了日志、没释放帧内容,失败越多,持有的 ByteBuf 越多。
第四步:验证 Netty 层统计。 如果引用链是 Netty 对象,可以顺便看 PooledByteBufAllocator 的池化指标(JMX 或 Arthas):
io.netty.pool:type=PooledByteBufAllocatorMetric
directArenas[].chunkList[].activeBytes # 竞技场活跃内存,持续上涨不归还是泄漏特征
directArenas[].numActiveAllocations # 活跃分配数
池化 ByteBuf 泄漏和未池化的区别:池化泄漏时内存在 Netty 的 PoolArena 里不归还(arena 只增不减),未池化泄漏则表现为 DirectByteBuffer/malloc 数量增长。 两种在事故里都会让 RSS 上涨,但修复点都是同一个:配对 release。
5.3 一个应急手段(不是修复)
确认是 Cleaner 回收不及时(而非彻底的引用泄漏)时,可以让应用定时触发并发 Full GC 临时压住曲线:
-XX:+ExplicitGCInvokesConcurrent # 让 System.gc() 走 CMS/G1 并发周期,而不是 Serial Full GC
仅用于争取修复窗口,严禁当作解决方案——靠 Full GC 释放直接内存说明释放时机已经失控,根因仍是漏 release。
六、第五步:Netty LEAK 检测——让框架亲口说出分配点
MAT 引用链是静态推断,Netty 还提供了更直接的武器:泄漏检测会在 ByteBuf 被 GC 前检查 refCnt,发现没 release 就打印分配时的访问记录堆栈。
6.1 四个级别
| 级别 | 采样率 | 用途 |
|---|---|---|
| DISABLED | 0 | 关闭 |
| SIMPLE | 默认,约 1% 抽样 | 生产默认,告诉你"有泄漏" |
| ADVANCED | 1% 抽样 + 访问记录 | 看泄漏 buf 的访问轨迹 |
| PARANOID | 100% | 预发/压测环境定位问题,开销大 |
开启方式(JVM 系统属性,应用启动时设置):
-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=40 # 保留最近40条访问记录(默认10条,堆栈可能不够深)
压测复现后,日志里会出现那段标志性输出:
ERROR ResourceLeakDetector:238 - LEAK: ByteBuf.release() was not called before it's
garbage-collected. Recent access records:
Created at:
io.netty.buffer.UnpooledByteBufAllocator.newDirectBuffer(UnpooledByteBufAllocator.java:96)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame.<init>(...)
com.xxx.push.WsFrameAggregator.channelRead0(WsFrameAggregator.java:118) ← 直接指到业务行号
io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(...)
...
: 128 leak records were discarded in this period.
Created at 堆栈直接打在 WsFrameAggregator.java:118——和 MAT 引用链结论交叉验证一致,这就是铁证。
一个关键经验:不要看到 Netty 的堆栈就认定是 Netty 的 bug。先按堆栈确认 pipeline 归属——同样的 LengthFieldBasedFrameDecoder 堆栈,可能属于 Lettuce 的 Redis 客户端、也可能属于自研 TCP 服务,查错模块会原地踏步。那次事故里日志线程名是
nio-http-server-(网关自己的 ServerBootstrap),直接锁定服务端推送链路,排除了 Lettuce 侧。
6.2 ByteBuf 引用计数的所有权规则(修复的理论基础)
修复漏 release 不能靠"看到 buf 就 release",必须先明确所有权,否则会引发更难查的 use-after-release:
- 入站消息:
channelRead(ctx, msg)收到的 ByteBuf/Frame,当前 Handler 消费完就要释放——要么继承SimpleChannelInboundHandler(框架在 channelRead0 返回后自动 release),要么手动ReferenceCountUtil.release(msg)。 - 出站消息:
write/writeAndFlush的 ByteBuf,pipeline 负责最终释放(成功失败都释放),调用方别再自己 release。 - retain 必须配对 release:把 buf 传给异步线程、放进聚合列表跨方法持有,必须先
retain(),消费方完成后对应一次release()。 - 异常路径与正常路径同等重要:
catch分支、return提前退出、循环continue,每一条都要检查引用计数去向——事故就出在 catch 分支。
6.3 修复前后对比
问题代码(异常分支泄漏 + 聚合列表释放不全):
// 自定义 Handler,不是 SimpleChannelInboundHandler → msg 不会自动释放
public class WsFrameAggregator extends ChannelInboundHandlerAdapter {
private final List<BinaryWebSocketFrame> pendingFrames = new ArrayList<>();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof BinaryWebSocketFrame)) {
ctx.fireChannelRead(msg);
return;
}
BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
pendingFrames.add(frame);
if (frame.isFinalFragment()) {
try {
ByteBuf payload = aggregate(ctx.alloc(), pendingFrames);
downstream.push(ctx.channel(), payload); // 推送下游,失败会抛异常
} catch (Exception e) {
// ✗ 只打日志:pendingFrames 里每个 frame.content()(堆外内存)全部泄漏
log.error("push failed", e);
} finally {
pendingFrames.clear(); // ✗ clear 掉的只是 Java 引用,ByteBuf 没释放
}
}
// 非 final 分片时也没有任何释放约定,完全依赖聚合完成
}
}
修复后(try-finally 兜底 + 所有权下沉到消费点):
public class WsFrameAggregator extends SimpleChannelInboundHandler<WebSocketFrame> {
// ① 继承 SimpleChannelInboundHandler:channelRead0 返回后,入站 frame 由框架自动 release
private final List<BinaryWebSocketFrame> pendingFrames = new ArrayList<>();
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame msg) {
if (!(msg instanceof BinaryWebSocketFrame)) {
ctx.fireChannelRead(msg);
return;
}
BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
// ② 入站帧框架会自动释放,但我们要跨方法聚合持有 → 显式 retain
frame.retain();
pendingFrames.add(frame);
if (!frame.isFinalFragment()) {
return;
}
List<BinaryWebSocketFrame> batch = new ArrayList<>(pendingFrames);
pendingFrames.clear();
ByteBuf payload = null;
try {
payload = aggregate(ctx.alloc(), batch); // 聚合出新 buf(归本方法所有)
downstream.push(ctx.channel(), payload); // push 内部 writeAndFlush,
// payload 所有权移交 pipeline
payload = null; // 移交成功,本地不再负责
} catch (Exception e) {
log.error("push failed", e);
} finally {
// ③ 聚合失败/推送失败:新 buf 没移交出去,本地兜底释放
if (payload != null) {
ReferenceCountUtil.release(payload);
}
// ④ 每个 retain 过的分片帧配对 release,无论成功失败
for (BinaryWebSocketFrame f : batch) {
ReferenceCountUtil.release(f);
}
}
}
}
四条所有权规则全部落地:自动释放入站帧、跨层持有先 retain、移交后置空避免重复释放、finally 兜底全部路径。上线后开 PARANOID 压测 2 小时,LEAK 日志归零,direct MemoryUsed 稳定在 300M 不再上涨。
七、第六步:jemalloc——第三方 native 分配的终极归因
不是每次都能直接看到 DirectByteBuffer。如果 NMT 里 Internal 涨、BufferPool direct 计数却平稳,说明内存是绕过 JVM 直接走 malloc 的:JNI 压缩库(图片/Snappy/ZSTD)、Netty 高版本 Unsafe 池化、 RocksDB 客户端、glibc 碎片等。此时上 jemalloc 做 native 侧的分配 profiling(Linux 生产环境)。
7.1 安装与挂载
# CentOS
$ yum install -y jemalloc jemalloc-devel
# Ubuntu
$ apt-get install -y libjemalloc-dev
# 确认路径(一般在 /usr/lib64/libjemalloc.so.2 或 /usr/local/lib/libjemalloc.so)
$ ls /usr/lib64/libjemalloc.so*
以 agent 方式预加载,不需要改任何业务代码,改启动参数重启即可:
# MALLOC_CONF 关键参数:
# prof:true 开启分配采样
# lg_prof_sample:17 每分配 2^17=128KB 采样一次(数值越小越精确、开销越大)
# prof_prefix dump 文件前缀
# lg_prof_interval:30 每 2^30=1GB 分配自动 dump 一次(可选)
export MALLOC_CONF="prof:true,lg_prof_sample:17,prof_prefix:/tmp/jeprof.out,lg_prof_interval:30,prof_leak:true"
export LD_PRELOAD=/usr/lib64/libjemalloc.so.2
# 然后正常启动 Java(K8s 里写进容器 env + 把 .so 打进基础镜像)
java -XX:NativeMemoryTracking=detail -jar app.jar
7.2 触发 dump 与分析
# 方式一:等自动 dump(每 GB),/tmp 下会生成 jeprof.out.<pid>.i<序号>.heap
# 方式二:手动触发(给进程发特定信号,jemalloc 约定)
$ jcmd 1 VM.native_memory # 只是顺便对照 NMT 时间点
# jemalloc 开启 prof_active 控制:可以用 jeprof 的触发开关或直接 kill -12(需 conf 支持)
# 实操中最常用:启动时一个 dump,内存涨上来后再等一个 dump,用增量对比
# 文本报告:对比两个时间点的分配增量(最能说明"谁在持续分配不释放")
$ jeprof --show_bytes --text --base=/tmp/jeprof.out.1.i0.heap \
$(which java) /tmp/jeprof.out.1.i6.heap | head -30
Using local file /tmp/...i6.heap.
Using local file /tmp/...i0.heap (as base).
Total: 2147483648 Bytes
0 0.0% 0.0% 2040109465 95.0% io_netty_buffer_PoolArena_allocateRun [这是JIT本地符号示意]
1610612736 75.0% 75.0% 1610612736 75.0% je_calloc
... 536870912 25.0% Java_io_netty_..._Unsafe_AllocateMemory ← native 调用栈
# 生成 SVG 火焰/调用图(肉眼看最大的扇形)
$ jeprof --show_bytes --svg --base=/tmp/jeprof.out.1.i0.heap \
$(which java) /tmp/jeprof.out.1.i6.heap > /tmp/native-leak.svg
报告按分配调用栈聚合,能直接看到 Java_* 符号对应的 Java 类方法(JNI 符号名),或第三方 .so 内部的分配热点。那次事故如果发生在 JDK11 + Netty 自管池化内存的环境,NMT 只能告诉你 Internal 涨,jemalloc 会直接把 io.netty.buffer.PoolArena → allocateDirect 这条栈顶在榜首。
7.3 顺带解决的 glibc 碎片问题
还有一类"假泄漏":内存确实 free 了,但 glibc 的 ptmalloc 因为多线程竞技场(arena)锁竞争和碎片整理策略,不把空闲页归还 OS,RSS 只涨不降。jemalloc 自身的碎片率显著低于 ptmalloc,很多服务挂上 jemalloc 后 RSS 立刻下降 20%~40%——这本身就是一个收益:
# 验证是否碎片问题的旁证:pmap 看大块匿名映射的分布
$ pmap -x 1 | sort -k3 -n -r | head -20
# 大量 64MB(典型 arena 块)且 NMT 各项不涨 → 高度怀疑 malloc 碎片
注意区分:碎片问题的特征是 NMT/JMX/LEAK 全部正常但 RSS 涨;泄漏问题的特征是某一项计数单调增长。 两者经常同时出现,jemalloc 对两者都有效,但根因修复不能省。
八、修复与预防:七条落地措施
-
入站 Handler 优先继承
SimpleChannelInboundHandler,把业务写进 channelRead0,让自动释放兜底;必须用ChannelInboundHandlerAdapter时,try-finally +ReferenceCountUtil.release(msg)紧贴消费边界,不跨层释放。 -
异步持有必配对:跨线程、放入聚合容器、塞进队列的 ByteBuf,入口
retain()、出口release(),把"谁在什么时刻拥有引用计数"写进 Code Review checklist。 -
预发环境常开 PARANOID + 压测:把
-Dio.netty.leakDetection.level=PARANOID写入压测环境启动脚本,压测脚本必须包含"下游异常/断连/超时"路径——泄漏大半藏在异常分支,只压正常路径永远发现不了。 -
给直接内存设上限,别用默认值:
-XX:MaxDirectMemorySize=512m -Dio.netty.maxDirectMemory=536870912 # Netty 自己的池化上限(字节),JDK11+ 尤其要设 -Dio.netty.allocator.numDirectArenas=... # 容器小规格应用调小 arena 数,降低碎片和虚高不设 MaxDirectMemorySize 时,JDK8 默认直接内存上限约等于 Xmx,容器极易出现"堆 2G + 直接内存 2G + 其他"撑爆 limit。
-
容器内存配额做加法:
container limit ≥ Xmx + MaxDirectMemorySize + Metaspace(约256~512M) + 线程数×Xss(500线程≈500M) + CodeCache(约256M) + GC结构 + JVM本身(约300M) + 安全余量15%以 2G 堆的服务为例,容器 limit 给 4G 是底线,给 2G/2.5G 等于定时炸弹。
-
监控补齐三个指标(JMX/Actuator 都能采):
java.nio:type=BufferPool,name=direct的 Count 与 MemoryUsed(直接内存)- 进程 RSS 与 cgroup
memory.current(容器视角,配 OOM 事件告警) - Netty
PooledByteBufAllocatorMetric的 activeBytes / numActiveAllocations(有 Netty 的服务必加)
告警策略:直接内存 30 分钟单调上涨且 GC 后不回落即 P1,别等 OOMKilled。
-
基础镜像统一 jemalloc:高并发 Netty 服务的基础镜像默认
LD_PRELOADjemalloc(关 prof,零代码成本降碎片);出问题时再通过环境变量打开 prof 采样,省去临时安装。
九、常见问题
9.1 jmap / jstat 都正常,为什么容器还是被杀?
容器 limit 约束的是整个进程 RSS(含堆外),不是 JVM 堆。堆只用 40% 但直接内存、线程栈、CodeCache、malloc 碎片加起来超过 limit,内核 OOM Killer 直接发 SIGKILL——JVM 没机会抛 OutOfMemoryError,也不会生成 hprof。排查时第一步就是 dmesg 确认 "Memory cgroup out of memory",看到这条就知道账要往堆外算。
9.2 NMT 显示 Internal 涨,但没开 NMT 怎么办?
NMT 必须重启加 -XX:NativeMemoryTracking=detail 才能用,不能动态开启。线上没开时的替代组合:① JMX BufferPool.direct 看 DirectByteBuffer;② jcmd GC.class_histogram 数 DirectByteBuffer 个数;③ pmap -x 看匿名大块;④ Arthas 观察 Netty allocator metric;⑤ 实在无法归因时,挂 jemalloc 重启(下次发布窗口带上)。重要服务建议常开 summary 级别,开销很小。
9.3 Full GC 之后直接内存短暂回落,算泄漏吗?
算。正常的直接内存使用(Netty 池化)应该在业务负载稳定后基本平稳,不依赖 Full GC。靠 GC 回收说明存在大量"未显式释放、等 Cleaner 兜底"的 DirectByteBuffer——短期侥幸不炸,一旦 DirectByteBuffer 晋升 Old 区、Full GC 周期变长,就会演变成持续上涨。LEAK 检测开起来抓分配点,不要把 GC 兜底当机制。
9.4 Netty 报 LEAK 但业务正常跑,可以先不管吗?
不行,但要先排除误报。确认方法:看 LEAK 日志的堆栈归属(线程名、pipeline 是你自己的 Server 还是 Lettuce/gRPC 客户端),一次抽样可能是框架内部在异常路径下的正常释放延迟;PARANOID 下同一堆栈稳定复现就是真泄漏。泄漏的 ByteBuf 在低流量下每天漏几兆,大促时几分钟 OOM,这类问题必须在压测期清零。
9.5 为什么我 release 了还报 LEAK / 报 IllegalReferenceCountException?
两种典型错误:① release 多了——msg 已经由 SimpleChannelInboundHandler 或 writeAndFlush 释放,又手动 release 一次,refCnt 减到负数抛异常;② release 早了——异步线程还没消费完就在提交方释放了,消费方 use-after-release。记住检查顺序:先画清这条消息从 channelRead 到最终消费的所有权转移图,再决定在哪一层 release,不要在桥接层无条件 release。
9.6 dump 堆会不会把线上服务搞挂?
jmap/jcmd heap dump 会触发 safepoint,停顿时间与堆大小和对象数量正相关,2G 堆通常数百毫秒到数秒,8G 以上大堆在高 QPS 服务上可能十几秒;同时 dump 文件写盘有 IO 压力。建议:① 优先在隔离实例/摘流量后的实例上 dump;② 磁盘预留 1.5 倍堆大小空间;③ JDK11+ 可用 jcmd GC.heap_dump -all=false 只 dump 存活对象减小体积;④ class_histogram(不带 :live)停顿很小,可先粗筛再决定要不要 full dump。
十、总结
排查决策树速查卡
内存告警
├─ dmesg 有 "Memory cgroup out of memory"? ── 是 ──→ 进程级 OOM,查 RSS 构成
├─ jstat -gcutil:Old 区持续上涨? ── 是 ──→ 堆内泄漏:dump + MAT 查大对象链(另一类问题)
│ └─ 否(Old平稳)
├─ JMX BufferPool.direct 的 Count/MemoryUsed 单调涨?
│ ├─ 是 → DirectByteBuffer 泄漏
│ │ ├─ 预发:-Dio.netty.leakDetection.level=PARANOID 抓 Created at 堆栈
│ │ └─ 线上:jcmd GC.heap_dump → MAT/OQL 查 DirectByteBuffer 的 incoming refs
│ │ → 引用链顶端业务类 = 泄漏入口
│ └─ 否,但 NMT Internal 涨 / pmap 匿名块增长
│ → JNI/Unsafe 直接 malloc:jemalloc + jeprof 对比 dump
│ → NMT/JMX 都正常只 RSS 涨:glibc 碎片,换 jemalloc
└─ 修复后验证:LEAK 日志归零 + direct MemoryUsed 平稳 + RSS 24h 不再单调上涨
完整命令速查表
# ── ① 现象确认 ──
ps -o pid,rss,vsz,etime,cmd -p <pid>
jstat -gcutil <pid> 1000 60
jmap -heap <pid> # JDK9+ 用 jcmd <pid> GC.heap_info
cat /sys/fs/cgroup/memory.current # v2;v1: memory/memory.usage_in_bytes
dmesg -T | grep -i "killed process"
# ── ② NMT(需 -XX:NativeMemoryTracking=detail 启动)──
jcmd <pid> VM.native_memory summary scale=MB
jcmd <pid> VM.native_memory baseline # 内存正常时打基线
jcmd <pid> VM.native_memory summary.diff scale=MB
# ── ③ 直接内存计数 ──
# Arthas:
mbean java.nio:type=BufferPool,name=direct
# JMX 客户端:java.nio → BufferPool → direct,看 Count/MemoryUsed 趋势
# ── ④ 类直方图(:live 会触发 Full GC,慎用)──
jcmd <pid> GC.class_histogram | grep -i DirectByteBuffer
jmap -histo <pid> | grep -i ByteBuffer
# ── ⑤ 堆 dump + MAT ──
jcmd <pid> GC.heap_dump /tmp/app.hprof
# MAT: OQL → SELECT s FROM java.nio.DirectByteBuffer s
# → incoming references → Path To GC Roots → 定位业务持有者
# ── ⑥ Netty 泄漏检测(预发/压测)──
-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=40
# ── ⑦ jemalloc(Linux)──
export LD_PRELOAD=/usr/lib64/libjemalloc.so.2
export MALLOC_CONF="prof:true,lg_prof_sample:17,prof_prefix:/tmp/jeprof.out,lg_prof_interval:30"
jeprof --show_bytes --text --base=/tmp/jeprof.out.<pid>.i0.heap $(which java) /tmp/jeprof.out.<pid>.i6.heap
jeprof --show_bytes --svg --base=...i0.heap $(which java) ...i6.heap > leak.svg
pmap -x <pid> | sort -k3 -n -r | head -20
给团队的建议
| 项 | 建议 |
|---|---|
| JVM 参数 | 必带 -XX:NativeMemoryTracking=summary、显式 MaxDirectMemorySize,容器按加法留配额 |
| 预发环境 | LEAK=PARANOID 常开,压测必须覆盖下游异常/断连/超时路径 |
| Code Review | 重点查 ByteBuf 所有权:入站释放、异步 retain/release 配对、catch/finally 分支 |
| 监控告警 | 直接内存使用率 + 单调不回落趋势、RSS/容器内存、OOM 事件,三个缺一不可 |
| 工具准备 | Arthas 进基础镜像;jemalloc 打进基础镜像(默认不 prof,用时开关一开) |
| dump 纪律 | 摘流量实例上 dump,磁盘预留 1.5 倍堆空间,大堆优先 class_histogram 粗筛 |
| 架构习惯 | 网关/推送类服务别把 ByteBuf 长时间塞进业务集合,聚合边界即释放边界 |
一句话
堆外内存泄漏排查的核心心法是"对账":先用 RSS、cgroup、dmesg 确认是进程级匿名内存被 OOM Killer 杀,再用 jstat/jmap 排除堆内(Old 区平稳而 RSS 涨,是堆外泄漏最硬的特征);然后让 NMT 的 baseline diff 指出 Internal malloc 的持续增量、让 JMX BufferPool.direct 的 Count 和 MemoryUsed 实锤直接内存,最后记住"堆外内存的借条在堆里"——jcmd dump 堆后用 MAT 的 OQL 筛出 java.nio.DirectByteBuffer,沿 incoming references 找到持有它的业务类,或者干脆在预发打开 -Dio.netty.leakDetection.level=PARANOID 让 Netty 直接打印 Created at 堆栈;绕过 JVM 的 JNI/Unsafe 分配和 glibc 碎片则交给 jemalloc + jeprof 的 base 对比归因。修复永远不是"补一个 release"那么简单,而是落实引用计数的所有权规则:入站帧由 SimpleChannelInboundHandler 或 try-finally 释放、异步持有先 retain 再配对 release、异常分支和正常路径同等覆盖。容器时代配好 MaxDirectMemorySize、给直接内存和线程栈在 limit 里留出位置、监控 direct MemoryUsed 曲线,远比 OOM 后半夜翻日志轻松——堆外内存不可怕,账对不上才可怕。
互动话题:你们被堆外内存"教做人"是在哪种场景下——Netty 推送、Redis 客户端、文件下载还是图片处理?抓到元凶用的是 NMT、MAT 还是 jemalloc?评论区聊聊你的 RSS 对账经历。
标题:Java 内存泄漏排查实战:从 MAT 到 jmap——堆外内存暴增的元凶
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/29/1790517594721.html
公众号:服务端技术精选
- 引言
- 一、全景:Java 进程的内存到底花在哪
- 1.1 RSS 的构成
- 1.2 排查路线图
- 二、第一步:确认现象,别一上来就 dump
- 2.1 先分清三件事
- 2.2 再排除堆内泄漏
- 三、第二步:NMT 给 JVM 的本地内存记账
- 3.1 什么是 NMT
- 3.2 三个核心命令
- 3.3 NMT 的 JDK 版本差异(容易踩坑)
- 四、第三步:锁定 DirectByteBuffer——证据在 JMX 和堆里
- 4.1 看直接内存的官方计数
- 4.2 为什么"堆外泄漏"要回到堆里查
- 4.3 不 dump 也能先粗筛一把
- 五、第四步:堆 dump + MAT 顺藤摸瓜
- 5.1 安全地 dump
- 5.2 MAT 里的四步操作
- 5.3 一个应急手段(不是修复)
- 六、第五步:Netty LEAK 检测——让框架亲口说出分配点
- 6.1 四个级别
- 6.2 ByteBuf 引用计数的所有权规则(修复的理论基础)
- 6.3 修复前后对比
- 七、第六步:jemalloc——第三方 native 分配的终极归因
- 7.1 安装与挂载
- 7.2 触发 dump 与分析
- 7.3 顺带解决的 glibc 碎片问题
- 八、修复与预防:七条落地措施
- 九、常见问题
- 9.1 jmap / jstat 都正常,为什么容器还是被杀?
- 9.2 NMT 显示 Internal 涨,但没开 NMT 怎么办?
- 9.3 Full GC 之后直接内存短暂回落,算泄漏吗?
- 9.4 Netty 报 LEAK 但业务正常跑,可以先不管吗?
- 9.5 为什么我 release 了还报 LEAK / 报 IllegalReferenceCountException?
- 9.6 dump 堆会不会把线上服务搞挂?
- 十、总结
- 排查决策树速查卡
- 完整命令速查表
- 给团队的建议
- 一句话
评论