Java 基础体系 · 第 76/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
JVM Native Memory:堆外、线程栈、Metaspace、NMT 和泄漏
在 JVM 进程中,“内存使用量”不是一个单一数字。Java 堆只是 JVM 使用的内存区域之一;线程栈、Metaspace、JIT 编译产生的代码、GC 数据结构、Direct Buffer、JNI 库以及本地库分配的内存,都可能位于 Java 堆之外。
因此,下面三个数字经常同时出现且彼此不一致:
- Java 进程的 RSS:操作系统当前认为驻留在物理内存中的页面。
- JVM 的 committed memory:JVM 已经向运行时提交、准备使用的内存。
- Java 堆已使用量:由 GC 管理的对象当前占用量。
例如,Java 堆使用量可能没有增长,但线程数、类加载器数量或 Direct Buffer 数量持续增加,最终仍然可能触发操作系统内存压力或容器 OOM。
1. Native Memory 到底是什么
1.1 Java 堆之外不等于一种内存
“堆外内存”通常是工程上的统称,不是 JVM 规范中的单一内存区域。它至少可能包括:
| 来源 | 典型内容 | 是否由 Java GC 直接管理 |
|---|---|---|
| 线程栈 | Java 线程调用栈、局部执行状态、保护页 | 否 |
| Metaspace | 类元数据、方法元数据、部分运行时类信息 | 不直接按普通对象管理 |
| Compressed Class Space | 压缩类指针关联的类空间 | 不直接按普通对象管理 |
| Code Cache | JIT 编译后的机器码 | 否 |
| Direct Buffer | ByteBuffer.allocateDirect 分配的缓冲区 |
缓冲区对象由 GC 管理,数据区不在堆中 |
| JNI / FFM / 本地库 | C/C++ 分配的结构和缓冲区 | 否,取决于调用方 |
| GC 和 JVM 内部结构 | 卡表、Remembered Set、标记位图、线程同步结构等 | 否 |
mmap 文件或共享库 |
映射文件、动态库、共享内存 | 否 |
| C 运行时分配 | malloc、线程本地缓存、分配器碎片 |
否 |
Java 虚拟机规范主要定义了运行时数据区和执行语义,并没有规定 HotSpot 必须如何组织 native memory。比如,规范要求每个线程有自己的 JVM 栈,但没有要求这个栈必须使用某个操作系统 API,也没有规定其保留地址空间和实际提交内存的精确大小。
因此,需要区分三个概念:
- 规范保证:Java 虚拟机必须提供符合执行语义的运行时数据区。
- 实现事实:HotSpot 通常使用操作系统线程和 native stack 实现平台线程的执行栈。
- 工程观察:可以用 NMT、
jcmd、操作系统工具和 JFR 观察部分内存行为,但这些工具不会自动覆盖所有第三方 native 分配。
1.2 Reserved、Committed 和 RSS
内存诊断中最容易误判的地方是把“地址空间保留”当成“已经占用物理内存”。
设:
R:reserved,保留的虚拟地址空间;C:committed,JVM 已提交、可使用的地址空间;P:resident,当前驻留在物理内存中的页面,也就是 RSS 的重要组成部分。
通常有:
但这是理解 JVM 内部区域时的近似关系,不是对整个进程所有映射都严格成立的账本。文件映射、共享页、内核回收、透明大页和分配器行为都会使操作系统统计与 JVM 统计存在差异。
例如:
某线程栈:
reserved = 1 MiB
committed = 64 KiB
resident = 16 KiB
这表示 JVM 或操作系统为该线程预留了较大的栈地址空间,但当前只有一小部分页面实际提交或驻留。线程递归加深、调用更多本地函数或触碰更多栈页时,committed 和 RSS 可能增加。
反过来,某块 native 内存释放后,C 运行时分配器可能暂时保留这些页面,不立即归还操作系统。此时应用层已经释放,但进程 RSS 不一定马上下降。这属于分配器缓存或碎片,不一定是 Java 对象泄漏。
一个粗略的进程内存模型可以写成:
其中:
H_r:Java 堆驻留页面;M_r:Metaspace 和类空间驻留页面;S_r:线程栈驻留页面;C_r:Code Cache 驻留页面;G_r:GC 内部结构;D_r:Direct Buffer 等堆外缓冲区;N_r:JNI、本地库和其他 native 分配;F_r:文件映射、共享库和其他进程级开销。
这个式子不是操作系统的精确计费公式,因为共享库页面可能被多个进程共享,部分区域也可能存在统计重叠。它的用途是防止把所有 RSS 增长都归因于 Java 堆。
2. Java 堆、Native Memory 与 GC 的关系
Java 堆中的对象由垃圾收集器追踪。一个对象不可达后,GC 可能回收其堆内存;但这不意味着对象关联的所有 native 资源都同步释放。
以 Direct Buffer 为例:
ByteBuffer buffer = ByteBuffer.allocateDirect(256 * 1024 * 1024);
这里通常存在两个不同的实体:
ByteBuffer Java 对象 ──位于 Java 堆
│
└──关联的 native 缓冲区 ──位于堆外
当 buffer 仍然被 Java 引用时,native 缓冲区不能安全释放。即使 Java 引用已经消失,释放动作也通常依赖清理机制和 GC 发现不可达对象的时机,而不是像 free() 那样由业务代码立即执行。
因此,下面两个现象都可能发生:
- Java 堆占用较低,但 Direct Buffer 很多,进程 RSS 很高;
- Direct Buffer 的 Java 包装对象已经不可达,但 native 内存因为尚未触发清理或分配器缓存,RSS 下降滞后。
-XX:MaxDirectMemorySize 可以限制 HotSpot 管理的 direct buffer 容量上限,但它不是所有 native 内存的总上限,也不能限制任意 JNI 库、内存映射文件或本地分配器的使用。
3. 线程栈:每个线程都消耗什么
3.1 平台线程的栈
Java 平台线程通常对应一个操作系统线程。线程创建时,JVM 需要为其执行栈保留地址空间,线程运行期间还会使用:
- Java 方法调用所需的栈帧;
- native 方法调用所需的栈空间;
- 栈保护页;
- 操作系统线程控制块;
- JVM 保存的线程状态;
- 调度和同步相关的 native 结构。
-Xss 用于设置 Java 线程栈大小,例如:
java -Xss1m -cp out demo.ThreadMemoryDemo
它表示每个线程的栈大小配置;实际占用并不一定等于 1 MiB,因为平台实现、保护页、提交策略和 native 调用都可能影响结果。-Xss 是常用写法,HotSpot 还提供对应的 -XX:ThreadStackSize 选项,但单位和行为应以目标平台上的 JDK 文档及实际启动结果为准。
如果有 T 个平台线程,可以先用以下模型估算栈地址空间:
其中:
T是平台线程数量;X是单线程栈配置大小。
例如:
线程数 T = 2,000
-Xss X = 1 MiB
粗略栈地址空间 = 2,000 × 1 MiB ≈ 2 GiB
这不是精确的 RSS 预测。实际 RSS 通常低于这个数,因为栈页面只有被触碰后才会提交;但线程数量过多仍可能造成以下问题:
- 虚拟地址空间耗尽;
- native thread 创建失败;
- 线程调度开销增加;
- 栈保护页和线程控制结构占用内存;
- 容器 cgroup 内存达到上限。
常见错误表现包括:
java.lang.OutOfMemoryError: unable to create native thread
这个错误不等价于 Java 堆不足。可能原因包括:
- 进程或容器达到线程数限制;
- 操作系统无法为新线程提供栈地址空间;
- native memory 不足;
- 用户进程数或 cgroup
pids限制; - 文件描述符、调度资源或其他内核资源不足。
3.2 线程泄漏与栈泄漏
如果线程池、定时任务或阻塞 IO 线程不断创建新线程,却没有退出机制,那么每个存活平台线程都可能保留一份线程栈和线程相关 native 结构。
例如:
for (int i = 0; i < 10_000; i++) {
new Thread(() -> {
try {
Thread.sleep(Duration.ofDays(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "leaked-" + i).start();
}
这段程序的主要风险不是每个线程当前执行了多少 Java 代码,而是这些线程都长期处于存活状态。即使线程几乎不消耗 CPU,它们仍然需要栈和线程控制结构。
诊断时应同时观察:
jcmd <pid> Thread.print
jcmd <pid> VM.native_memory summary scale=MB
如果线程数、NMT 的 Thread 区域和进程 RSS 长期一起增长,线程泄漏的可能性较高。若线程数不再增长但 RSS 继续增长,则应继续检查 Direct Buffer、JNI、Metaspace、分配器缓存和文件映射。
3.3 虚拟线程不能简单乘以 -Xss
虚拟线程不是“一条虚拟线程对应一份固定 native stack”。它们由少量 carrier platform threads 承载,暂停时的执行状态由 JVM 管理,通常不会为每个虚拟线程保留一份与平台线程等大的固定 native 栈。
因此,下面的估算通常是错误的:
更合理的分析方式是区分:
- carrier platform thread 的数量及其 native stack;
- 虚拟线程的 Java 状态和 continuation 相关内存;
- 虚拟线程是否持有大对象、Direct Buffer、文件描述符或其他资源;
- 阻塞点是否导致 carrier 数量或任务堆积增加。
虚拟线程降低了“每个并发任务都需要一个平台线程栈”的成本,但不会自动消除对象、缓冲区、连接和 native 资源泄漏。
4. Metaspace:类元数据为什么在堆外
4.1 Metaspace 存储什么
Java 8 以后,HotSpot 将大部分类元数据从永久代迁移到 Metaspace。Metaspace 位于 native memory,不属于 Java heap。
类加载后,JVM 需要保存与该类相关的元数据,例如:
- 类层次结构;
- 字段和方法描述;
- 方法字节码及运行时元数据;
- 常量池相关结构;
- 注解、接口、访问控制等信息;
- 部分类加载器和运行时元数据。
但需要严格区分:
java.lang.Class对象本身是 Java 对象,位于堆中;- 类的许多元数据位于 Metaspace;
- 类的实例对象仍然位于堆中;
- JIT 生成的机器码主要位于 Code Cache,而不是 Metaspace。
Compressed Class Space 是与压缩类指针相关的特殊地址空间。它与 Metaspace 相关,但不是“所有 Metaspace 都在 Compressed Class Space 中”。因此,看到 Class 区域或类空间增长时,不能只看一个地址空间数字。
4.2 类卸载的必要条件
类卸载不是“类没有业务引用就立即删除”。通常需要满足更强的可达性条件:
- 对应的
ClassLoader不再可达; - 该类加载器加载的类对象不再可达;
- 相关实例、静态字段、线程上下文类加载器、反射缓存等不再形成引用链;
- 使用的垃圾收集器和 GC 周期执行了支持类卸载的处理。
可以把它简化为:
ClassLoader 可达
├── Class 对象可达
├── 静态字段可达
├── 线程上下文类加载器可达
└── 反射 / 代理 / 注册表引用可达
↓
类不能卸载
↓
Metaspace 持续保留
这解释了为什么动态部署、脚本引擎、插件系统和大量代理类容易出现 Metaspace 增长。旧版本的类加载器即使不再服务请求,只要仍被线程、缓存、监听器或全局注册表引用,加载的类就可能无法卸载。
4.3 Metaspace 泄漏的典型例子
下面的代码模拟反复创建类加载器,但通过静态集合保留类加载器:
public final class LoaderLeak {
private static final List<ClassLoader> LEAK = new ArrayList<>();
public static void main(String[] args) throws Exception {
while (true) {
ClassLoader loader = new URLClassLoader(
new URL[0],
LoaderLeak.class.getClassLoader());
LEAK.add(loader); // 全局集合永久保留 loader
Thread.sleep(100);
}
}
}
这里真正的泄漏根是 LEAK,而不是 Metaspace 自己“无法回收”。因果链是:
LEAK
→ ClassLoader
→ 该加载器定义的类
→ 类元数据留在 Metaspace
→ Metaspace committed 持续增加
生产环境中更隐蔽的根可能是:
- 线程的
contextClassLoader; - ThreadLocal 值;
- JDBC 驱动或日志框架注册表;
- 观察者和事件监听器;
- 定时任务;
- 字节码增强框架缓存;
- 由旧类加载器创建但未关闭的线程。
-XX:MaxMetaspaceSize 可以设置 Metaspace 上限。达到上限时,JVM 可能尝试触发 GC 和类卸载;如果类确实仍然可达,最终可能抛出:
java.lang.OutOfMemoryError: Metaspace
这个错误不表示 Java 堆一定已经耗尽。
5. Direct Buffer 和其他显式堆外内存
5.1 Direct Buffer 的生命周期
ByteBuffer.allocateDirect 创建的是一个堆上的 Java 包装对象和一个堆外数据区:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
其生命周期可以抽象为:
创建 Java 包装对象
↓
分配 native 数据区
↓
业务持有 ByteBuffer 引用
↓
引用断开
↓
GC 发现包装对象不可达
↓
清理关联的 native 数据区
最后两步不是确定的实时操作。GC 何时发现对象、清理动作何时执行、底层分配器何时归还页面,都可能不同。
下面这个示例会持续保留 Direct Buffer,便于观察堆外增长:
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectMemoryDemo {
public static void main(String[] args) throws Exception {
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
buffers.add(ByteBuffer.allocateDirect(8 * 1024 * 1024));
System.out.printf("buffers=%d%n", buffers.size());
Thread.sleep(1_000);
}
}
}
编译和运行:
javac -d out DirectMemoryDemo.java
java \
-Xms256m -Xmx256m \
-XX:MaxDirectMemorySize=512m \
-XX:NativeMemoryTracking=detail \
-cp out DirectMemoryDemo
然后在另一个终端执行:
jcmd <pid> VM.native_memory summary scale=MB
当 Direct Buffer 累计接近限制时,常见表现是:
java.lang.OutOfMemoryError: Cannot reserve ... bytes of direct buffer memory
这与 java.lang.OutOfMemoryError: Java heap space 是不同故障。前者说明受管理的 direct buffer 预留失败,后者说明堆分配失败。
需要注意,NIO Direct Buffer 的具体 NMT 分类属于 HotSpot 实现细节,不能仅凭某个版本的分类名称建立永久化监控规则。应结合:
MaxDirectMemorySize;- 应用中 Direct Buffer 的创建和释放逻辑;
- 进程 RSS;
- NMT 的 Other 等相关区域;
- JFR、堆转储和业务指标。
5.2 JNI 和其他 native API
JNI 代码可以直接执行:
void *p = malloc(size);
如果没有对应的 free(p),Java GC 不会因为 Java 引用消失而自动知道如何释放这块内存。即使 JNI 对象句柄被释放,本地指针也可能仍然泄漏。
类似风险还存在于:
- 压缩库和图像库;
- 数据库驱动;
- 网络库;
mmap映射;dlopen加载的动态库;- FFM API 调用的 arena 或 native segment;
- 本地内存分配器自己的缓存和碎片。
NMT 主要用于跟踪 HotSpot/JDK 能够插桩的 native memory。它不是任意 C/C++ 库的完整内存分析器。发现“RSS 增长而 NMT 没有对应增长”时,应优先怀疑第三方 native 分配、文件映射、共享库或分配器行为,而不是直接认为 NMT 失效。
6. Native Memory Tracking 的工作方式
6.1 NMT 能解决什么问题
Native Memory Tracking,简称 NMT,是 HotSpot 提供的 native memory 诊断机制。它记录 JVM 内部 native memory 的分配统计,并按组件汇总,例如:
- Thread;
- Class;
- Code;
- GC;
- Compiler;
- Internal;
- Symbol;
- Native Memory Tracking 自身;
- Other;
- Arena Chunk 等。
精确分类、字段和统计方式属于 HotSpot 实现,并非 JVMS 保证。Java 25 使用的具体 JDK 构建版本也可能影响输出细节。
NMT 最适合回答:
- 哪类 JVM 内部 native memory 在增长?
- 增长发生在 Thread、Class、Code 还是 GC 等区域?
- 两个时间点之间的增量是多少?
- 某次部署后 native memory 增长是否来自类加载或线程创建?
NMT 不适合单独回答:
- 哪个 Java 对象持有了某块 Direct Buffer?
- 哪一行 JNI C 代码忘记了
free? - 操作系统 RSS 中每个页面到底对应哪个 Java 对象?
- 分配器缓存中哪些块已经可复用?
6.2 启用 NMT
NMT 必须在 JVM 启动时配置:
java \
-XX:NativeMemoryTracking=summary \
-cp out com.example.Main
如果需要更细粒度的调用点信息:
java \
-XX:NativeMemoryTracking=detail \
-cp out com.example.Main
summary 开销较低,适合持续诊断;detail 会记录更多信息,运行开销和内存开销更高。生产环境是否启用应通过压测和目标服务的延迟预算验证,不能假定开销对所有应用都相同。
通常不能把一个已经启动且未启用 NMT 的 JVM 临时切换为完整 NMT 跟踪。需要重新启动进程,以便从启动早期开始记录。
6.3 常用命令和输出含义
查看进程:
jcmd -l
查看汇总:
jcmd <pid> VM.native_memory summary scale=MB
典型输出结构类似:
Native Memory Tracking:
Total: reserved=..., committed=...
- Java Heap (reserved=..., committed=...)
- Class (reserved=..., committed=...)
- Thread (reserved=..., committed=...)
- Code (reserved=..., committed=...)
- GC (reserved=..., committed=...)
- Compiler (reserved=..., committed=...)
- Internal (reserved=..., committed=...)
- Symbol (reserved=..., committed=...)
- Other (reserved=..., committed=...)
这里的关键不是某一行的绝对值,而是:
reserved是否只是大地址空间预留;committed是否持续增长;- 增长是否与线程数、类数量、GC 行为或请求流量相关;
- NMT 总量与进程 RSS 是否同方向变化。
建立基线:
jcmd <pid> VM.native_memory baseline
一段时间后查看增量:
jcmd <pid> VM.native_memory summary.diff scale=MB
需要更详细的差异时:
jcmd <pid> VM.native_memory detail.diff scale=MB
这些命令的逻辑是:
时刻 t0:记录 baseline
↓
应用继续运行
↓
时刻 t1:当前统计 - baseline
↓
观察各分类的 reserved / committed 增量
例如,若差异显示:
Thread committed 增长
同时线程数量从 300 增加到 2,000,则线程创建或线程池失控比 Java 堆泄漏更符合证据。
若显示:
Class committed 增长
同时堆中存在大量旧类加载器或动态生成类,则应检查类卸载条件。
若 RSS 增长数百 MiB,而 NMT 总 committed 几乎不变,则 NMT 不能解释这部分增长,应该转向 Direct Buffer、JNI、第三方库、mmap 和操作系统工具。
6.4 NMT 的边界
NMT 的统计不是进程完整内存账本,原因包括:
- 第三方 native 库可能没有接入 HotSpot 的跟踪点;
- 文件映射和共享库的驻留情况由操作系统决定;
- 分配器可能保留已经释放的页面;
- NMT 的分类属于实现细节;
- NMT 本身也需要占用一部分内存;
- NMT 的
committed不等于 RSS; - Java 堆对象、对象图和 native 分配之间没有天然的一一映射。
所以,正确的诊断结论应类似:
NMT 显示 Class committed 持续增长,且类加载器数量也增长;结合堆转储发现旧类加载器被全局注册表保留,因此怀疑类加载器泄漏。
而不应写成:
NMT 的 Class 增长,所以一定是 Metaspace 泄漏。
前者有多项证据,后者把相关性直接当成了因果关系。
7. 从进程 RSS 到具体泄漏点的诊断路径
Native memory 问题需要先确认“增长的到底是哪一层”。
flowchart TD
A[进程 RSS 或容器内存持续增长] --> B{Java 堆使用量是否同步增长}
B -->|是| C[分析堆对象、GC、缓存和引用链]
B -->|否| D[查看线程数、NMT 和进程映射]
D --> E{线程数是否同步增长}
E -->|是| F[检查线程创建、线程池和阻塞任务]
E -->|否| G{NMT 哪个分类增长}
G -->|Class| H[检查 Metaspace、动态类和 ClassLoader 卸载]
G -->|Code| I[检查 JIT、代码缓存和动态生成代码]
G -->|GC| J[检查收集器数据结构和堆配置]
G -->|Thread| F
G -->|Other 或无法解释| K[检查 Direct Buffer、JNI、FFM、mmap 和第三方库]
K --> L[结合 RSS、进程映射、JFR 和 native profiler]
第一步:确认是泄漏还是正常增长
连续采样,而不是只看一次:
jcmd <pid> VM.native_memory summary scale=MB
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print
同时记录:
- RSS;
- Java 堆 used / committed;
- 存活线程数;
- 已加载类数量;
- Direct Buffer 申请量;
- 请求量和连接数;
- 容器 memory.current 或等价指标。
如果内存增长后在负载下降时回落,可能是缓存、提交策略或分配器行为;如果在负载恢复到原水平后仍持续抬高,泄漏可能性更大。
严格地说,泄漏需要定义为:
在外部负载和系统状态基本稳定时,某类资源的可回收部分随时间单调积累,并且不能通过正常回收过程恢复。
“RSS 没下降”本身还不能证明泄漏。
第二步:区分堆泄漏和 native 泄漏
如果 Java 堆 used 持续接近上限,优先做堆转储或使用 JFR、堆分析工具查找:
- 大集合;
- 缓存;
- ThreadLocal;
- 消息积压;
- 监听器;
- 连接对象;
- 类加载器引用。
堆泄漏也会间接造成 native memory 问题。例如,大量 ByteBuffer 包装对象存活,会阻止其 Direct Buffer 释放;大量线程对象存活通常意味着对应线程本身也没有退出。
如果堆 used 稳定而 RSS 上升,则继续分析 native 组成,不能只增加 -Xmx。增大堆上限甚至可能减少可用 native memory,令容器更快触发 OOM。
第三步:检查线程
执行:
jcmd <pid> Thread.print > threads.txt
比较不同时间点:
grep -c 'java.lang.Thread.State' threads.txt
grep -E 'pool-|worker-|http-|scheduler-' threads.txt | sort | uniq -c
命令输出只能提供初步线索。真正需要确认的是线程生命周期:
- 是不是每个请求创建一个长期线程?
- 线程池是否无界扩张?
- 线程是否卡在锁、网络读、队列等待或外部进程?
- 应用关闭时是否停止 Executor?
- 是否有线程持有旧类加载器的
contextClassLoader?
线程数量稳定但 Thread 区域增长,可能还需要考虑线程栈提交、分配器行为或采样时点;不能仅靠一次快照断言泄漏。
第四步:检查 Metaspace 和类加载器
如果应用使用热部署、插件、脚本、代理或动态字节码生成,应记录:
- 已加载类数量;
- 卸载类数量;
- 类加载器实例数量;
- 每次部署后 Metaspace 的 committed;
- Full GC 或支持类卸载的 GC 周期后是否下降。
一个重要反例是:
Metaspace committed 上升
并不必然表示泄漏。应用可能正在合法地加载更多类,或者 Metaspace 已提交的内存暂时没有归还操作系统。应观察:
- 类数量是否仍增长;
- 旧类加载器是否能够被回收;
- GC 后是否释放可回收元数据;
- committed 增长是否最终稳定。
只有在旧类加载器持续存活、动态类持续累积等证据同时存在时,才更接近类加载器泄漏。
第五步:检查 NMT 无法解释的部分
当出现以下关系时:
RSS 持续增长
Java 堆稳定
线程数稳定
NMT 总量增长很小
应重点检查:
- Direct Buffer;
- JNI
malloc; - FFM 分配的内存段;
mmap文件;- 第三方库内部缓存;
- glibc 或其他分配器的 arena 和碎片;
- 共享内存及动态库映射。
Linux 上可以查看进程映射:
cat /proc/<pid>/smaps_rollup
cat /proc/<pid>/maps
还可以使用:
pmap -x <pid>
这些工具展示的是操作系统视角,适合确认匿名映射、文件映射和 RSS/PSS 分布,但通常不能直接告诉你某个映射对应哪一行 Java 代码。
对于 JNI 或第三方库,通常需要在测试环境启用 native profiler、分配器 profiling,或使用库自身的诊断接口。不要把 NMT 的“未分类”理解为“没有泄漏”。
8. JFR 和 JMC 在 native memory 诊断中的位置
JFR(Java Flight Recorder)记录 JVM 和应用运行事件,JMC(JDK Mission Control)用于查看和分析 JFR 记录。它们与 NMT 解决的问题不同:
- NMT:按 JVM native memory 组件查看聚合分配;
- JFR:观察线程、锁、类加载、GC、分配、IO 和应用行为的时间关系;
- JMC:以图形界面分析 JFR 记录中的事件和趋势。
可以先启动一个有限时长的 JFR 记录:
jcmd <pid> JFR.start \
name=memory-investigation \
settings=profile \
duration=10m \
filename=/tmp/memory-investigation.jfr
查看记录是否存在:
jcmd <pid> JFR.check
如果没有直接指定 duration,也可以手动停止:
jcmd <pid> JFR.stop \
name=memory-investigation \
filename=/tmp/memory-investigation.jfr
然后使用 JMC 打开 .jfr 文件,重点查看:
- 线程创建和线程状态;
- 类加载及类卸载;
- GC 暂停和堆分配;
- 长时间阻塞;
- 应用事件与内存增长的时间关系。
JFR 的事件和可见字段受 JDK 版本、配置文件和启用事件影响。不能把 JFR 当作任意 native malloc 的逐调用追踪器,也不能因为 JMC 中没有直接显示某块 native 内存就断言它不存在。
一个可靠的组合通常是:
RSS / cgroup 指标
+
NMT 组件增量
+
JFR 时间线
+
堆转储或类加载器分析
+
操作系统映射 / native profiler
这些工具分别提供不同观察面,只有把时间、数量和引用关系拼起来,才可能建立因果链。
9. 常见错误理解
9.1 “堆没满,所以进程不会 OOM”
错误。容器限制的是进程或 cgroup 的总内存,不是 Java heap used。线程栈、Metaspace、Direct Buffer、JIT、JNI 和共享库都可能消耗预算。
9.2 “Metaspace 在堆外,所以 GC 管不到”
不准确。Metaspace 不属于普通 Java 堆,但类卸载需要 GC 协作判断类加载器和类是否可达。GC 可能触发类卸载和相关元数据回收,只是这不是普通对象复制或标记清除的同一块堆空间。
9.3 “设置 -Xss 为 1 MiB,就会立刻消耗线程数乘以 1 MiB 的物理内存”
错误。通常首先涉及地址空间保留,页面按需提交和驻留;但栈保护页、已触碰页面、native 调用以及线程控制结构仍会消耗真实资源。
9.4 “调用 System.gc() 就能释放所有堆外内存”
错误。它既不是规范保证的即时 Full GC,也不能释放不受 GC 管理的 JNI 分配、文件映射或第三方库缓存。即使 Direct Buffer 已经满足回收条件,也还存在清理时机和分配器归还策略。
9.5 “NMT 总量等于 RSS”
错误。NMT 是 JVM 视角的统计,RSS 是操作系统视角的驻留页面统计,二者的范围、时间点和计量方式不同。
9.6 “把 MaxDirectMemorySize 调大就能解决 native memory 不足”
这可能只是把失败时间推迟。该参数主要影响 HotSpot 管理的 direct buffer 限制,不能替代进程总内存预算,也不能控制任意 JNI 和第三方库分配。
10. 生产环境中的取舍与恢复
NMT 应在启动参数中启用,并在压测环境测量其开销。summary 适合较低成本地观察组件级趋势;detail 适合问题复现和定位,但不应未经验证就永久用于所有延迟敏感服务。
发生 native memory 压力时,恢复动作应先保证进程不会继续失控:
- 限制或停止造成增长的流量、部署任务或批处理;
- 降低线程创建速率,停止失控的线程池或任务提交者;
- 停止继续加载插件、脚本或动态生成类;
- 对 Direct Buffer、JNI 和 native 库释放路径进行隔离验证;
- 在允许的情况下滚动重启泄漏进程,释放整个进程的 native memory;
- 保留 NMT、JFR、线程快照、堆转储和操作系统映射,避免只重启而丢失证据。
重启是恢复手段,不是根因修复。若问题来自类加载器泄漏,下一次热部署仍会复现;若问题来自线程泄漏,重启后线程数会暂时归零,但错误的创建路径仍然存在;若问题来自 JNI,只有修复 native 生命周期或升级相关库才能真正解决。
11. 一套可复用的判断模型
可以把 JVM native memory 问题归纳为三个问题:
问题一:增长的是地址空间、提交内存,还是物理驻留
先区分:
reserved 增长
committed 增长
RSS 增长
只看到 reserved 增长,不能直接推出物理内存压力;只看到 RSS 增长,也不能直接推出 Java 对象泄漏。
问题二:增长是否具有稳定因果变量
寻找与增长同步的变量:
线程数 → Thread / 栈
类加载器数 → Class / Metaspace
Direct Buffer → 堆外缓冲区
堆对象存活数 → Java heap / 间接阻止 native 清理
请求或连接数 → 缓存、缓冲区、JNI 资源
如果没有可重复的相关变量,应该先延长采样窗口并控制负载,而不是立即调整 JVM 参数。
问题三:资源是否有明确的所有权和释放路径
每一种 native memory 都需要回答:
谁创建?
谁持有?
何时变得不可达或可关闭?
谁执行释放?
释放后是否归还操作系统?
Java 堆对象可以依赖 GC,但线程、文件描述符、native segment、JNI 指针和第三方库对象通常需要显式生命周期管理。只有把所有权和释放路径写清楚,才能区分“暂时未归还”“分配器缓存”“合法保留”和真正的泄漏。
JVM native memory 的核心不是“堆外内存很危险”,而是 JVM 进程同时受多个内存管理系统约束:Java GC 管理对象,类卸载管理元数据,线程系统管理栈,JVM 管理部分 native 区域,C/C++ 分配器管理本地块,操作系统管理页面和映射。诊断时必须先确定正在观察哪一层,再用对应工具建立从增长现象到资源所有权的因果链。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JVM ZGC 深入:并发回收、染色指针、分代模式和容量规划
- 下一篇:JVM ClassLoader 泄漏:线程、缓存、Driver、热部署和诊断
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论