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,也没有规定其保留地址空间和实际提交内存的精确大小。

因此,需要区分三个概念:

  1. 规范保证:Java 虚拟机必须提供符合执行语义的运行时数据区。
  2. 实现事实:HotSpot 通常使用操作系统线程和 native stack 实现平台线程的执行栈。
  3. 工程观察:可以用 NMT、jcmd、操作系统工具和 JFR 观察部分内存行为,但这些工具不会自动覆盖所有第三方 native 分配。

1.2 Reserved、Committed 和 RSS

内存诊断中最容易误判的地方是把“地址空间保留”当成“已经占用物理内存”。

设:

  • R:reserved,保留的虚拟地址空间;
  • C:committed,JVM 已提交、可使用的地址空间;
  • P:resident,当前驻留在物理内存中的页面,也就是 RSS 的重要组成部分。

通常有:

0PCR0 \leq P \leq C \leq R

但这是理解 JVM 内部区域时的近似关系,不是对整个进程所有映射都严格成立的账本。文件映射、共享页、内核回收、透明大页和分配器行为都会使操作系统统计与 JVM 统计存在差异。

例如:

某线程栈:
reserved  = 1 MiB
committed = 64 KiB
resident  = 16 KiB

这表示 JVM 或操作系统为该线程预留了较大的栈地址空间,但当前只有一小部分页面实际提交或驻留。线程递归加深、调用更多本地函数或触碰更多栈页时,committed 和 RSS 可能增加。

反过来,某块 native 内存释放后,C 运行时分配器可能暂时保留这些页面,不立即归还操作系统。此时应用层已经释放,但进程 RSS 不一定马上下降。这属于分配器缓存或碎片,不一定是 Java 对象泄漏。

一个粗略的进程内存模型可以写成:

RSSHr+Mr+Sr+Cr+Gr+Dr+Nr+FrRSS \approx H_r + M_r + S_r + C_r + G_r + D_r + N_r + F_r

其中:

  • 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 个平台线程,可以先用以下模型估算栈地址空间:

SreservedT×XS_{\text{reserved}} \approx T \times X

其中:

  • 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 栈。

因此,下面的估算通常是错误的:

虚拟线程数量×Xss\text{虚拟线程数量} \times Xss

更合理的分析方式是区分:

  • 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 类卸载的必要条件

类卸载不是“类没有业务引用就立即删除”。通常需要满足更强的可达性条件:

  1. 对应的 ClassLoader 不再可达;
  2. 该类加载器加载的类对象不再可达;
  3. 相关实例、静态字段、线程上下文类加载器、反射缓存等不再形成引用链;
  4. 使用的垃圾收集器和 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 的统计不是进程完整内存账本,原因包括:

  1. 第三方 native 库可能没有接入 HotSpot 的跟踪点;
  2. 文件映射和共享库的驻留情况由操作系统决定;
  3. 分配器可能保留已经释放的页面;
  4. NMT 的分类属于实现细节;
  5. NMT 本身也需要占用一部分内存;
  6. NMT 的 committed 不等于 RSS;
  7. 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 压力时,恢复动作应先保证进程不会继续失控:

  1. 限制或停止造成增长的流量、部署任务或批处理;
  2. 降低线程创建速率,停止失控的线程池或任务提交者;
  3. 停止继续加载插件、脚本或动态生成类;
  4. 对 Direct Buffer、JNI 和 native 库释放路径进行隔离验证;
  5. 在允许的情况下滚动重启泄漏进程,释放整个进程的 native memory;
  6. 保留 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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。