运行时数据区与对象布局
Java 进程的内存由多种用途共同组成。对象和数组占用 Java heap,类元数据、编译代码、平台线程栈及直接缓冲区又占用其他空间。操作系统统计驻留页,容器按 cgroup 规则计费,各自回答不同的问题。
Java 进程
├─ Java heap:对象、数组、部分运行时对象
├─ Metaspace / Compressed Class Space:类元数据
├─ Code Cache:JIT 代码与 VM 代码
├─ 平台线程栈、GC、编译器及 JVM 内部结构
└─ direct buffer、JNI、映射文件等其他内存
↓ 驻留页及系统计费
进程 RSS / cgroup memory.current缓存大小估算先看完整对象图,容器容量估算还要加入堆外和系统开销。两种计算都不能只把 Java 字段宽度相加。
建立可比较的内存测量环境
运行时布局是实现事实,不是只靠 Java 源码就能固定的常量。下面的实验统一使用 64 位 Temurin 17.0.20+8、8 字节对象对齐和 JOL 0.17;生产服务可以使用其他受支持的 JDK,但比较前后版本时必须保持发行商、JDK 版本、架构和 JVM 参数一致。
需要从 Eclipse Temurin 下载页安装完整 JDK,而不是只带 java 的精简运行时,因为实验还会使用 javac 和 jcmd。在 Linux Bash 中以普通用户操作,先将路径改为实际安装目录,再确认三个命令属于同一套 JDK:
export JDK17_HOME=/opt/jdk-17
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JDK17_HOME/bin/java" -version
"$JDK17_HOME/bin/javac" -version
"$JDK17_HOME/bin/jcmd" -l下载完整实验包,解压后进入 runtime-memory-object。实验固定 Linux amd64/x86_64、普通用户及完整 JDK 17。JDK 17 用于观察传统对象头;升级 JDK 后需要重新测量布局。
bash run.sh脚本会下载 jol-core-0.17.jar 并校验固定 SHA-256。企业内网或离线环境应先把同一 JAR 放入可信制品库,再显式传入本地路径:
export JOL_JAR=/path/to/jol-core-0.17.jar
bash run.sh第一次成功运行会以 PASS 结束,并明确输出压缩与非压缩引用宽度、对象图关系、16 MiB 堆载荷、12 MiB direct buffer、NMT diff 和 direct 上限负向结果。脚本拒绝 JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS 与 _JAVA_OPTIONS,防止环境变量悄悄改变声明过的实验参数。
从运行时数据区到进程内存
JVMS 17 的运行时数据区描述虚拟机必须提供的逻辑语义。每个线程有自己的 PC 寄存器、Java 虚拟机栈和可能存在的本地方法栈;堆、方法区和运行时常量池由线程共享。每次方法调用产生一个栈帧,帧中有局部变量表、操作数栈和指向运行时常量池的引用。局部变量里的对象引用可以指向堆对象,但引用位于栈帧并不等于对象也“分配在栈上”。
HotSpot 要把这些语义落实到真实进程。Java 对象和数组进入 heap;类元数据主要进入 Metaspace;JIT 生成的机器码进入 Code Cache;每个平台线程还需要本地栈与线程结构。虚拟线程的挂起栈可保存在堆中的 stack chunk,挂载后使用 carrier 执行,因此不能用“虚拟线程数 × -Xss”估算本地栈。虚拟线程并未改变每次调用需要保存执行状态的语义。ByteBuffer.allocateDirect 创建的包装对象本身仍在堆里,但它管理的缓冲存储不在普通 Java heap 中。JNI 库、GC、编译器、mmap 和其他 native 组件还会继续占用地址空间或驻留页。
操作系统看到的是进程的虚拟地址空间和驻留页。Linux /proc 中的 VmRSS 是该进程当前驻留集的近似统计,不是 HotSpot committed 的简单总和。cgroup v2 的 memory.current 又是整个 cgroup 及其后代的计费量,还可能包含文件缓存、套接字和部分内核内存,因此单进程 RSS 也不能直接代替容器用量。
这些词最容易被混用:
| 口径 | 回答的问题 | 不能据此推出什么 |
|---|---|---|
reserved | 组件预留了多少虚拟地址空间 | 这些地址都已提交或驻留 |
committed | JVM 或 OS 已承诺可用多少内存 | 每一页都已被业务写入并计入 RSS |
used | 某个 JVM 内存池估算已有多少内容 | 整个进程或容器用了多少内存 |
| 进程 RSS | 该进程有多少页面当前驻留 | cgroup 总用量只来自这个进程 |
cgroup memory.current | 当前 cgroup 的总计费内存 | 每一字节都能被 NMT 或 heap dump 解释 |
| memory limit | cgroup 允许使用的上限 | 当前已经占用了这么多内存 |
因此,“容器 4 GiB、-Xmx2g,还剩 2 GiB 给堆外”只是预算起点,不是事实分解。线程栈的 线程数 × -Xss 更接近地址空间和上界预算,实际 committed 与 resident 还取决于栈触页、guard page 和 native 线程结构。NMT 的 reserved/committed、进程 RSS 与 cgroup 计费必须保留各自单位和时间点,不能拼成一张看似精确的总表。
对象头、字段与可达对象图
JVMS 2.7没有规定对象必须使用哪种内存布局。在常见 64 位 HotSpot 中,一个普通对象可以从三部分理解:
对象头:Mark Word + Klass pointer
实例数据:原始值字段与引用槽经过实现相关的布局和重排
对齐填充:让实例大小满足对象对齐要求数组还需要保存长度。压缩普通对象指针影响对象字段或数组元素中的普通引用宽度;压缩类指针影响对象头里的 Klass pointer。两者是不同开关,不能都简称为“压缩指针”后假定影响完全相同。
OpenJDK JOL适合观察当前 JVM 的字段偏移、对象头、内部空洞、尾部 padding 与实例大小。LayoutProbe.java 中的 OrderLine 包含 long orderId、int quantity、boolean gift 和 Object note,脚本分别在压缩和非压缩配置下运行 ClassLayout。固定环境中会得到:
layout=compressed-ref-4B+klass-4B
layout=uncompressed-ref-8B+klass-8B
layout=compressed-header-12B+shallow-32B+internal-padding-3B
object-graph=larger-than-shallow可以直接运行 JOL 探针,观察摘要背后的偏移表。以下命令接续解压目录,JOL_JAR 指向已校验的本地 JAR;完整脚本中的 SHA-256 为 bd73d9ad265d8478ccde06130200877f3a4f0a6e2d44e7fb8cbb2e6f4104cbdc。
sha256sum "$JOL_JAR"
LAB_OUT=$(mktemp -d /tmp/runtime-memory-object.XXXXXX)
mkdir "$LAB_OUT/classes"
"$JDK17_HOME/bin/javac" --release 17 -cp "$JOL_JAR" \
-d "$LAB_OUT/classes" LayoutProbe.java MemoryRegionProbe.java
"$JDK17_HOME/bin/java" -Xmx96m -XX:ObjectAlignmentInBytes=8 \
-XX:+UseCompressedOops -XX:+UseCompressedClassPointers \
-cp "$LAB_OUT/classes:$JOL_JAR" example.memory.LayoutProbe
"$JDK17_HOME/bin/java" -Xmx96m -XX:ObjectAlignmentInBytes=8 \
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers \
-cp "$LAB_OUT/classes:$JOL_JAR" example.memory.LayoutProbe校验和必须完全匹配;缺少 JOL 时先按实验包 README 下载并校验,不跳过依赖检查。压缩模式的 OrderLine 为 32 字节,非压缩模式为 40 字节。JOL 若报告 attach 或地址推断警告,保留警告并核对布局来源,不把推测地址作为准确地址使用。更换 JDK、架构或对齐参数后,重新读取偏移表。
压缩模式下的实际 ClassLayout 片段把“字段大小相加为何不成立”直接展开了:
OFF SZ TYPE DESCRIPTION
0 8 (object header: mark)
8 4 (object header: class)
12 4 int OrderLine.quantity
16 8 long OrderLine.orderId
24 1 boolean OrderLine.gift
25 3 (alignment/padding gap)
28 4 java.lang.Object OrderLine.note
Instance size: 32 bytes字段声明顺序没有直接变成内存偏移顺序,note 的 4 B 也只是一条引用槽,不包含它指向的 String。脚本会逐项断言两个模式的 Mark Word、Klass pointer、四个字段、padding 与 instance size 都实际出现在 JOL 输出中,而不是只检查最后一行摘要。
ClassLayout 的 instance size 是 shallow size:它包含 OrderLine 自己的对象头、字段槽和 padding,却不包含 note 指向的 String 及其底层数组。GraphLayout 从根对象继续遍历可达对象,因此这个实验要求 graph footprint 必须大于 shallow size。两种数字分别回答“这个实例壳多大”和“从这个根出发能触达多少对象”,不能互相替代。
紧凑对象头与数组
传统压缩类指针模式下,普通对象头常为 8 字节 Mark Word 加 4 字节 Klass pointer,数组还需要长度字段并满足对齐。一个空 byte[] 也有头部与长度;只有元素载荷接近业务声明的字节数。引用数组保存引用槽,各槽指向的对象需要另外计算。
JDK 25 的紧凑对象头将普通对象头压缩为 64 位,是可通过 UseCompactObjectHeaders 选择的正式功能,并非所有 JDK 25 默认布局。是否支持与是否启用应检查目标 JVM;JEP 519说明其实现状态。JOL 0.17 的本实验输出仅用于上文 JDK 17,不用旧工具结果推测紧凑头布局。
export JDK25_HOME=/opt/jdk-25
"$JDK25_HOME/bin/java" -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'UseCompactObjectHeaders|UseCompressedOops|UseCompressedClassPointers|ObjectAlignmentInBytes'这条命令显示新启动 JVM 的默认或显式参数,检查已运行服务时应使用同一目标的 jcmd VM.flags -all。最终对象尺寸还受字段组合和对齐影响:头部减少4字节不保证每个对象恰好减少4字节。
shallow、graph 与 retained
JOL 的 graph footprint 统计从指定根可达的对象大小;heap dump 中 retained size 则考虑某对象消失后哪些对象不再被其他路径保留。存在共享字符串或全局缓存时,这两种数字可能相差很大。
容量估算也不能把一个 graph footprint 机械乘以条目数。多个条目可能共享字符串、字典、类对象或缓存节点,单条对象图相乘会重复计算;反过来,只测一条平均 DTO 又会漏掉长尾数组和集合扩容。更可靠的做法是按真实数据分布构造一整个代表性容器或批次,比较加入数据前后的对象图差值,再用压测中的峰值存活量校准。独占 payload、共享对象和容器索引要分别定义所有权。
分别观察堆载荷与直接缓冲区
MemoryRegionProbe.java先建立基线,再逐页写入并保留 16 个 1 MiB byte[],同时分配并触页一个 12 MiB direct buffer。普通数组的载荷由 Java 引用保活;direct buffer 除了堆内包装对象,还要在 Direct BufferPoolMXBean 中产生 count、total capacity 与 memory used 变化。
一次固定环境的输出可能是:
heap-payload=16MiB-live
direct-buffer=12MiB-live+pool-observed
heap-used-delta-observed=16777472
direct-memory-used-delta-observed=12582917
nmt=baseline+summary.diff-observed
direct-limit=direct-buffer-OOME+heap-headroom
PASS前两行来自程序对载荷的检查:数组的长度、内容和强引用仍在,direct buffer 的容量、内容及 Direct BufferPool 增量也被检查。两个 *-observed 是 MXBean 在本次时间点给出的估计差值,JVM 启动活动、管理接口自身分配和采样时机会让它们略有变化,所以脚本只报告,不要求恰好等于请求字节数。
脚本还以 -XX:NativeMemoryTracking=summary 启动同一个进程,在分配前执行 baseline,分配后执行 summary.diff。JDK 17 NMT必须在 JVM 启动时开启,运行中可以停止但不能重新启动;它会带来额外开销,适合在压测和受控生产范围内提前评估。更重要的是,NMT 跟踪 HotSpot/JVM 内部分类,不覆盖所有第三方 native 代码和 JDK 类库分配,也不是 RSS 或 cgroup 用量的完整解释。
继续使用前面编译出的 classes。探针以文件信号控制分配,使 baseline 与 diff 确实来自同一进程。以下代码块整体执行;每个信号最多等待 20 秒,Java 端最长等待 5 分钟,超时后检查 run.log。
CONTROL="$LAB_OUT/control"
mkdir "$CONTROL"
"$JDK17_HOME/bin/java" -Xint -Xms96m -Xmx96m -XX:+UseSerialGC \
-XX:NativeMemoryTracking=summary -XX:MaxDirectMemorySize=32m \
-cp "$LAB_OUT/classes" example.memory.MemoryRegionProbe "$CONTROL" \
>"$LAB_OUT/run.log" 2>&1 &
LAB_PID=$!
wait_signal() {
for attempt in $(seq 1 200); do
test -f "$1" && return 0
kill -0 "$LAB_PID" 2>/dev/null || break
sleep 0.1
done
cat "$LAB_OUT/run.log"
return 1
}
if wait_signal "$CONTROL/ready"; then
"$JDK17_HOME/bin/jcmd" "$LAB_PID" VM.native_memory baseline
touch "$CONTROL/allocate"
if wait_signal "$CONTROL/allocated"; then
cat "$CONTROL/report"
"$JDK17_HOME/bin/jcmd" "$LAB_PID" VM.native_memory summary.diff scale=KB
fi
touch "$CONTROL/stop"
fi
wait "$LAB_PID"baseline 应返回已建立基线的提示,report 应有 heap-payload-live=true、direct-payload-live=true 和 direct-pool-observed=true,diff 应列出各内存分类。NMT 未启用就回查启动参数;attach 失败则检查 PID、同用户权限和 /tmp 可见性。BufferPool 反映直接缓冲区,NMT 反映其覆盖的 JVM 分类,系统指标反映驻留与计费,三者分别保留原口径。
负向模式用 -XX:MaxDirectMemorySize=4m 尝试分配 8 MiB direct buffer。探针先确认 heap 的 max - used 大于 8 MiB 请求,再要求异常信息明确属于 direct buffer memory;任意其他 OutOfMemoryError 都会让实验失败。直接运行负例:
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-XX:MaxDirectMemorySize=4m -cp "$LAB_OUT/classes" \
example.memory.MemoryRegionProbe direct-limit预期输出 direct-limit=direct-buffer-OOME+heap-headroom,进程退出 0:探针已捕获并核对特定异常。若出现其他异常或意外分配成功,它会失败退出。这个结果将直接缓冲区上限与 Java heap 余量分开验证。
所有探针退出后,只删除本次创建的临时目录:
case "$LAB_OUT" in
/tmp/runtime-memory-object.*) rm -rf -- "$LAB_OUT" ;;
*) printf '拒绝清理未知路径\n' >&2 ;;
esac根据业务峰值分配容器预算
给容器设置 -Xmx 前,先把会实际争夺上限的部分列出来:
cgroup 预算
>= heap 峰值
+ Metaspace / Compressed Class Space 峰值
+ direct buffer 与 mmap 峰值
+ 线程栈及 native 线程开销
+ Code Cache、GC、编译器和 JVM internal
+ JNI / 第三方 native
+ 同 cgroup 的其他进程、文件缓存和安全余量这不是要求把所有 reserved 数字直接相加。线程栈和某些虚拟地址预留可能没有全部 committed,更没有全部 resident;cgroup 限制却针对实际计费量。预算过程应先用业务上界约束缓存条数、批次大小、direct buffer 池和线程数,再在与生产一致的 JDK、参数和容器限制下压测,比较稳定期与峰值期的 heap、BufferPool、NMT、进程 RSS 和 memory.current。
首轮归位只需要少量证据:
| 现象 | 第一证据 | 下一步问题 |
|---|---|---|
| GC 后 heap 基线仍上涨 | heap pool、GC 后存活量 | 谁从 GC Roots 保留对象 |
| heap 稳定,Direct BufferPool 上涨 | BufferPool count/capacity/memory used | buffer 生命周期与池上限是否失控 |
| Metaspace 上涨 | VM.metaspace、类加载器数量 | 是否持续生成类或保留旧加载器 |
| 线程数上涨 | 线程计数、NMT Thread reserved/committed | 谁创建线程,线程池上限是否存在 |
| NMT 分类上涨 | baseline 后的 summary.diff | 哪个 HotSpot 子系统在增长 |
| RSS 或 cgroup 上涨,NMT 解释不了 | /proc、pmap、memory.stat/events | mmap、文件缓存、JNI 或第三方 native 是否增长 |
只有证据已经指向堆对象,heap dump 才值得承担停顿、磁盘和敏感数据成本。对象为何仍可达、TLAB 与安全点怎样工作,应沿对象分配、GC Roots 与安全点继续取证;收集器周期和停顿由GC 收集器与日志处理;PID、权限、dump、JFR 与事故采集则交给 JVM 诊断。
内存增长的定位与恢复
heap dump 没有大对象,但容器仍被杀。 先对齐同一时刻的 heap used、Direct BufferPool、线程数、NMT diff、进程 RSS、cgroup memory.current 与 memory.events。若 NMT 解释不了差额,继续检查 mmap、文件缓存、JNI 和第三方 native;不要反过来扩大 -Xmx 挤压剩余空间。修复后在相同负载下比较各分类增长斜率,并确认 cgroup 峰值远离限制线。
JOL 显示 DTO 很小,缓存容量却远超估算。 先确认拿到的是 shallow size 还是 graph footprint,再检查字符串、数组、集合容量、共享节点与长尾记录。修复可以是限制集合尺寸、压缩业务表示、拆分冷热字段或给缓存设置条目与字节双上限;复测应针对完整容器和真实数据分布,不只重测一个空对象。
线程数上涨后 RSS 上涨,于是直接用 线程数 × -Xss 当泄漏量。 这个乘积只能作为栈预留上界,不能代替 committed 或 resident。需要同时比较线程数、NMT Thread 的 reserved/committed、进程 RSS 和线程实际调用深度。永久修复是找到无界线程创建、阻塞放大或线程池生命周期问题;复测既要确认线程数收敛,也要确认 RSS 与 cgroup 峰值随负载恢复稳定。
JOL 的字段偏移、BufferPool 的容量、NMT 分类和系统驻留量分别保留原单位与采样时刻。修复后用相同数据分布重放负载,既检查对应增长项,也检查业务吞吐和容器峰值。
权威资料与规范地址
按正文中的概念与命令查阅原始规范。版本化文档对应标注的 JDK,升级后应查目标版本的同名章节。
