"堆内存没涨,为啥进程 RES 内存飙到 大几G?" "OutOfMemoryError: Direct buffer memory 是啥?我根本没开大堆啊!" ------ 这些问题,其实都藏在 JVM 的"堆外世界" 里。
今天,我们就用一个简单实验 + 一个超好用的 JDK 工具------NMT(Native Memory Tracking),带你亲手揭开 Java 应用"神秘吃内存"的真相。
🛠️ 一、先说说:NMT 是谁?
从 Java 7u40 开始 ,JDK 就悄悄内置了一个"内存侦探"------Native Memory Tracking(NMT)。
它的作用很简单:追踪 JVM 自己用了多少原生内存(native memory),比如:
- Java 堆(Heap)
- 类元数据(Metaspace)
- 线程栈
- JIT 编译代码
- GC 内部结构
- 还有最容易被忽视的:堆外内存(Other)
⚠️ 注意:NMT 只能跟踪 JVM 自身分配的内存,第三方 native 库(如 OpenSSL、OpenCV)的内存它看不见。但它已经能解决 90% 的"内存去哪了"问题!
🧪 二、来个"犯罪现场":模拟堆外内存泄漏
我们写一段"看似无害"的代码:
java
List<ByteBuffer> list = new ArrayList<>();
while (true) {
// 每2秒,偷偷申请10MB堆外内存
ByteBuffer buf = ByteBuffer.allocateDirect(10 * 1024 * 1024);
list.add(buf); // 但!不释放!
Thread.sleep(2000);
}
这段代码干了什么?
- 用
ByteBuffer.allocateDirect()分配 堆外内存(off-heap) - 把引用存进 List,永远不释放
- 结果:内存像漏水的桶,越积越多......
启动时加上 NMT 开关:
bash
nohup java -Xmx1g -Xmn512m -Xms512m -XX:NativeMemoryTracking=detail -jar java-demo-1.0-SNAPSHOT.jar > app.log 2>&1 &

✅
-XX:NativeMemoryTracking=detail:开启详细模式,才能看到调用栈!
🔎 三、第一步:打个"基线",锁定起点
应用刚启动,还没开始"作案"时,我们先拍张"案发现场快照":
bash
jcmd 140190 VM.native_memory baseline scale=MB

✅ 输出 Baseline succeeded,说明基线已记录。
此时看系统内存(top):

- RES(物理内存占用)≈ 33MB 一切看起来岁月静好。
🔎四、也顺便先看下此时的NMT数据长啥样?
shell
jcmd 140190 VM.native_memory summary scale=MB

