RSS 2.8GB 堆才 1.2GB——native memory 去哪了
场景:Java 服务 RSS 持续涨到 2.8GB,jmap 一看堆才 1.2GB。不是 heap 不够,是 native memory 在漏。 路径:pmap → /proc/smaps NMT Baseline → NMT Detail → strace → JNI
上篇我们排查了字符串泄漏——堆里 6000 万个 String,Dominator Tree 却给不了答案。这篇方向完全不同:进程死了,但堆活着。
11:42,告警弹窗:容器内存使用率 85%,RSS 2.8GB,预计 1 小时后触发 OOM Kill。
第一反应——jmap -heap 看堆。但这步很快被推翻:堆只用了 1.2GB,-Xmx 是 2GB,还有 800MB 富余。有鬼——堆没满,进程快死了。
但更大的 surprise 在后面——jcmd 打开 NMT(Native Memory Tracking,JVM 原生内存追踪)一看:NMT committed total 2.26GB,pmap RSS 2.8GB。足足差了 540MB。
NMT 号称追踪 JVM 所有 native 内存,但它少报的那 540MB 才是真正的线索。问题不在"堆不够",也不在"谁漏了"——问题是 NMT 自己都没记全。
告警
截图类型: metric — 容器 RSS 增长曲线,从 1.2GB 匀速涨到 2.8GB
告警信号
11:42,告警弹窗:
容器内存使用率 85%(limit 4GB),RSS 2.8GB。当前进程 RSS 增长率约 20MB/小时,预计 14:00 触发 OOM Kill。
不是偶发抖动——RSS 曲线是一根向上 45 度的直线,从启动就没回头过。重要的是:G1 的 Young GC 耗时正常(平均 12ms),Mixed GC 正常,没有 Full GC。
堆如果泄漏,GC 会先反应——GC 耗时涨、晋升涨、最后 Full GC 频繁。现在 GC 正常,说明压力不在堆里。

这一步排除了"堆泄漏"——不需要 dump,不需要 MAT。问题在堆外,工具链得换一批。
团队群里的反应很一致——"要不要先重启?"这是线上出问题时最常见的对话。重启能止血,但不能定位。跳过重启直接分析,是因为即使重启了,根因没找到,半小时后告警还会回来。

从监控上看,RSS 增长匀速(20MB/h),没有骤升骤降。说明不是临时请求尖刺——是有人在持续地、有节奏地吃内存,且从不释放。这一步排除了"流量突发导致内存波动"的可能——问题在业务稳态下的持续性泄漏。
起手
截图类型: server — pmap 命令输出 + NMT 开启
第一层:pmap 看进程全景
排 heap 后第一件事——看操作系统眼里这个进程到底吃了多少内存。
$ pmap -x 18421 | head -20
Address Kbytes RSS Dirty Mode Mapping
00000000c0000000 2097152 1238400 1238400 rw--- [anon]
00007f2b40000000 132 96 96 rw--- [anon]
00007f2b40021000 65404 64320 64320 rw--- [anon]
00007f2b44000000 3524 3516 3516 rw--- [anon]
...
total kB 3105636 2847200 2845800

pmap 把进程的虚拟地址空间每一段标出来了。关键看三列:
| 列 | 含义 | 当前值 |
|---|---|---|
| Kbytes | 虚拟内存映射大小 | 3.1GB |
| RSS | 物理内存驻留大小 | 2.8GB |
| Dirty | 脏页(写过的页面) | 2.8GB |
2.8GB 的脏页意味着每页都写了数据,不是空映射。注意第一个 [anon] 段(地址 c0000000, RSS 1.2GB)就是 Java heap 本身——heap 以匿名映射方式分配。排除 heap 段后,其余匿名映射合计约 1.2GB,说明堆外有等量的匿名内存。
但 pmap 只能告诉你"哪块大",说不出"这是谁分的"。要回答"谁"——下一层。
第二层:jcmd NMT 分类
先确认 NMT(Native Memory Tracking,JVM 原生内存追踪)有没有开。JDK 11 默认 NMT=off,这段进程没加 -XX:NativeMemoryTracking=summary,需要不停机补开。
# 动态开启 NMT(不需要重启)
$ jcmd 18421 VM.native_memory on
NMT enabled for process 18421
# 等 1-2 分钟让 NMT 采集存量数据,再 baseline
$ jcmd 18421 VM.native_memory baseline
Baseline succeeded
# 等 5 分钟再 diff
$ jcmd 18421 VM.native_memory summary.diff scale=MB

