堆明明只给了 256M,容器里看进程却占了 550 多兆,这种事排查时最常见。多出来的部分在堆外,得用 NMT(Native Memory Tracking)去看,MemoryMXBean 里的 non-heap 根本不包含直接内存和线程栈。下面拿一个 Spring Boot 4.1.1 小应用在 JDK 27 上实际跑一遍,把每一兆落到哪儿算清楚。

先搭个能“漏”内存的应用

项目就是一个 spring-boot-starter-webmvc,端口 18085。控制器里放三个接口:/direct 每次分配若干块 4MB 的 ByteBuffer.allocateDirect,/threads 起一批一直 await 的平台线程,/memory 把 JVM 自己通过 MXBean 能看到的数字和 /proc/self/status 里的 VmRSS 一起吐出来。

@PostMapping("/direct")
public Map<String, Object> direct(@RequestParam(defaultValue = "64") int blocks) {
    for (int i = 0; i < blocks; i++) {
        buffers.add(ByteBuffer.allocateDirect(4 * 1024 * 1024));
    }
    return Map.of("directBuffers", buffers.size(), "directMB", buffers.size() * 4);
}

@PostMapping("/threads")
public Map<String, Object> threads(@RequestParam(defaultValue = "200") int count) {
    for (int i = 0; i < count; i++) {
        Thread.ofPlatform().name("parked-" + threadCount++).daemon().start(() -> {
            try {
                never.await();
            } catch (InterruptedException ignored) {
            }
        });
    }
    return Map.of("parkedThreads", threadCount);
}

@GetMapping("/memory")
public Map<String, Object> memory() throws Exception {
    Map<String, Object> out = new LinkedHashMap<>();
    var mem = ManagementFactory.getMemoryMXBean();
    out.put("heapCommittedMB", mem.getHeapMemoryUsage().getCommitted() >> 20);
    out.put("nonHeapCommittedMB", mem.getNonHeapMemoryUsage().getCommitted() >> 20);
    Map<String, Long> pools = new LinkedHashMap<>();
    for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
        if (p.getType() == MemoryType.NON_HEAP) {
            pools.put(p.getName(), p.getUsage().getCommitted() >> 20);
        }
    }
    out.put("nonHeapPoolsMB", pools);
    for (BufferPoolMXBean b : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
        if (b.getName().equals("direct")) {
            out.put("directBufferMB", b.getMemoryUsed() >> 20);
        }
    }
    out.put("threads", ManagementFactory.getThreadMXBean().getThreadCount());
    String rss = Files.readAllLines(Path.of("/proc/self/status")).stream()
            .filter(l -> l.startsWith("VmRSS")).findFirst().orElse("");
    out.put("rss", rss.replaceAll("\\s+", " "));
    return out;
}

启动时堆固定成 256M,再打开 NMT。NMT 有 summary 和 detail 两档。summary 只按分类汇总,detail 还会记下每次分配的调用栈,平时先开 summary。

$ java -version
openjdk version "27" 2026-09-15
OpenJDK Runtime Environment (build 27+35-2325)
OpenJDK 64-Bit Server VM (build 27+35-2325, mixed mode, sharing)

$ java -Xms256m -Xmx256m -XX:NativeMemoryTracking=summary -jar target/nmt-demo-0.0.1.jar
2026-10-17T08:08:31.937+08:00  INFO 1118039 --- [           main] com.spring4all.nmt.DemoApplication       : Started DemoApplication in 1.288 seconds (process running for 1.688)

刚启动时的账

先看应用自己报的数:

$ curl -s localhost:18085/memory
{"heapCommittedMB":256,"nonHeapCommittedMB":47,"nonHeapPoolsMB":{"CodeHeap 'non-nmethods'":2,"Metaspace":30,"CodeHeap 'profiled nmethods'":6,"Compressed Class Space":6,"CodeHeap 'non-profiled nmethods'":2},"directBufferMB":0,"threads":23,"rss":"VmRSS: 274896 kB"}

堆 256 加 non-heap 47,303M,跟 RSS 的 268M 左右对不上不说,方向还反了。原因是堆虽然 committed 了 256M,没碰过的页不一定真占物理内存。再用 jcmd 看 NMT:

$ jcmd 1118039 VM.native_memory summary scale=MB
1118039:

Native Memory Tracking:

(Omitting categories weighting less than 1MB)

Total: reserved=1748MB, committed=414MB
       malloc: 64MB #141548, peak=99MB #93637
       mmap:   reserved=1684MB, committed=350MB

-                 Java Heap (reserved=256MB, committed=256MB)
                            (mmap: reserved=256MB, committed=256MB, at peak)

