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 正常,说明压力不在堆里

RSS 增长趋势

这一步排除了"堆泄漏"——不需要 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 进程内存分布

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 summary baseline diff

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

NMT detail Internal 细分

关键发现——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_AllocateMemorysun.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

strace 跟踪 JNI 内存分配

输出显示 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.cprocess() 函数:

JNI 代码 malloc 未 free

两处泄漏:

  1. malloc(len + 1024) — 每次调用申请 ~11KB(输入块平均大小),从来没有对应的 free。每 30s 一次,一天 ~31MB。这部分通过 Unsafe 分配路径被 NMT 记在了 Internal/Unsafe 分类下
  2. process_internalmalloc(256 * 1024) — 每次 256KB 处理缓存,同样只分配不释放。256KB 超过 mmap 阈值,走 libc 的 mmap 而非 JVM Unsafe,所以 NMT 完全不知道——这就是绕过 NMT 的那部分缺口

此外 GetByteArrayElements 获取的 native_input 也没有对应 Release——在 JDK 11 的某些 GC 模式下会 pin 住堆内存,但这不是 RSS 涨的主因。

主因是 mallocfree。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. 最便宜:容器重启——RSS 回到 1.2GB,但 3 天又吃满。治标不治本
  2. 中等:补 free(buf) + ReleaseByteArrayElements——根因修复,改两行代码
  3. 最彻底:valgrind 审查全部 JNI 代码,建立 JNI 内存生命周期审查 pipeline——每次提交自动扫描

NMT 最佳实践

这条排查经历给了一个残酷的教训:NMT 不是事后诸葛亮的工具,它是事前的预警雷达

生产环境建议:

  1. 默认开启:JDK 11+ 的容器化部署,-XX:NativeMemoryTracking=summary 应加入默认 JVM 参数。性能损耗约 5%,但排查时省 90% 的时间
  2. 基线打标签:每次发版后执行 jcmd <pid> VM.native_memory baseline,打一个版本标签。下次出问题时直接 diff 两个版本间的变化
  3. NMT 与 OS 交叉校验:不要只看 NMT 数字。NMT total < pmap RSS - heap 时,说明有 NMT 追踪不到的区域,缺口大小就是漏洞的线索
  4. 告警阈值: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拆代码的曹操」