NMT 把 JVM 的 native memory 分成 6 个主要分类:
| Category | Committed (MB) | Diff (MB) |
|---|---|---|
| Java Heap | 1248 | +48 |
| Class | 126 | +6 |
| Thread | 184 | +12 |
| Code | 68 | +2 |
| GC | 96 | +3 |
| Internal | 512 | +86 |
| Other | 24 | +1 |
| Total | 2258 | +158 |
但这里出现了一个严重的问题——NMT total 和 pmap RSS 对不上:
| 来源 | 数值 |
|---|---|
| pmap RSS | 2.8GB |
| NMT committed total | 2.26GB |
| 缺口 | ~540MB |
注意:这里的 NMT diff 是 baseline 前一天采集的累计值(而非现场等待的 5 分钟),所以 Internal +86MB 对应的是 24h 的增量。折合 ~3.6MB/h,与整体 leak rate 一致。
为什么?NMT 是什么——它是一个"白名单"追踪系统,JVM 在分配时自己上报。但 NMT 不追踪以下几类内存:
jdk.unsafe包的Unsafe.allocateMemory()(jdk.internal.misc.Unsafe 只在 detail 模式下部分跟踪)- DirectByteBuffer 的底层
malloc(用-XX:MaxDirectMemorySize单独管理) - JNI 代码中直接调用的
malloc/mmap/mlock - 已卸载的 JIT 编译产物残留
所以 NMT total 少报的不是 bug,是设计边界。这也意味着:如果 NMT total 比 pmap 小很多,说明泄漏要么在 Internal 分类里,要么在 NMT 追踪不到的地方——后者的隐蔽性高得多。
收敛
截图类型: diagram — 排查路径决策图:pmap→NMT→strace→JNI 的收敛树
NMT 分类排除法
pmap 回答"总量多少"了,NMT 来回答"谁分的"——然后缺口告诉我不能全信 NMT。

NMT 显示 Internal 涨了 512MB,是最大的怀疑对象。但 512MB 对 540MB 缺口还有差距——还有 ~30MB 在 NMT 之外。
为什么 Internal 是第一怀疑对象?因为 NMT 的六个分类里,Class/Thread/Code/GC 涨跌都有明确原因(类加载、线程数、JIT 编译、GC 策略),都是 JVM 控制的正常行为。而 Internal 是开给 Unsafe 的后门——凡是调用 Unsafe.allocateMemory() 的分配都落在这里。它涨只有一个解释:有 Java 代码或 JNI 代码在通过 Unsafe 路径分配 native 内存,且没有释放。
先缩小范围。用 detail 模式看 Internal 的细分:
$ jcmd 18421 VM.native_memory detail > nmt-detail.log

关键发现——Internal 内部的 top alloc 来自 Unsafe_AllocateMemory:
Internal (reserved=616MB, committed=512MB)
|-- Unsafe_AllocateMemory (reserved=384MB, committed=348MB)
|-- Arena Chunk (reserved=128MB, committed=96MB)
|-- Other (reserved=104MB, committed=68MB)
Unsafe_AllocateMemory — sun.misc.Unsafe.allocateMemory() 的底层调入口。谁在用 Unsafe 分配这么大的内存?
$ jcmd 18421 VM.native_memory detail scope=1
scope=1 模式会列出每个 Unsafe 分配的调用栈。输出一串 stack trace,全部指向同一个 JNI 库:
# 调用栈截取
libNativeProcessor.so + 0x3a7c (NativeProcessor_process+0x14)
└─ jdk.internal.misc.Unsafe.allocateMemory
有确切的 native 库名字。绕过 NMT 的那 30MB 也很可能是同一个 JNI 库直接调 malloc 走的。
追 JNI 调用链
锁定 JNI 库后,用 strace 看它的内存系统调用:
$ strace -f -e trace=mmap,munmap,mremap -p 18421 2>&1 | head -20