一般情况下,只需关注红色部分的内存区域,Java Heap、Class、Thread、Other这4块,这些是应用源码本身、或引用的第三方库可能会导致的内存问题,蓝色的部分不用关系,一般都是虚拟机本身的,不会出啥问题。
以下是详细的说明:
- Java Heap(Java 堆)
- 作用 :用于存放 Java 对象实例(即通过
new创建的对象)。 - 分配方式 :通过
mmap(内存映射)一次性保留大块虚拟地址空间,按需提交物理内存。 - 说明:
reserved=1024MB表示 JVM 向操作系统申请了最多 1GB 的虚拟地址空间。committed=512MB表示当前实际已分配(物理内存或交换空间)512MB。- 这部分受
-Xmx(最大堆)和-Xms(初始堆)控制。
- Class(类元数据)
这部分包含两部分:
a) Metadata(非 Class space 部分)
- 作用 :存储类的元数据(如方法、字段信息等),在 Java 8+ 中由 Metaspace 管理(取代永久代)。
- 分配方式 :通过
mmap分配。 - 说明:
reserved=8MB, committed=4MB, used=4MB:表示当前加载了约 652 个类,使用了 4MB 元数据空间。
b) Class space(压缩类指针空间)
- 作用 :当启用 Compressed Class Pointers(默认开启,若堆 ≤32GB)时,JVM 会将类指针压缩为 32 位,并集中存放在一个连续的 1GB 地址空间中(即 Class Space)。
- 分配方式 :通过
mmap保留一大块(通常是 1GB),但只提交少量。 - 说明:
reserved=1024MB是为压缩类指针预留的固定空间。committed=1MB, used=0MB表示目前几乎没有使用(可能因为类较少或未启用压缩类指针?但通常会用一点)。
⚠️ 注意:即使
used=0MB,只要启用了压缩类指针,JVM 仍会保留这 1GB 虚拟地址。
- Thread(线程)
- 作用:每个 Java 线程的栈空间(包括主线程、GC 线程、编译线程等)。
- 分配方式 :通过
mmap(Linux 上通常如此)为每个线程栈分配。 - 说明:
thread #17:当前有 17 个线程。stack: reserved=17MB, committed=1MB:假设每个线程栈默认 1MB(-Xss1m),则 17 线程 ≈ 17MB 保留;但只有部分栈被实际使用(提交 1MB)。
- Code(代码缓存)
-
作用:存储 JIT 编译器生成的本地机器码(如热点方法编译后的 native code)。
-
分配方式 :通过
mmap。 -
说明:
reserved=242MB:JVM 预留了较大空间(默认 CodeCache 大小约 240MB)。committed=7MB:当前只使用了 7MB,说明应用尚未大量触发 JIT 编译。
若 CodeCache 耗尽,JIT 编译会停止,影响性能。
- GC(垃圾回收器内部结构)
- 作用:GC 算法所需的内部数据结构,如卡表(Card Table)、Remembered Sets、标记位图(Mark Bitmap)等。
- 分配方式:
malloc=10MB:小块动态分配(如 G1 的 Remembered Sets)。mmap=70MB reserved / 51MB committed:大块结构(如 G1 的 Heap Region 表、ZGC 的 forwarding table 等)。
- 说明:不同 GC 算法(如 G1、ZGC、Parallel)对这块内存需求差异较大。
- Internal(JVM 内部使用)
- 作用:JVM 自身运行所需的临时数据结构,如命令行解析、内部哈希表、监控数据等。
- 分配方式 :
malloc(小块堆分配)。 - 说明 :通常较小,这里
1MB属正常范围。
- Other(其他原生内存)
- 作用:不属于上述分类的原生内存,常见于:
- JNI 调用中
malloc的内存(如通过Unsafe.allocateMemory或本地库分配)。 - Direct ByteBuffer(堆外内存)。
- 第三方 native 库(如 Netty 的池化 direct buffer、OpenSSL 等)。
- JNI 调用中
- 分配方式 :
malloc(此处显示malloc=60MB #7,说明有 7 次较大分配)。 - 注意 :DirectByteBuffer 不属于 Java Heap,但会计入 NMT 的 "Other" ,容易导致 OOM(
OutOfMemoryError: Direct buffer memory)。
- Symbol(符号表)
- 作用:存储类名、方法名、字段名等字符串符号(interned strings for internal use)。
- 分配方式:
malloc=1MB:动态分配字符串内容。arena=1MB:使用 arena allocator(一种高效批量分配器)管理符号表节点。
- 说明:随类加载数量增加而增长,但通常不大。
📈 五、过一会儿,再看:内存暴涨!
运行几分钟后,再次执行:
bash
jcmd 140190 VM.native_memory summary.diff scale=MB

