《Java 虚拟机原理与性能调优》3.1 运行时数据区:堆、栈、元空间与代码缓存

线上 RSS 比 -Xmx 多出 1 GB,钱花在哪?本节用 MemoryShape 复现 libraryd 的线程与内存形状,跑 -XX:NativeMemoryTracking=summary 与 jcmd VM.native_memory、VM.metaspace、GC.heap_info,逐项解释 reserved/committed/used,并给出堆、元空间、直接内存与线程栈的预算表。

本节目标:把 libraryd 进程的 RSS 拆成堆、线程栈、元空间、代码缓存和直接内存,并用 NMT 的 reserved/committed/used 三列解释每一笔账。
适用版本:JDK 25(Temurin 25.0.5);jcmd 25;-XX:NativeMemoryTracking=summary 必须在启动时加。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB,G1 Region 4 MB。

承接的问题

libraryd 压测结束后,运维看到容器 RSS 是 2.1 GB,而启动参数写的是 -Xmx1g。第一反应通常是「堆配错了」或者「有泄漏」,但 jcmd GC.heap_info 显示堆只用了 259 MB。多出来的部分不在堆里:线程栈、元空间、JIT 代码缓存、直接内存各自占了一块,只是 Runtime.totalMemory() 看不见它们。

这一节不靠猜。我们用一个与 libraryd 同构的进程 MemoryShape 复现内存形状:200 个平台线程、200 MB 长寿对象、4 个 32 MB 直接缓冲区。它的每个数字都能用 jcmd 重新跑出来。真实 libraryd 的采集命令完全相同,只是线程数和对象图不同。

先把「内存」这个词拆成三个口径,后面所有输出才有意义:

口径含义谁在看
reserved进程向 OS 预留的虚拟地址空间,不等于物理内存NMT、VM.metaspace
committed已向 OS 提交、可读写的内存,通常计入 RSSNMT、GC.heap_info
used其中真正被对象或元数据使用的部分GC.heap_info、MemoryPoolMXBean

-Xmx 约束的是 Java 堆的 committed 上限;它不约束线程栈、元空间、代码缓存和直接内存。这正是 RSS 超出的来源。

用 NMT 把 RSS 拆开

NMT(Native Memory Tracking)是 HotSpot 自带的分类记账器。摘要模式的开销很小,生产环境也可以常开:

java -XX:NativeMemoryTracking=summary \
     -Xmx1g -Xss1m \
     -XX:MaxMetaspaceSize=256m \
     -XX:MaxDirectMemorySize=256m \
     -cp out lab.MemoryShape

进程启动后,用 PID 查询:

jcmd <pid> VM.native_memory summary

本机输出(节选,保留了所有大于 1 MB 的类别):

Native Memory Tracking:

(Omitting categories weighting less than 1KB)

Total: reserved=2058552KB, committed=763192KB
       malloc: 154808KB #18111, peak=154489KB #18113
       mmap:   reserved=1903744KB, committed=608384KB

-                 Java Heap (reserved=1056768KB, committed=526336KB)
                            (mmap: reserved=1056768KB, committed=526336KB, at peak)

