本节目标:把 libraryd 进程的 RSS 拆成堆、线程栈、元空间、代码缓存和直接内存,并用 NMT 的 reserved/committed/used 三列解释每一笔账。
适用版本:JDK 25(Temurin 25.0.5);jcmd25;-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 提交、可读写的内存,通常计入 RSS | NMT、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 值,只改线程栈,其他不变:
-Xss | Thread reserved | stack reserved | stack committed |
|---|---|---|---|
2m | 463620 KB | 462848 KB | 16512 KB |
1m | 249604 KB | 248832 KB | 16512 KB |
256k | 89092 KB | 88320 KB | 16512 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 堆 | -Xmx2g | 2048 MB | jcmd GC.heap_info |
| 元空间 | -XX:MaxMetaspaceSize=256m | 256 MB(committed 上限) | jcmd VM.metaspace |
| 直接内存 | -XX:MaxDirectMemorySize=256m | 256 MB | NMT Other |
| 线程栈 | -Xss1m × 200 平台线程 | 200 MB(reserved) | NMT Thread |
| 代码缓存 | -XX:ReservedCodeCacheSize=240m | 240 MB | jcmd Compiler.codecache |
| GC 与 VM 内部 | 默认 | 约 150 MB | NMT 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 对象布局:对象头、压缩指针与字段重排 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。