输出关键部分:
text
Total: reserved=3268MB +780MB, committed=1459MB +780MB
- Other (reserved=870MB +780MB, committed=870MB +780MB)
(malloc=870MB +780MB #88 +78)
🔍 重点来了:
- Java Heap 没变(还是 512MB committed)
- Class、Thread、Code 全都没涨
- 唯独 "Other" 猛增 780MB!
💡 "Other" 是 NMT 里的"杂物间",专门收容:
DirectByteBuffer(堆外缓冲区)Unsafe.allocateMemory()(危险操作!)- JNI 调用的 native 内存
所以,问题一定出在堆外!
🕵️♂️ 六、放大招:用 detail.diff 定位"凶手"
光知道"Other 涨了"还不够,我们要知道 谁干的!
执行:
bash
jcmd 140190 VM.native_memory detail.diff scale=MB

结果末尾出现一段"通缉令":
text
[0x00007f1ff3c6363d] Unsafe_AllocateMemory0+0xbd
(malloc=900MB type=Other +810MB #91 +81)
此时java进程的RES内存如下:

上图显示当前的RES内存为957.4M,比刚启动时33.4M,增加了924M。
🎉 破案了!
- 调用栈明确指向:
Unsafe_AllocateMemory0 - 对应 Java 代码就是:
sun.misc.Unsafe.allocateMemory() - 虽然我们的代码用的是
ByteBuffer.allocateDirect(),但底层正是通过Unsafe实现的! - NMT 在
detail模式下,能识别出这是 DirectByteBuffer 的分配行为
📌 补充:如果你真的用了
Unsafe.allocateMemory()(而不是 ByteBuffer),这里会直接显示,且不受-XX:MaxDirectMemorySize限制,更危险!
特别说明下:
为什么detail.diff显示是+810MB,+81?而summary.diff显示是+780MB,+78
因为博主是在summary.diff后,才执行了detail.diff,模拟代码中是每2秒分配10MB,所以detail.diff显示增加的多。
📊 七、验证:系统内存也对上了!
回到 top 看进程 RES 内存:
- 初始:33.4 MB
- 几分钟后:957.4 MB
- 差值 ≈ 924 MB,和 NMT 报告的 +810~+900MB 高度吻合!
✅ 说明:NMT 数据靠谱!堆外内存确实"吃"掉了近 1GB 物理内存。
🚨 八、为什么这很危险?
很多人以为:"只要 -Xmx 设小点,内存就安全"。大错特错!
-
堆外内存不受
-Xmx控制! -
默认情况下,
-XX:MaxDirectMemorySize=-Xmx(本例是 1GB) -
一旦超过,会抛出:
arduinoOutOfMemoryError: Direct buffer memory -
更惨的是:如果用的是
Unsafe.allocateMemory(),连这个限制都没有!可能直接把机器干崩,被 Linux OOM Killer 无情 kill。下图可以看到,当堆外内存溢出时,进程直接就飞了。。。

🛡️ 九、如何避免?3 条黄金建议
✅ 1. 慎用堆外内存
- 能用堆内就别用
allocateDirect() - 如果必须用(如 Netty、高性能 I/O),务必控制总量 + 及时释放
✅ 2. 显式设置堆外上限
bash
-XX:MaxDirectMemorySize=512m
这样即使泄漏,也能早点暴露问题,而不是默默吃光内存,不设置的话默认等于-Xmx。
✅ 3. 定期用 NMT 监控
-
生产环境可临时开启 NMT(有轻微性能损耗,约 5~10%)
-
关键命令:
bashjcmd <pid> VM.native_memory summary # 总览 jcmd <pid> VM.native_memory baseline # 打基线 jcmd <pid> VM.native_memory summary.diff # 看变化
🧰 10、附:NMT 各区域速查表(重点关注这4块)
| 区域 | 是否需关注 | 常见问题 |
|---|---|---|
| Java Heap | ✅ | 对象泄漏、GC 不及时 |
| Class | ✅ | 动态生成类过多(如 Groovy、反射) |
| Thread | ✅ | 线程池未回收、线程泄漏 |
| Other | 🔥🔥🔥 | 堆外内存泄漏!最常见"隐形杀手" |
| Code / GC / Internal | ❌ | JVM 自身使用,一般无需干预 |
💬 最后说两句
NMT 就像给 JVM 装了个"内存摄像头",让你看清每一 MB 内存的去向。 下次再遇到"内存莫名增长"、"进程被 kill"、"Direct buffer OOM",别再瞎猜了------
打开 NMT,打个基线,diff 一下,真相就在眼前。
📌 记住:Java 的内存问题,从来不只是"堆"的问题。
🎁 彩蛋:排查清单
遇到内存异常?按顺序检查:
jcmd <pid> VM.native_memory summary→ 看总览- 是否 "Other" 异常增长?
- 执行
baseline后运行一段时间,再summary.diff - 若 "Other" 涨了,用
detail.diff看调用栈 - 检查代码中是否滥用
ByteBuffer.allocateDirect()或Unsafe - 设置
-XX:MaxDirectMemorySize并监控!
希望这篇文章能帮你少踩坑、多睡觉 😴
如果你觉得有用,欢迎转发给那个总在半夜被内存告警叫醒的同事!