🔍 你的 Java 应用“吃光”了内存?别慌,NMT 帮你揪出真凶!

"堆内存没涨,为啥进程 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块,这些是应用源码本身、或引用的第三方库可能会导致的内存问题,蓝色的部分不用关系,一般都是虚拟机本身的,不会出啥问题。

以下是详细的说明:

  1. Java Heap(Java 堆)
  • 作用 :用于存放 Java 对象实例(即通过 new 创建的对象)。
  • 分配方式 :通过 mmap(内存映射)一次性保留大块虚拟地址空间,按需提交物理内存。
  • 说明:
    • reserved=1024MB 表示 JVM 向操作系统申请了最多 1GB 的虚拟地址空间。
    • committed=512MB 表示当前实际已分配(物理内存或交换空间)512MB。
    • 这部分受 -Xmx(最大堆)和 -Xms(初始堆)控制。

  1. 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 虚拟地址。


  1. Thread(线程)
  • 作用:每个 Java 线程的栈空间(包括主线程、GC 线程、编译线程等)。
  • 分配方式 :通过 mmap(Linux 上通常如此)为每个线程栈分配。
  • 说明:
    • thread #17:当前有 17 个线程。
    • stack: reserved=17MB, committed=1MB:假设每个线程栈默认 1MB(-Xss1m),则 17 线程 ≈ 17MB 保留;但只有部分栈被实际使用(提交 1MB)。

  1. Code(代码缓存)
  • 作用:存储 JIT 编译器生成的本地机器码(如热点方法编译后的 native code)。

  • 分配方式 :通过 mmap

  • 说明:

    • reserved=242MB:JVM 预留了较大空间(默认 CodeCache 大小约 240MB)。
    • committed=7MB:当前只使用了 7MB,说明应用尚未大量触发 JIT 编译。

若 CodeCache 耗尽,JIT 编译会停止,影响性能。


  1. 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)对这块内存需求差异较大。

  1. Internal(JVM 内部使用)
  • 作用:JVM 自身运行所需的临时数据结构,如命令行解析、内部哈希表、监控数据等。
  • 分配方式malloc(小块堆分配)。
  • 说明 :通常较小,这里 1MB 属正常范围。

  1. Other(其他原生内存)
  • 作用:不属于上述分类的原生内存,常见于:
    • JNI 调用中 malloc 的内存(如通过 Unsafe.allocateMemory 或本地库分配)。
    • Direct ByteBuffer(堆外内存)。
    • 第三方 native 库(如 Netty 的池化 direct buffer、OpenSSL 等)。
  • 分配方式malloc(此处显示 malloc=60MB #7,说明有 7 次较大分配)。
  • 注意DirectByteBuffer 不属于 Java Heap,但会计入 NMT 的 "Other" ,容易导致 OOM(OutOfMemoryError: Direct buffer memory)。

  1. 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)

  • 一旦超过,会抛出:

    arduino 复制代码
    OutOfMemoryError: 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%)

  • 关键命令:

    bash 复制代码
    jcmd <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 的内存问题,从来不只是"堆"的问题。


🎁 彩蛋:排查清单

遇到内存异常?按顺序检查:

  1. jcmd <pid> VM.native_memory summary → 看总览
  2. 是否 "Other" 异常增长?
  3. 执行 baseline 后运行一段时间,再 summary.diff
  4. 若 "Other" 涨了,用 detail.diff 看调用栈
  5. 检查代码中是否滥用 ByteBuffer.allocateDirect()Unsafe
  6. 设置 -XX:MaxDirectMemorySize 并监控!

希望这篇文章能帮你少踩坑、多睡觉 😴

如果你觉得有用,欢迎转发给那个总在半夜被内存告警叫醒的同事!

相关推荐
悟空码字2 小时前
Java短信验证码保卫战,当羊毛党遇上“铁公鸡”
java·后端
爱吃KFC的大肥羊2 小时前
Redis 基础完全指南:从全局命令到五大数据结构
java·开发语言·数据库·c++·redis·后端
用户2190326527352 小时前
Spring Boot4.0整合RabbitMQ死信队列详解
java·后端
golang学习记2 小时前
Go Gin 全局异常处理:别让 panic 把你的服务“原地升天”
后端
Targo2 小时前
Go 高可用策略库-Resilience
后端·go
天天摸鱼的java工程师2 小时前
🚪单点登录实战:同端同账号互踢下线的最佳实践(Java 实现)
java·后端
Cache技术分享2 小时前
270. Java Stream API - 从“怎么做”转向“要什么结果”:声明式编程的优势
前端·后端
小飞Coding2 小时前
Java堆外内存里的“密文”--从内存内容反推业务模块实战
jvm·后端
狂奔小菜鸡2 小时前
Day29 | Java集合框架之Map接口详解
java·后端·java ee