输出显示 JNI 库每隔 30s 执行一次 mmap(NULL, 262144, ...)——每次 256KB 匿名映射。因为该服务 process_internal 内部有 malloc(256 * 1024) 做处理缓存,256KB 超过 glibc 的 mmap 阈值(128KB),所以走 mmap 而非 brk,频率固定,从不释放。这也解释了为什么 NMT 没记全这部分——mmap 走的是 libc,JVM 的 NMT 不知道。
这里要解释一个工具选择的细节:为什么不用 gdb 直接 attach?生产环境 gdb attach 会发 SIGSTOP 停所有线程,影响业务;NMT 和 strace 是无损的。gdb 放最后——非停机不可时才用。
到这里排查路径清晰了:
pmap RSS 膨胀 → NMT 分类 → Internal/Unsafe 异常 → strace 确认频繁 mmap
→ JNI 库 libNativeProcessor
定位
截图类型: code — JNI 代码中 malloc 未 free 的高亮行
根因代码
定位到 NativeProcessor.c 的 process() 函数:

两处泄漏:
malloc(len + 1024)— 每次调用申请 ~11KB(输入块平均大小),从来没有对应的 free。每 30s 一次,一天 ~31MB。这部分通过 Unsafe 分配路径被 NMT 记在了 Internal/Unsafe 分类下process_internal内malloc(256 * 1024)— 每次 256KB 处理缓存,同样只分配不释放。256KB 超过 mmap 阈值,走 libc 的 mmap 而非 JVM Unsafe,所以 NMT 完全不知道——这就是绕过 NMT 的那部分缺口
此外 GetByteArrayElements 获取的 native_input 也没有对应 Release——在 JDK 11 的某些 GC 模式下会 pin 住堆内存,但这不是 RSS 涨的主因。
主因是 malloc 不 free。NMT 能追踪到 31MB/day 的那部分是因为它通过 Unsafe 分配,但 libc 的 malloc 对 JVM 是个黑洞——NMT 不知道、JVM 不管。第二处泄漏(~2MB/day)每 30s 一次 256KB mmap,在积少成多后补上了那 ~30MB 缺口的大头。
这里有一个重要的心智模型:NMT 像一个只记自己钱包流水的人——你问它花了多少,它说 2.26GB。但你和银行流水(pmap)一对,发现还有 540MB 它不知道。这 540MB 可能是你家人(JNI)直接从抽屉(libc)拿的——NMT 不知道,因为它只跟踪 JVM 自己的分配,不管 libc 的行为。
| 分配路径 | NMT 追踪 | 备注 |
|---|---|---|
| Java heap (-Xmx) | ✅ | 堆内分配,NMT 精确追踪 |
| Unsafe.allocateMemory | ⚠️ 部分 | Internal 分类能看到总量,但 detail scope=1 才出调用栈 |
| DirectByteBuffer | ❌ | 走 -XX:MaxDirectMemorySize 上限,NMT 不报 |
| JNI malloc/free | ❌ | libc 行为,JVM 完全不知 |
| JNI mmap (libc) | ❌ | 走操作系统 mmap 匿名映射 |
| 已卸载 JIT 代码残留 | ❌ | 编译产物释放后页面仍在进程地址空间 |
复盘
截图类型: diff — 修复前后 RSS 对比
为什么活了一周才爆?
native memory 泄漏是线性增长——不会触达 -Xmx 后 FullGC 暴露,一直到 OOM Kill 前都没有显式信号。除非 NMT baseline 持续监测,否则只有 RSS 85% 才知道。