-                     Class (reserved=213128KB, committed=264KB)
                            (classes #845)
                            (  instance classes #708, array classes #137)
                            (  Metadata:   )
                            (    reserved=65536KB, committed=448KB)
                            (    used=396KB)
                            (  Class space:)
                            (    reserved=212992KB, committed=128KB)
                            (    used=30KB)

-                    Thread (reserved=249604KB, committed=17284KB)
                            (threads #226)
                            (stack: reserved=248832KB, committed=16512KB, peak=16512KB)

-                      Code (reserved=249735KB, committed=7719KB)
                            (malloc=86KB tag=Code #1880) (at peak)
                            (mmap: reserved=249648KB, committed=7632KB, at peak)

-                        GC (reserved=72612KB, committed=62436KB)

-                     Other (reserved=131072KB, committed=131072KB)
                            (malloc=131072KB tag=Other #4) (at peak)

逐项读:

  • Total 的 reserved 约 2.0 GB,committed 约 745 MB。reserved 大不代表占用大,它只是地址空间。
  • Java Heap reserved 1056768 KB = 1032 MB,比 -Xmx1g 多 8 MB。G1 会按 Region 对齐堆边界,GC.heap_info 里写的是 total reserved 1048576K,NMT 把额外的对齐也算了进来。committed 526336 KB 才是真实提交的堆。
  • Class 是元空间加压缩类空间。classes #845 说明这个进程只加载了 845 个类,Metadata used=396KB,元空间本身几乎不占内存;但 Class space reserved=212992KB 预留了 208 MB 地址空间。
  • Thread 是 226 个线程的栈。stack reserved=248832KB,committed 只有 16512KB。预留大、提交小,因为线程栈按页提交,线程没跑深递归就不会碰满。
  • Code 是 JIT 代码缓存,reserved 249735 KB 约 244 MB,committed 7.7 MB。代码缓存按 240 MB 预留,但只编译了几百个方法。
  • GC 是 G1 的卡表、标记位图、任务队列,committed 62 MB,与堆大小和 Region 数相关。
  • Other 的 131072 KB 正好等于 4 × 32 MB:ByteBuffer.allocateDirect 分配的直接内存被归到 Other,tag=Other #4 是 4 次 malloc。

VM.info 可以确认这些行为不是猜的:

jcmd <pid> VM.info
Java VM: OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (25.0.5+8, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64)
Host: "MacBookPro18,1" arm64, 10 cores, 32G, Darwin 25.3.0, macOS 26.3 (25D125)
Heap address: 0x0000000300800000, size: 1024 MB, Compressed Oops mode: Zero based, Oop shift amount: 3

tiered, compressed oops, compressed class ptrs, g1 gc 就是本书后续章节默认的运行时组合。

堆自己的账目用 GC.heap_info 看,它和 NMT 的 Java Heap 是同一块内存的两个视角:

jcmd <pid> GC.heap_info
garbage-first heap   total reserved 1048576K, committed 526336K, used 259101K [0x0000000300800000, 0x0000000340800000)
 region size 1024K, 76 young (77824K), 24 survivors (24576K)

used 259101K 里包含 200 MB 的长寿 byte[] 和启动时的临时对象。region size 1024K 不是固定的 4 MB:G1 的 Region 大小按堆容量推导,-Xmx1g 时是 1 MB,本机默认 8 GB 堆时才是 4 MB。这个数字在 3.3 讲 Humongous 时会再次出现,因为大对象阈值是 Region 的一半。

线程栈:预留与提交分离

把同一个程序换三个 -Xss 值,只改线程栈,其他不变:

-XssThread reservedstack reservedstack committed
2m463620 KB462848 KB16512 KB
1m249604 KB248832 KB16512 KB
256k89092 KB88320 KB16512 KB

预留随 -Xss 近似线性变化,提交始终是 16512 KB,因为线程都阻塞在同一个 CountDownLatch 上,只触碰了栈顶几页。200 个线程用 2m 会预留约 452 MB 地址空间;这不会立刻变成 RSS,但在 32 位地址空间或受限容器里会直接导致创建线程失败。

元空间与代码缓存

堆之外最容易被忽略的是元空间。jcmd VM.metaspace 给出的不是估算值,而是 chunk 级的真实账目:

jcmd <pid> VM.metaspace
Metaspace        used 426K, committed 576K, reserved 278528K
 class space     used 30K, committed 128K, reserved 212992K

Total Usage - 9 loaders, 845 classes (796 shared):
  Non-Class:   11 chunks,      4.02 MB capacity,  407.00 KB ( 10%) committed,   396.27 KB ( 10%) used
      Class:    9 chunks,    265.00 KB capacity,   73.00 KB ( 28%) committed,    30.45 KB ( 11%) used
       Both:   20 chunks,      4.28 MB capacity,  480.00 KB ( 11%) committed,   426.73 KB ( 10%) used

三个数字要分开看:used 是类元数据真正占的字节,committed 是已向 OS 要来的 chunk,reserved 是地址空间。启动时参数是 -XX:MaxMetaspaceSize=256m,但 reserved 显示 272 MB:VM.info 里的 CompressedClassSpaceSize=208.00 MB 加上非类空间预留的 64 MB,正好是 272 MB。MaxMetaspaceSize 限制的是 committed 总量,不是这段地址空间的划分方式。

9 loaders, 845 classes 是另一个信号。libraryd 的插件加载器每加载一个插件就多一个 loader,如果反复热部署而不卸载旧 loader,used 会持续上涨。3.1 只建立基线,第 7.3 节再用 Byte Buddy 生成类把这条曲线拉起来。

代码缓存用 jcmd Compiler.codecache 看:

jcmd <pid> Compiler.codecache
CodeHeap 'non-profiled nmethods': size=120032Kb used=32Kb max_used=32Kb free=119999Kb
CodeHeap 'profiled nmethods': size=120032Kb used=122Kb max_used=122Kb free=119909Kb
CodeHeap 'non-nmethods': size=5712Kb used=1190Kb max_used=1205Kb free=4521Kb
CodeCache: size=245776Kb, used=1344Kb, max_used=1359Kb, free=244429Kb
 total_blobs=535, nmethods=157, adapters=287, full_count=0

ReservedCodeCacheSize 在本机默认是 251674624 字节(240 MB),被分成两个 120 MB 的 nmethod 堆和一个约 5.7 MB 的 non-nmethods 堆:C1 生成的 profiled 代码放一个堆,C2 生成的 non-profiled 代码放另一个,适配器和运行时桩放第三个。full_count=0 表示代码缓存从未满过;一旦满了,JVM 会停止编译,表现为「运行越久越慢」。

在 libraryd 里落地

RuntimeProbe 是第 1 章建立的观测入口,它只能看到 used/committed,看不到 reserved:

package com.plume.library.app;

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.RuntimeMXBean;
import java.lang.management.ThreadMXBean;

record RuntimeSnapshot(long heapUsed, long heapCommitted, long heapMax,
                       long nonHeapUsed, long metaspaceUsed,
                       long gcCount, long gcTimeMillis,
                       int threadCount, int peakThreadCount,
                       long uptimeMillis) {}

public final class RuntimeProbe {

    public RuntimeSnapshot snapshot() {
        MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
        MemoryUsage heap = memory.getHeapMemoryUsage();
        MemoryUsage nonHeap = memory.getNonHeapMemoryUsage();

        long metaspaceUsed = 0;
        for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
            if (pool.getName().contains("Metaspace")) {
                metaspaceUsed += pool.getUsage().getUsed();
            }
        }

        long gcCount = 0;
        long gcTime = 0;
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            gcCount += gc.getCollectionCount();
            gcTime += gc.getCollectionTime();
        }

        ThreadMXBean threads = ManagementFactory.getThreadMXBean();
        RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();

        return new RuntimeSnapshot(
                heap.getUsed(), heap.getCommitted(), heap.getMax(),
                nonHeap.getUsed(), metaspaceUsed,
                gcCount, gcTime,
                threads.getThreadCount(), threads.getPeakThreadCount(),
                runtime.getUptime());
    }
}

它适合放进 /actuator 或压测报告,不适合回答「RSS 为什么是 2.1 GB」——那个问题要靠 NMT。两者配合的方式是:RuntimeProbe 持续记录趋势,怀疑堆外增长时再对进程执行一次 jcmd VM.native_memory summary。

下面这张预算是 libraryd 部署到 4 GB 容器时的设计值,按 reserved 上限相加,不是某次实测:

项目启动参数预算观测命令
Java 堆-Xmx2g2048 MBjcmd GC.heap_info
元空间-XX:MaxMetaspaceSize=256m256 MB(committed 上限)jcmd VM.metaspace
直接内存-XX:MaxDirectMemorySize=256m256 MBNMT Other
线程栈-Xss1m × 200 平台线程200 MB(reserved)NMT Thread
代码缓存-XX:ReservedCodeCacheSize=240m240 MBjcmd Compiler.codecache
GC 与 VM 内部默认约 150 MBNMT GC/Internal
合计约 3.1 GB留 0.9 GB 给 OS 与堆外波动

预算表的意义不是精确,而是让每一项都有参数和命令对应。任何一项没有参数约束,就会随负载漂移。

常见错误与边界

「NMT 没开,运行时再开。」 直接执行 jcmd <pid> VM.native_memory summary 只会得到:

Native memory tracking is not enabled

NMT 必须在启动参数里打开,不能对已经运行的进程补开。摘要模式开销约 1% 到 2%,可以常驻;detail 模式记录每个调用点,开销更大,只适合短时间排查。

「把 reserved 当成 RSS。」 NMT 的 Thread reserved=248832KB 不意味着进程占了 243 MB 物理内存;同一份输出里 stack committed=16512KB 才是提交量。看容器 OOM 要看 RSS 或 container_memory_working_set_bytes,不是 reserved。反过来,reserved 过大仍可能失败:地址空间受限时,线程栈预留会先撞墙。

「直接内存受 -Xmx 保护。」 直接内存不受堆上限约束,但受 -XX:MaxDirectMemorySize 约束。把上限设成 128 MB、循环分配 32 MB 缓冲区,会在第 5 次得到:

java.lang.OutOfMemoryError: Cannot reserve 33554432 bytes of direct buffer memory (allocated: 134217728, limit: 134217728)
	at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
	at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:108)
	at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:367)

这条消息同时给出了已分配量和上限,比「堆外泄漏」的猜测有用得多。

小结

  • -Xmx 只约束 Java 堆;RSS 超出堆的部分来自线程栈、元空间、代码缓存和直接内存。
  • NMT 的 reserved 是地址空间,committed 是已提交内存,used 是真正使用的部分,三者不能混用。
  • 线程栈的 committed 只与触碰的页有关,-Xss 主要改变 reserved;200 个线程用 2m 会预留约 452 MB。
  • 元空间的 MaxMetaspaceSize 限制 committed;CompressedClassSpaceSize 决定类空间预留,二者不是同一个数。
  • 代码缓存默认 240 MB,分 profiled/non-profiled/non-nmethods 三个堆;full_count 大于 0 就要怀疑编译被截断。
  • 直接内存记在 NMT 的 Other 类别,超限时报 Cannot reserve ... direct buffer memory,与堆 OOM 是两回事。

下一篇把镜头拉到单个对象:一个 Book 为什么是 32 字节,Loan 为什么是 48 字节,字段顺序和对齐各自贡献了多少。

阅读导航:上一节:2.3 字节码执行模型:栈帧、局部变量表与操作数栈 · 下一节:3.2 对象布局:对象头、压缩指针与字段重排 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发