-                     Class (reserved=1025MB, committed=7MB)
...
-                    Thread (reserved=45MB, committed=1MB)
                            (threads #45)
                            (stack: reserved=45MB, committed=1MB, peak=1MB)

-                      Code (reserved=246MB, committed=16MB)
...
-                        GC (reserved=54MB, committed=54MB)
                            (malloc=17MB tag=GC #4869) (peak=17MB #5674)
                            (mmap: reserved=38MB, committed=38MB, at peak)

-                  Compiler (reserved=0MB, committed=0MB)
                            (arena=0MB #8) (peak=68MB #34)
...
-               Arena Chunk (reserved=31MB, committed=31MB)
                            (malloc=31MB tag=Arena Chunk #1129) (peak=75MB #1205)

-                 Metaspace (reserved=64MB, committed=24MB)
                            (mmap: reserved=64MB, committed=24MB, at peak)

中间省略了几段,完整输出更长。这里能看到 MXBean 完全没提的几块:GC 自己的数据结构占了 54M,Arena Chunk 31M(主要是 JIT 编译时用的临时内存,peak 到过 75M),还有 Symbol、Shared class space 这些。committed 合计 414M,这才是 JVM 向操作系统要过的量。

另外注意线程数。MXBean 说 23 个线程,NMT 说 45 个。MXBean 只数 Java 线程,NMT 把 GC 线程、JIT 编译线程这些 JVM 内部线程也算进去了。

打个基线,再制造点麻烦

NMT 最好用的是 baseline 加 summary.diff,先记一笔,过一会儿只看变化。

$ jcmd 1118039 VM.native_memory baseline
1118039:
Baseline taken

$ curl -s -X POST 'localhost:18085/direct?blocks=64'
{"timestamp":"2026-10-17T00:08:33.510Z","status":500,"error":"Internal Server Error","path":"/direct"}
$ curl -s -X POST 'localhost:18085/threads?count=200'
{"parkedThreads":200}

/direct 直接 500 了。这个本来不在计划里,但很有代表性。看应用日志:

java.lang.OutOfMemoryError: Cannot reserve 4194304 bytes of direct buffer memory (allocated: 264251392, limit: 268435456)

没有显式设 -XX:MaxDirectMemorySize 的时候,JDK 把直接内存上限设成 Runtime.getRuntime().maxMemory(),基本就是最大堆。日志里的 limit 是 268435456 字节,正好 256M。前 63 块已经占了 264251392 字节,第 64 块再要 4M 就超了。所以堆和直接内存各有一份 256M 的额度,加起来轻松翻倍。用 Netty 这类大量吃直接内存的框架时,容器内存如果只按 -Xmx 留余量,很容易被 OOMKilled。

第二次对账

$ curl -s localhost:18085/memory
{"heapCommittedMB":256,"nonHeapCommittedMB":48,"nonHeapPoolsMB":{"CodeHeap 'non-nmethods'":2,"Metaspace":31,"CodeHeap 'profiled nmethods'":6,"Compressed Class Space":6,"CodeHeap 'non-profiled nmethods'":2},"directBufferMB":252,"threads":224,"rss":"VmRSS: 565276 kB"}

RSS 从 274896 kB 涨到 565276 kB,多了两百八十多兆。heap 没动,nonHeapCommittedMB 只从 47 变成 48。也就是说,只盯着 MemoryMXBean 的 heap 和 non-heap,这次暴涨你一点都看不出来。直接内存得另外去 BufferPoolMXBean 里找,这里是 252。

NMT 的 diff 把变化直接列了出来:

$ jcmd 1118039 VM.native_memory summary.diff scale=MB
1118039:

Native Memory Tracking:

(Omitting categories weighting less than 1MB)

Total: reserved=2200MB +452MB, committed=670MB +256MB

malloc=316MB +253MB #144314 +2746

mmap: reserved=1883MB +199MB, committed=354MB +4MB
...
-                    Thread (reserved=245MB +200MB, committed=5MB +4MB)
                            (threads #244 +199)
                            (stack: reserved=244MB +199MB, committed=5MB +3MB)
                            (malloc=1MB #1479 +1209)
...
-                     Other (reserved=252MB +252MB, committed=252MB +252MB)
                            (malloc=252MB +252MB #73 +69)

直接内存在 NMT 里记在 Other 下面,是 malloc 出来的,+252MB,跟 BufferPoolMXBean 对得上。

再看线程。199 个新线程,reserved 涨了 200MB,committed 只涨了 4MB。每个平台线程默认预留 1M 栈,但只有真正用到的页才会提交。这些线程一直在 await,栈很浅,所以实际物理内存不多。很多人按“一个线程 1M”去估算内存,其实高估了不少。不过线程数上去之后 reserved 地址空间会膨胀,在限制虚拟内存的环境里一样会出事。

放到线上怎么用

线上要不要常驻 -XX:NativeMemoryTracking=summary,先掂量一下开销。Oracle 的排障文档写的是,开 NMT 会让 JVM 性能下降 5% 到 10%,每块 malloc 内存还要多挂两个机器字的头。这次 NMT 自己记账用了 2 到 3M。能接受的话,出问题时直接 jcmd 就行,不用重启复现。detail 模式要记调用栈,留到定位具体泄漏点时再开。

容器里配内存上限时,别只按 -Xmx 算。至少要把直接内存、线程栈、Metaspace、CodeCache、GC 结构都加上。直接内存显式设上 -XX:MaxDirectMemorySize,不设它就悄悄跟着 -Xmx 走。这几个参数的写法,JVM 配置参数大全 里都列过。

还有一点:NMT 只管 JVM 自己分配的内存。JNI 库自己 malloc 的、glibc 的 arena 碎片,NMT 看不见。如果 NMT 的 committed 和 RSS 差得很远,问题就多半在 JVM 外面了,那是另一种排查路子。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。