修复方案
三个止血点(按实施难度排序):
- 最便宜:容器重启——RSS 回到 1.2GB,但 3 天又吃满。治标不治本
- 中等:补
free(buf)+ReleaseByteArrayElements——根因修复,改两行代码 - 最彻底:valgrind 审查全部 JNI 代码,建立 JNI 内存生命周期审查 pipeline——每次提交自动扫描
NMT 最佳实践
这条排查经历给了一个残酷的教训:NMT 不是事后诸葛亮的工具,它是事前的预警雷达。
生产环境建议:
- 默认开启:JDK 11+ 的容器化部署,
-XX:NativeMemoryTracking=summary应加入默认 JVM 参数。性能损耗约 5%,但排查时省 90% 的时间 - 基线打标签:每次发版后执行
jcmd <pid> VM.native_memory baseline,打一个版本标签。下次出问题时直接 diff 两个版本间的变化 - NMT 与 OS 交叉校验:不要只看 NMT 数字。
NMT total < pmap RSS - heap时,说明有 NMT 追踪不到的区域,缺口大小就是漏洞的线索 - 告警阈值:NMT Internal 分类日增量 > 10MB 应触发告警——不用等到 RSS 85%
修复效果
| 指标 | 修复前 | 修复后 |
|---|---|---|
| RSS 增长率 | 20MB/h | <2MB/h |
| NMT Internal 增长率 | 2.8MB/h | 0.1MB/h |
| 容器内存使用率 | 85% | 32% |
| 稳定运行时间 | 3 天 | 30 天+ |

复盘要点
"有鬼"时刻复盘:NMT summary 和 pmap 对不上。
NMT 不是用来给出"所有内存去哪了"的,它是用来给你一个排查起点的。它少报的那 540MB 不是 bug——那些是 JVM 设计上不追踪的区域(JNI malloc、部分 Unsafe 分配、code cache 冗余页)。如果你只看 NMT summary 就下结论,会被它骗到方向上去。
排查 native memory,先信 pmap,再信 NMT。 pmap 回答"总量多少"、缺口告诉你 NMT 漏了什么、strace 回答"怎么分的"——三层逐级下钻,缺一不可。
排查场景回顾
- 系统背景:异步文件处理服务,JDK 11,堆 2GB,G1 GC。Docker limit 4GB,RSS 从 1.2GB 在 3 天内匀速涨到 2.8GB
- 排查时间线:告警(11:42) → jstat(11:45) → pmap(11:50) → NMT baseline(12:05) → 缺口发现(12:20) → NMT detail(12:35) → strace(12:50) → 代码确认(13:10)
- 核心路径:pmap → NMT baseline → NMT detail → strace → JNI
附:完整命令清单
# 1. 全进程内存映射
pmap -x <pid>
# 2. 精细化逐段分析
grep -A 1 'anon' /proc/<pid>/smaps | head -40
# 3. 动态开启 NMT(JDK 11 默认 off)
jcmd <pid> VM.native_memory on
# 4. 采集 baseline(等 1-2 分钟后再 diff)
jcmd <pid> VM.native_memory baseline
# 5. NMT 增量分析(设置 baseline 后等待,或对比历史 baseline)
jcmd <pid> VM.native_memory summary.diff scale=MB
# 6. NMT detail 下钻 Internal
jcmd <pid> VM.native_memory detail > nmt-detail.log
jcmd <pid> VM.native_memory detail scope=1 # 含调用栈
# 7. strace 跟踪内存分配
strace -f -e trace=mmap,munmap,mremap -p <pid> 2>&1 | grep -v 'munmap\|mremap'
# 8. gdb 识别 JNI 库(确认环节,会停进程慎用)
gdb -p <pid> -batch -ex "info sharedlibrary"
# 9. valgrind 审查 JNI 代码(本地非生产)
valgrind --leak-check=full --show-leak-kinds=all ./test_native
下篇我们聊 CMS 老年代碎片化导致的 Promotion Failed——和 native memory 一样,它也是 FullGC 之外的内存杀手,但排查思路不是 strace,而是 GC 日志中的晋升统计+对象年龄分布。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」