JVM 内存区域与对象分配:规范、实现与进程账单
本页语义基线为 Java SE 17,HotSpot 源码固定到
jdk-17.0.12+7,实验运行 Oracle JDK 17.0.12(macOS x86_64)。规范概念、HotSpot 实现和本机实测数字分三层表述;图解服务结构,实验分别写预期与实测。
规范与实现分层
JVM 是抽象执行模型:JVMS 规定 class 文件、验证、运行时数据区和指令语义;JLS 规定 Java 程序语义,包括初始化与内存模型;HotSpot 是 JVM 的一种具体实现。规范通常不规定对象头的位布局、堆的代际组织、JIT 阈值或某收集器的日志文案。
因此讨论一个现象时先标明层次:程序在规范上是否合法、虚拟机需要满足什么语义、当前 HotSpot 通过什么机制实现。不能从一次堆转储推导所有虚拟机相同,也不能用某版本的源码分支替代语言保证。本页及同目录各页的源码结论都固定到 jdk-17.0.12+7,升级 JDK 后要重核分支与常量。
从运行时数据区到进程账单
JVMS 定义线程私有的 PC、Java 虚拟机栈和可选的本地方法栈,以及线程共享的堆、方法区和运行时常量池。栈帧承载局部变量、操作数栈及动态链接等状态;栈里可以有指向堆对象的引用,不能把“局部变量”直接等同于“对象在栈上”。方法区是规范概念,HotSpot Metaspace 承载大量类元数据,而 Class 镜像仍在堆中。运行时常量池不等同于全局字符串表,class 常量项、解析结构与堆里的 String 对象要分层讨论(见 JVMS §2.5、§2.6)。
进程账单有三个必须分开的口径:reserved 是预留虚拟地址范围,committed 是 JVM 已承诺可用的范围,resident 是操作系统当前驻留在物理页的部分,三者不能相加互推。-Xmx 只限 Java 堆,不覆盖线程栈、代码缓存、GC 辅助结构、直接缓冲、JNI 和文件映射。NMT 是 HotSpot 的分类账,不覆盖所有第三方 native 分配,committed 也不等于 resident(见 NMT 文档);容器内的资源约束与 /proc 证据属于 Linux 主题,见 Linux、cgroup 与容器诊断。
规划算例:容器 2 GiB,堆 1024 MiB,直接缓冲预算 192 MiB,元空间 160 MiB,代码缓存 128 MiB,300 个线程按每线程约 1 MiB 栈预留估算,再为 GC/native/文件缓存与峰值留 244 MiB。这不是精确 RSS 加法,也不是 JVM 自动限制的总和,应使用实测验证。如果线程变成 600 个,即使堆稳定也会挤掉余量;只把 -Xmx 加到 1.5 GiB 可能加速容器被杀,应先约束并发和 native 资源。
对象头、字段、数组与对齐
HotSpot 17 的普通对象头包含 mark word 与 klass 指针。mark word 在不同状态承载哈希、年龄、锁等信息,并不是所有信息始终作为独立字段存在。压缩类指针影响 klass 表示,压缩普通对象指针影响对象引用字段,两者是不同选项;不能仅凭 UseCompressedOops=true 就认定所有指针都是 4 字节。实现入口见 oop.hpp 与 markWord.hpp。
64 位、压缩 klass/oop、8 字节对齐的典型配置下,空对象头 12 字节,对齐后浅大小为 16。数组额外存长度:Object[3] 通常按 16 + 3×4 = 28 向上对齐为 32;byte[3] 为 16 + 3 = 19,对齐为 24。字段可能重排、填补父类空隙并受特殊注解影响,不能按源码声明顺序直接求和;布局由 FieldLayoutBuilder 等实现。以上数字属于“典型配置预期”,必须结合实际 VM 参数用实验验证(本页实验一节给出实测值)。
压缩引用的寻址范围取决于基址、对齐和平台,“32 GB 永远是唯一分界”不是可靠的容量规则。增大对齐可以改变表示范围,却可能增加小对象尾部浪费。浅大小不包含引用指向的对象;retained size 取决于对象图中其他持有者,两者不能混用,更不能用浅大小推导对象释放后会归还多少 RSS。
一次分配:TLAB、慢路径与失败
解释器、C1、C2 有各自内联的分配快路径;通用慢路径可以沿 InstanceKlass::allocate_instance → CollectedHeap::obj_allocate → ObjAllocator / MemAllocator::allocate 阅读。这不表示每个成功的 new 都调用 C++ 函数:编译后的 TLAB 快路径通常直接操作线程本地指针。入口见 instanceKlass.cpp 与 memAllocator.cpp。
TLAB 是从 Java 堆划给单个线程的一段区域,常见快路径检查 top + size <= end 后推进 top,避免每个小对象都争抢共享分配指针。独享的是分配权:里面的对象仍属于堆,可以发布给其他线程,照样需要符合 Java 内存模型。TLAB 既不是线程栈,也不决定对象能否被回收。
TLAB 不够时不是立即 GC。对照 MemAllocator::allocate 的慢路径分支,一次失败会依次尝试:采样边界恢复、重新申请 TLAB、保留旧 TLAB 改走共享堆分配,只有后续资源压力才可能触发回收与重试。大对象可能走 TLAB 之外,但 TLAB 外不等于老年代,代际归属由收集器规则决定。TLAB 退役时剩余缓冲尾部必须成为 GC 可遍历的布局,不能把随机字节留给扫描器;过多低分配线程会放大缓冲尾部浪费。因此先看 -Xlog:gc+tlab=debug 的 refill/waste 统计,再讨论参数。
原码:得到堆内存后才初始化对象
原码连续摘录(仅统一公共缩进,截取方法整体):memAllocator.cpp,jdk-17.0.12+7,L340–L354。
oop MemAllocator::allocate() const {
oop obj = NULL;
{
Allocation allocation(*this, &obj);
HeapWord* mem = mem_allocate(allocation);
if (mem != NULL) {
obj = initialize(mem);
} else {
// The unhandled oop detector will poison local variable obj,
// so reset it to NULL if mem is NULL.
obj = NULL;
}
}
return obj;
}Allocation 是本次分配的上下文,mem_allocate 取得 HeapWord 区域,只有非空才 initialize 对象。失败分支把 obj 设回 NULL;OOME 与采样等处理还要结合 Allocation 生命周期和慢路径理解,不能把 return obj 等同于“保证分配成功”。
原码:finish 用 release 语义发布 klass
原码连续摘录(仅统一公共缩进,截取方法整体):memAllocator.cpp,jdk-17.0.12+7,L364–L377。
oop MemAllocator::finish(HeapWord* mem) const {
assert(mem != NULL, "NULL object pointer");
if (UseBiasedLocking) {
oopDesc::set_mark(mem, _klass->prototype_header());
} else {
// May be bootstrapping
oopDesc::set_mark(mem, markWord::prototype());
}
// Need a release store to ensure array/class length, mark word, and
// object zeroing are visible before setting the klass non-NULL, for
// concurrent collectors.
oopDesc::release_set_klass(mem, _klass);
return cast_to_oop(mem);
}注释直接说明了这一段协议:先设置 mark word,再用 release 语义发布 klass,确保长度、mark word 和清零在 klass 可见之前完成,并发收集器才不会扫描到无法解析的半成品对象。这是 GC 协议,不能代替业务安全发布——Java <init> 若把 this 存入全局集合,其他线程仍可能观察到业务不变量尚未完成的对象;构造器抛异常也不会回滚已经写入外部集合的引用。
分配最终失败时由 Allocation::check_out_of_memory 区分 heap space、GC overhead 等路径,在到达这里之前具体收集器可能已经回收、扩容和重试。直接缓冲、元空间、无法创建 native 线程是不同资源的不同失败,不能用这一条 Java 堆路径概括所有 OOME;直接缓冲失败的真实日志见 CPU、内存、GC 与线程故障实战。
逃逸分析、标量替换与反优化
C2 构建 Connection Graph 并传播 NoEscape、ArgEscape、GlobalEscape 等状态;NoEscape 只是分配消除的必要条件之一。ConnectionGraph::compute_escape 之后,adjust_scalar_replaceable_state、find_scalar_replaceable_allocs 继续检查字段、数组长度、别名与合流限制;满足可标量替换条件,并且 EliminateAllocations 等开关允许,才进入后续优化(固定 tag 的 escape.cpp)。
原码:无逃逸之后还要过“可标量替换”这一关
原码连续摘录(仅统一公共缩进,截取方法局部):escape.cpp,jdk-17.0.12+7,L240–L266。
// 3. Adjust scalar_replaceable state of nonescaping objects and push
// scalar replaceable allocations on alloc_worklist for processing
// in split_unique_types().
GrowableArray<JavaObjectNode*> jobj_worklist;
int non_escaped_length = non_escaped_allocs_worklist.length();
bool found_nsr_alloc = false;
for (int next = 0; next < non_escaped_length; next++) {
JavaObjectNode* ptn = non_escaped_allocs_worklist.at(next);
bool noescape = (ptn->escape_state() == PointsToNode::NoEscape);
Node* n = ptn->ideal_node();
if (n->is_Allocate()) {
n->as_Allocate()->_is_non_escaping = noescape;
}
if (noescape && ptn->scalar_replaceable()) {
adjust_scalar_replaceable_state(ptn);
if (ptn->scalar_replaceable()) {
jobj_worklist.push(ptn);
} else {
found_nsr_alloc = true;
}
}
}
// Propagate NSR (Not Scalar Replaceable) state.
if (found_nsr_alloc) {
find_scalar_replaceable_allocs(jobj_worklist);
}这段是“无逃逸≠可消除”的直接证据:先按 NoEscape 过滤,随后 adjust_scalar_replaceable_state 还要检查存入字段偏移是否未知、是否与 null base 合流、是否与其他对象合流等条件,不满足就标记 NSR;NSR 会沿引用图传播——对象存进一个不可标量替换的容器,对象自己也保不住。所以“作用域小”或“没逃出方法”都只是必要条件,最终裁决在这两步里。
例如 Pair p = new Pair(i, i+1); total += p.left()+p.right();,内联且优化成功时机器码可能只剩算术运算,运行时不存在完整的 Pair 对象。说“对象被分配到栈上”掩盖了真实机制:HotSpot 17 C2 主要做标量替换与分配消除,不承诺局部对象都变成完整栈对象。若把 p 放入静态列表,逃逸条件改变,通常又需要真实分配;若调用目标无法内联,编译器也可能失去证明条件。这个差异现在是可指认的观察:com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes() 的差量在默认配置下对本页实验的不逃逸循环测得 0 字节、同循环塞进共享集合测得 46,583,352 字节;而 C1(-XX:TieredStopAtLevel=1)不做逃逸分析,不逃逸循环也照常分配 64,000,000 字节——层级、开关、代码形态三者缺一,分配消除就不发生(四配置对照见JIT 单元)。
投机优化依赖类层次或运行 profile,加载新类型、走到未预期分支后可能失效并反优化。解释执行需要把被消除的对象与字段恢复出来,Deoptimization::realloc_objects / reassign_fields 承担相关工作,见 deoptimization.cpp 与去优化单元的 trap reason 全表。因此“编译热路径零分配”不等于任何执行路径都不需要内存。冷启动、分层编译、内联预算和诊断插桩都能改变结果;某些 PrintEscapeAnalysis 选项需要 debug VM,不能作为发行版必备命令。可以用编译日志、分配采样与独立 JVM 的对照实验交叉验证,但 JFR 采样没出现 Pair 不是逐对象零分配的证明。
图:从 new 到对象可被 GC 解析
图中左侧是进程与线程的分层账单,右侧是从 new 到“GC 可解析对象”的分配路径:TLAB 快路径、慢路径分支、清零与 mark,直到 release 发布 klass。读图时先定位“资源属于谁”,再找该路径的失败分支,不要把整张图读成一次系统 malloc。
可运行实验:布局与逃逸的独立对照
源码位于 labs/jvm-senior。从仓库根目录执行(本机为 macOS x86_64、Oracle JDK 17.0.12):
lab_jdk=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
lab_build=$(JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/build.sh)
"$lab_jdk/bin/java" -javaagent:"$lab_build/size-agent.jar" \
-cp "$lab_build" JvmSeniorLab layout
"$lab_jdk/bin/java" -XX:ObjectAlignmentInBytes=16 \
-javaagent:"$lab_build/size-agent.jar" -cp "$lab_build" JvmSeniorLab layout
"$lab_jdk/bin/java" -Xms64m -Xmx64m -Xlog:gc \
-cp "$lab_build" JvmSeniorLab escape
"$lab_jdk/bin/java" -Xms64m -Xmx64m -XX:-DoEscapeAnalysis -Xlog:gc \
-cp "$lab_build" JvmSeniorLab escapeSizeAgent 使用 Instrumentation.getObjectSize,只测浅大小,不输出字段偏移,API 返回值本身就是实现相关的近似(Instrumentation API)。
预期(上节推导)与实测(本次运行)对照:
# 预期:Object=16,Object[3]=32,byte[3]=24;16 字节对齐时 byte[3] 变大。
# 实测(Oracle 17.0.12,macOS x86_64,默认对齐):
Object=16
Sample=32
Object[3]=32
byte[3]=24
# 实测(-XX:ObjectAlignmentInBytes=16):
Object=16
Sample=32
Object[3]=32
byte[3]=32默认配置下四个数字与推导一致;把对齐调到 16 后 byte[3] 从 24 变为 32,说明“加尾部浪费换寻址范围”确实发生。Sample 为 32 而非按字段声明简单求和的结果,说明字段重排与补齐也是实测对象。
逃逸对照的实测摘录:
# 默认(逃逸分析开启):
checksum=400000000000000 elapsedNanos=13455007
# -XX:-DoEscapeAnalysis:
checksum=400000000000000 elapsedNanos=251353602
# 且关闭 EA 的运行中出现多次 Pause Young (Normal) 的 Young GC,默认运行未出现。两次校验值相同,说明计算等价;耗时与 GC 日志的差异与“标量替换消除分配”一致,但单次 elapsedNanos 受编译与平台影响,不能据此宣称优化百分比。另用 churn 模式观察短命数组分配、与 heap 模式的固定保留集合比较(见 G1、ZGC 选型),防止把分配量与存活量混为一谈。
U10 起同一目录的统一入口还能把分配消除直接量化(需显式 JAVA_HOME 指向 JDK 17):run.sh classjit 第 3 节在同一 JVM 内断言“不逃逸循环分配字节 × 8 < 逃逸循环分配字节”,run.sh classjit-tiers 在 -Xint/C1-only/默认/-XX:-DoEscapeAnalysis 四配置下分别打印同一负载的分配字节台阶(默认档 0 字节、其余 64,000,000 字节,本机观察值);输入、输出与退出状态见证据,层级与阈值的机制讲解归JIT 单元。
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
bash labs/jvm-senior/run.sh classjit-tiers高级口述与三轮追问
主问:“对象一定在堆上吗?TLAB 与逃逸分析是什么关系?”答:源码中的 new 不保证有对应的机器分配;真实对象通常经堆内线程独享的 TLAB 分配,C2 可能消除不逃逸且可标量替换的对象。TLAB 减少分配争用,逃逸分析为分配/锁消除提供证明,两者作用层次不同。
第一轮:“TLAB 不够就触发 GC 吗?”答:先尝试边界恢复、换 TLAB、保留旧 TLAB 走共享堆;只有后续资源压力才可能回收重试。可以指出 MemAllocator::allocate 中 mem_allocate 失败返回 NULL 后再由慢路径处理的分支。评分点:说出至少两个非 GC 分支。
第二轮:“局部 Pair 为什么仍然发生分配?”答:先检查是否进入 C2、访问器是否内联、别名/合流是否允许标量替换;作用域小不构成证明。评分点:区分“无逃逸”与“可消除”两个条件。
第三轮:“预热后为什么又出现分配尖峰?”答:检查类加载、编译失效/反优化、插桩与负载分支变化,用编译日志和 JFR 时间线交叉验证。评分点:给出可证伪的假设,而不是凭 GC 次数猜原因。
架构师变化条件:寿命、并发与对象池
每秒 100 万个浅大小 32 字节的包装对象约产生 30.5 MiB/s 分配;平均存活 10 ms 时粗略瞬时存活约 0.305 MiB。若排队让它活到 10 s,瞬时存活变成约 305 MiB——对象尺寸没变,在途时间改变了问题性质。这里按稳定到达与固定大小近似,未计引用对象;先约束并发与队列,再讨论分配细节。
候选 A 是保留廉价短命分配、优化批处理和内联;候选 B 是对象池。池减少某些昂贵 native 资源的创建,却引入长期保留、跨线程争用、脏状态清理和归还责任;便宜小对象进池反而可能让原本可消除的对象逃逸。若对象改为 4 MiB 直接缓冲,池的收益会变化,但必须限制总字节、借出时长和超时归还。比较同吞吐下的 P99、GC CPU、分配率和峰值 RSS,而不是只数 new 的次数。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JVMS SE 17 §2.5 运行时数据区 | Java SE 17 | 线程私有/共享区域、栈帧与常量池的规范定义 |
| JLS SE 17 | Java SE 17 | 程序语义与内存模型基线 |
| HotSpot NMT 文档 | JDK 17 | NMT 分类范围与 committed/resident 边界 |
| Instrumentation.getObjectSize | Java SE 17 | 浅大小为“实现相关近似值”的 API 承诺 |
| OpenJDK 17u memAllocator.cpp | jdk-17.0.12+7 | MemAllocator::allocate(L340–L354)、finish(L364–L377) |
| OpenJDK 17u escape.cpp | jdk-17.0.12+7 | Connection Graph 与标量替换条件;NSR 调整与传播(L240–L266) |
| OpenJDK 17u deoptimization.cpp | jdk-17.0.12+7 | 反优化时对象与字段的重建(机制展开见 JIT 单元) |
| OpenJDK 17u fieldLayoutBuilder.cpp | jdk-17.0.12+7 | 字段重排与对齐实现 |
| ThreadMXBean.getCurrentThreadAllocatedBytes | JDK 17(com.sun.management) | 分配字节差量的 HotSpot 扩展口径 |
本地 JvmSeniorLab layout/escape 实验 | Oracle JDK 17.0.12 | 浅大小与逃逸对照的实测输出 |
本地 ClassJitProbe classjit/classjit-tiers 实验 | Oracle JDK 17.0.12 | 分配字节台阶与四配置对照的实测输出(证据) |
访问日期:2026-10-23(escape.cpp 摘录与 ClassJitProbe 相关为 2026-10-25)。源码行号以 jdk-17.0.12+7 标签为准;实测数字来自本机 Oracle JDK 17.0.12(macOS x86_64),更换 JDK、平台或启动参数后需重跑实验。