JAVA · Vol.III · DAY 25 · JVM 原理与调优
对象创建全过程:类加载检查 → TLAB → 内存分配 → 初始化
new 指令到对象落地的五阶段完整链路
面试官喜欢用一个"入门级"问题开场,恰恰是因为它危险——人人都会答两句,而他要的是你能答出两句以上:
大多数候选人的回答是"在堆上分配内存,然后调用构造方法"。这句话没错,但就像把去医院描述成"看个病、拿个药"——省掉了分诊、挂号、检查、出报告的全过程。这篇文章把这"一次就诊"拆成五个阶段,逐个讲清楚每个阶段在做什么、为什么必须做、面试官会怎么追问。
先给结论。一条 new 指令从字节码到对象落地,必须经历五个阶段:
| 阶段 | 做什么 | 发生在哪 | 由谁完成 |
|---|---|---|---|
| ① 类加载检查 | 检查目标类是否已加载 / 链接 / 初始化,没有则先走类加载流程 | 方法区(类元信息)/ 堆(Class 对象) | JVM,由 new 指令自动触发 |
| ② 内存分配 | 按对象大小在堆上划出一块空间(指针碰撞 / 空闲列表,TLAB 优化并发) | 堆,通常是 Eden 里该线程的 TLAB | JVM 分配逻辑 |
| ③ 零值初始化 | 所有实例字段赋默认零值:0 / null / false / 0.0 | 堆上刚分配的那块空间 | JVM,与分配同一瞬间 |
| ④ 设置对象头 | 写入 Mark Word、类指针;数组对象再写入长度 | 堆上那块空间的头部 | JVM,与分配同一瞬间 |
| ⑤ 执行 <init> | 执行构造器字节码:super() → 字段初始化 → 构造器方法体 | 栈帧(执行)+ 堆(字段写回) | 解释器 / JIT + 构造器字节码 |
注意一个关键区分:阶段 ②③④ 都是"JVM 内部工作",在分配内存的那一刻一并完成;只有阶段 ⑤ 执行用户可见的构造器逻辑。很多面试陷阱的根源都在这——比如"为什么构造器里能读到字段的零值""为什么构造器抛异常的对象不会泄漏",答案都藏在"②③④ 先于 ⑤"这个顺序里。
阶段顺序容易记混:有资料把"设置对象头"写在"零值初始化"前面。HotSpot 实际的分配流程是先零值初始化、再设置对象头——先把整块内存(对象头部分除外)清零,再往头部写入 Mark Word 和类指针。两步发生在同一次分配里,中间没有任何业务逻辑。面试时按"先零值、后对象头"的口述是标准答案。
为什么 JVM 要把"一个 new"拆成五个阶段?因为每个阶段解决的是一个独立的问题:① 解决"类存不存在",② 解决"放在哪",③④ 解决"这块内存有没有确定状态",⑤ 解决"对象值是什么"。后面六站一站拆一个阶段:
- 第 2 站:类加载检查——为什么是"检查"而不是"加载";Class 对象其实住在堆里,不在方法区。
- 第 3~4 站:内存分配——指针碰撞 vs 空闲列表怎么选;多线程同时分配怎么不打架,TLAB 如何把竞争变成偶发事件。
- 第 5 站:零值初始化——为什么 Java 字段总有默认值而 C/C++ 没有;boolean 字段其实是 1 个字节。
- 第 6 站:对象头——Mark Word 的位级布局与五种锁状态、压缩指针、字段布局规则、用 JOL 算出任意对象的真实大小。
- 第 7 站:执行构造器——new → dup → invokespecial 的字节码真相,<init> 三部分的执行顺序。
- 第 8 站:JIT 限定词——"new 出来的对象一定在堆里"?不完全是。
发车前,把三个问题揣在兜里,边走边验证:
- 多个线程同时 new 对象,谁保证两个对象不会"撞"到同一块地址?
- TLAB 是固定大小吗?TLAB 申请失败之后会走哪条路?
- 8 字节的 Mark Word 凭什么同时装得下 hashcode、GC 年龄和锁状态?
阶段一:类加载检查——类准备好了吗?
面试官的第一个追问,通常就打在这个最不起眼的阶段上:
答"检查类加载了没有,没加载就加载"——80 分。想拿 100 分,要再说出两件事:"准备好"具体指哪三件事(加载 / 链接 / 初始化),以及类的"档案"到底存在哪(方法区 vs 堆)。
检查什么:三个"就绪"状态
new 指令执行时,目标类在字节码里以类常量引用的形式出现(常量池索引,比如 #2)。JVM 在使用这个引用之前,必须确认它指向的类处于"可用"状态。一个类从出生到可用要经历加载 → 链接(验证 / 准备 / 解析)→ 初始化,new 指令要求这三件事至少都已完成:
- 已加载:类的字节码流(来自 .class 文件、jar、网络、运行时生成)已被读取,堆里生成了
java.lang.Class对象,方法区里建好了类的元信息。 - 已链接:验证(字节码符合规范)、准备(给静态变量分配内存并赋零值)、解析(符号引用转成直接引用)都已完成。
- 已初始化:执行过类的
<clinit>(静态变量赋值和静态代码块编译后的字节码)——类的"静态世界"真正生效的那一刻。
重点来了:这一步是"检查",不是"加载"。绝大多数场景下,new 指令执行时类早已加载、初始化完毕——类在一个类加载器里只会被加载一次,业务类的"第一次使用"往往远早于此刻这个 new。new 指令做的只是确认"它在";只有确认"它不在",才会真正走完整的加载流程(委派父加载器、读字节码、验证、执行 <clinit>)。加载链接的完整流程见《类加载机制:双亲委派模型与三种破坏场景》。
为什么 new 能触发类初始化
有候选人会问:"类加载时不就一起初始化了吗,规范为什么还要单独强调 new 能触发类初始化?"因为加载、链接、初始化不保证一口气做完——初始化会一直延迟到第一次"主动使用"才发生。JVM 规范列举了几种必须触发类初始化的主动引用场景,其中"创建该类的实例(new,但 new 数组除外)"是最直接的一条;其余还包括:读写静态字段(非编译期常量)、调用静态方法、初始化子类(父类必须先行初始化)、JVM 直接执行的主类等。
所以 new 指令里的"检查"在极端情况下会"触发连锁反应":类还没初始化,JVM 先把完整的初始化流程(含静态块)走完,再继续分配内存。这也是线上"第一次请求特别慢"的微观解释之一——服务刚启动时的首个调用,常常撞上类加载 + 初始化 + JIT 冷启动,耗时比稳态大一个数量级很正常。
"Class 对象在哪?"——高频陷阱题。答案要一分为二:类的元信息(Klass、虚方法表、方法表、字段描述)在方法区(JDK 8 起是 Metaspace,位于堆外本地内存);Class 对象本身是一个普通的 Java 对象,分配在堆里。第 6 站讲的"类指针"指向方法区,是为了找到元信息;而你在代码里 .getClass() 拿到的,是堆里那个 Class 对象。两个区域的分工见《JVM 运行时数据区全景:堆、栈、方法区、程序计数器》。
特殊对象:数组
写 new int[100] 时容易忽略:int[] 本身也是一个"类"。但数组类不来自任何 .class 文件,它由 JVM 在运行时按需创建——数组类的元信息(元素类型、长度字段)是 JVM 当场组装的,初始化也在创建时一并完成。所以数组对象的"类加载检查"依然成立,只是这个类的"出生证明"是 JVM 自己开的,不走读文件的流程。数组对象的对象头结构(多一个长度字段)在第 6 站展开。
new 执行前,JVM 做的是"检查"而非"加载":确认类已加载、已链接、已初始化。绝大多数时候类早已就绪;只有首次使用时才走完整类加载流程(《类加载机制:双亲委派模型与三种破坏场景》)。记住位置分工:类元信息在方法区(Metaspace),Class 对象在堆里;数组类是 JVM 运行时生成的,没有类文件。
阶段二:指针碰撞 vs 空闲列表
阶段 ② 的核心问题只有一个:在堆上找到一块足够大的连续空间。听起来简单,但"找"的方式完全取决于一个前提——
答案是:当前堆内存是否"规整"——已用内存和空闲内存是否各占一侧、边界清晰。规整,就"碰指针";不规整,就"翻列表"。这两种方式叫指针碰撞(Bump the Pointer)和空闲列表(Free List),是 JVM 分配策略的两条基本路线。注意:这是"策略"层面的二选一,和"哪个线程来分配"(第 4 站的 TLAB)是两个正交的问题。
指针碰撞:一根指针顶完
当内存规整时,JVM 只需维护一根分配指针(Allocation Pointer),指向"已用内存"和"空闲内存"的分界线。分配一个 size 字节的对象:把指针往前挪 size 个字节,对象就放进了挪出来的那块空间。整个过程一次加法、一次指针更新,O(1),不需要遍历任何东西。
指针碰撞能用,靠的是"每次 GC 之后内存依然规整"。用复制算法的年轻代天然满足:每次 Minor GC 把存活对象紧凑复制到 To 区,Eden 和 From 区"指针拨回底部即清空",内存永远是"已用在前、空闲在后"的整齐形态。用标记-整理的区域同理——整理完成后对象紧凑排列,指针碰撞照样成立。
空闲列表:碎片化堆的无奈之选
当内存不规整——已用和空闲内存交错成碎片——指针碰撞就失效了:指针往前挪得到的空间里可能插着别的对象。这时 JVM 必须维护一张空闲列表:把每块空闲内存的(起始地址,大小)记下来,分配时遍历列表,找到第一块放得下的空闲块(first fit 一类策略)。对象放进去后,这块空闲内存被切掉,列表更新。
成本显而易见:分配从 O(1) 变成"查表 + 可能切块 + 更新列表",还要付出维护列表本身的 CPU 与内存开销。但这是标记-清除算法的必然代价——CMS 老年代用标记-清除回收(不整理),GC 之后堆就是碎片的,只能靠空闲列表分配。碎片越积越多时,一个稍大的对象可能"明明有空闲总量却找不到连续块",CMS 就会在 Full GC 时退而求其次做一次整理(Serial Old 单线程兜底,很慢)。各收集器用什么算法,见《垃圾收集器全览:Serial → Parallel → CMS → G1 → ZGC》。
一张表说清选型
| 策略 | 前提 | 分配成本 | 典型场景 |
|---|---|---|---|
| 指针碰撞 | 内存规整(已用/空闲各占一侧) | O(1),一次指针前移 | 年轻代(复制算法后)、Serial Old(整理后)、G1 Region 内 |
| 空闲列表 | 内存碎片化(已用/空闲交错) | 查表 + 切块 + 更新列表,慢 | CMS 老年代(标记-清除后);ZGC 的 Region 内块级分配 |
分配决策是一条向后的退路链:对象 ≤ Eden 剩余空间 → 在 Eden 分配(实际落在线程的 TLAB,第 4 站);Eden 剩余空间装不下(注意:是"剩余空间",不是 Eden 总大小)→ 直接在老年代分配;老年代也装不下 → 先触发 Full GC 腾空间再试;仍失败 → OutOfMemoryError: Java heap space。大对象直通老年代、G1 的 Humongous 对象这些细则,见《JVM 堆内存:年轻代三区与老年代的生命周期》第 3 站;OOM 本身的识别与排查,见《OOM 排查实战:六种 OutOfMemoryError 全解》。
分配策略看"内存规整不规整":规整 → 指针碰撞(O(1)),碎片 → 空闲列表(查表切块)。规整与否由收集算法决定——复制和标记-整理保持规整,标记-清除制造碎片。面试时把"策略(怎么找空间)"和"并发(谁来分配)"拆成两层说,层次感立刻不一样。
并发安全:CAS 重试与 TLAB 私有缓冲区
第 3 站解决了"怎么找空间",这一站解决"谁来分配"——多线程同时 new 对象时,如何保证两个线程不会把对象写到同一块地址。这是"对象创建全过程"里工程含量最高的一个知识点。
两个方案:CAS + 失败重试(无锁,原子地改指针)和 TLAB(给每个线程一块"私房钱")。先说 CAS 为什么不够,再说 TLAB 怎么把高频竞争变成偶发事件。
方案一:CAS + 失败重试
把"分配 size 字节"实现成对分配指针的原子操作:CAS(Compare-And-Swap)期望指针还在 old 位置,就改成 old + size;失败了说明别的线程抢先挪了指针,重试。低并发下这很高效,完全无锁。
但高并发下它有三个硬伤:① 所有线程挤在同一根指针、同一条缓存行上竞争,缓存行在核心间反复弹跳(cache line bouncing),CPU 大量耗在等内存一致性协议上;② 分配是每秒百万次级别的高频操作,竞争次数与分配频率成正比,自旋重试的开销直接线性放大;③ 重试还可能放大尾延迟——一个线程可能被连续"截胡"好几轮。
方案二:TLAB——线程私有分配缓冲区
TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)的思路是:既然大家抢同一根指针很贵,那就预先在 Eden 里给每个线程划一块私有缓冲区,线程在自己 TLAB 里分配对象时,动的是一根"私有指针"——不需要任何同步,还是指针碰撞,只是指针不再共享。只有当 TLAB 用完,才需要回 Eden "申请下一块",这时才发生一次 CAS 竞争。
本质是一笔交易:用"少量内存浪费"换"高频竞争的消失"。每次 TLAB 用完时,里面没用的剩余空间直接废弃(计入浪费);但换来的是 TLAB 装满之前(通常几百到几千个对象)该线程的每次分配都是零同步的。竞争从"每个对象一次"降频为"每个 TLAB 一次",频率差几个数量级。
TLAB 的大小:自适应,不是固定值
这是本题最大的陷阱区。三个参数各管一件事,面试时别串位:
-XX:+UseTLAB:开关,Server 模式默认开启(生产 JVM 都是 Server 模式,所以默认就有 TLAB)。-XX:TLABSize:手动固定 TLAB 大小(字节)。默认不固定——JVM 会观察每个线程的对象大小分布,动态调整 TLAB 大小:分配大对象的线程给大 TLAB,分配小对象的线程给小 TLAB,初始大小与线程近期分配的平均对象大小相关。-XX:TLABWasteTarget:默认 1%,含义是"TLAB 浪费目标"——JVM 希望"用不完就废弃的剩余空间"不超过 TLAB 大小的 1%。它约束的是浪费比例,不是 TLAB 占 Eden 的比例。
"TLAB 是 Eden 的 1%"或"TLAB 默认 32KB"——都是错的。TLAB 与 Eden 大小没有固定比例关系,也没有一个写死的默认大小:它是每线程独立、动态自适应的。1% 是 TLABWasteTarget(浪费目标)。面试被问"TLAB 多大",标准答法是:"默认自适应,JVM 按线程的对象分配模式动态调整;可用 TLABSize 手动固定,用 TLABWasteTarget(默认 1%)控制可接受的浪费比例。"
观察 TLAB:参数与日志
# 开关:Server 模式默认 true(生产 JVM 默认就有 TLAB)
-XX:+UseTLAB
# 手动固定 TLAB 大小(字节)。默认不设:JVM 按线程分配模式自适应
-XX:TLABSize=262144
# 浪费目标:TLAB 用完时废弃的剩余空间 ≤ TLAB 大小 × 1%(默认 1%)
# 注意:这是"浪费目标",不是"TLAB 占 Eden 的比例"
-XX:TLABWasteTarget=1
# JDK 8:GC 时打印各线程 TLAB 统计(示例日志,字段名以实际版本为准)
-XX:+PrintTLAB
# Thread 0x00007f8a1c008800 tlab_size=262144 fills=12 waste=8192 slowAllocs=3
# fills = TLAB 用完后 refill(重新申请)的次数
# waste = 被废弃的剩余空间字节数(refill 时计入)
# slowAllocs = TLAB 路径失败、回退到慢速路径分配的次数
# JDK 9+:统一日志框架输出 TLAB 分配信息(gc 日志 tlab 标签,具体输出随版本变化)
-Xlog:gc+tlab=trace
读这三个数字的方法:fills 很高说明 TLAB 偏小(频繁 refill,竞争回来了);waste 占比远超 1% 说明 TLAB 偏大(浪费内存);slowAllocs 持续非零说明对象经常大到绕过 TLAB(大对象多,去查分配源头)。G1 下 TLAB 取自当前 Eden Region,每次 Young GC 后旧 TLAB 作废、从新 Region 重新申请——所以 TLAB 统计和 GC 日志放在一起看才有意义。
CAS + 重试无锁但高频竞争下缓存行弹跳 + 自旋开销大;TLAB 给每个线程一块 Eden 里的私有缓冲区,线程内分配零同步,竞争降频为"每个 TLAB 一次"的 refill。大小自适应(TLABSize 可手动固定),TLABWasteTarget(默认 1%)是浪费目标而非 Eden 占比。装不下的对象:能开新 TLAB 就 refill,比新 TLAB 还大就绕过 TLAB 直接 Eden,Eden 也不够就进老年代。
阶段三:零值初始化——字段为什么总有默认值
内存分配完成的那一刻,JVM 顺手做了一件"看起来多余"的事:把刚划出来的那块空间(对象头部分除外)全部写成零值。于是 new 出来一个对象,即使你一行字段赋值都没写,它的字段也"有值":
public class ZeroInit {
int a;
long b;
boolean flag;
String name;
public ZeroInit() {
// 构造器还没做任何赋值,字段已经是确定的零值
System.out.println(a); // 输出: 0
System.out.println(b); // 输出: 0
System.out.println(flag); // 输出: false
System.out.println(name); // 输出: null
}
}
| 字段类型 | 默认零值 | 底层位模式 | 实例字段占用(HotSpot) |
|---|---|---|---|
int / short / byte | 0 | 全 0 位 | 4B / 2B / 1B |
char | '\u0000' | 全 0 位 | 2B |
long | 0L | 64 位全 0 | 8B |
float / double | 0.0f / 0.0d | IEEE 754 正零 | 4B / 8B |
boolean | false | 0 | 1B |
引用类型 | null | 全 0 位 | 4B(压缩指针)/ 8B |
为什么必须做:三条理由
理由一:堆内存是复用的,上面可能留着"尸体"。这块空间上一任住户可能是另一个已回收的对象,里面残留着它字段的字节。如果不清零,你 new 出来的对象读到的就是前任的"脏数据"——一个 int 字段可能"恰好"是上轮循环的计数值。清零是唯一能保证"字段有确定值"的手段。
理由二:Java 的语言契约允许"不赋值就使用"。实例字段不显式初始化就读取,语言是放行的(编译不报错),那么 JVM 就必须为这种放行兜底——保证任何字段在任何时刻都有确定值。对比一下局部变量:不赋值就使用是编译错误,因为局部变量的活跃区间短、编译器可以静态检查;字段会被跨方法、跨时间访问,编译器管不住,只能由 JVM 在分配时统一兜底。
理由三:对比 C/C++ 就能看出设计取舍。malloc 出来的内存不初始化,读到未初始化值是未定义行为。Java 选择在运行时统一清零,牺牲一点 CPU(每次分配多一次写零),换来"永远不会读到脏内存"的强保证——这是托管语言的典型做法:把安全做进运行时,而不是留给程序员自律。
不少资料(包括一些老版面试书)写"boolean 在 JVM 里按 int(4 字节)存储"——对实例字段不成立。HotSpot 中实例字段的 boolean 占 1 个字节(用 JOL 一看便知,第 6 站有工具)。"boolean 按 int 处理"说的是操作数栈上的行为:字节码栈里 boolean 以 32 位整型值存在(1 为 true,0 为 false),那是栈的粒度问题,和字段在对象里占几个字节是两回事。两个语境别混。
三个原因:① 构造器是用户代码,可能抛异常、可能被继承体系改写顺序——JVM 必须在用户代码开始执行之前就把内存置成"安全状态":即使 <init> 第一行就抛异常,字段依然是零值而不是脏值,任何拿到这个半成品对象的代码(比如父类构造器里)都不会读到垃圾。② 父类构造器可能读到子类字段——此刻子类的 <init> 还没执行,子类字段如果没被清零,父类代码读到的就是脏数据。③ 职责分层:零值初始化是"内存层"契约(JVM 负责,保证有值),构造器赋值是"逻辑层"行为(程序员负责,保证值正确)。零值是安全网,不是最终值——构造器随后会把它覆盖成真正的业务值,这次"重复劳动"正是分层的代价,也是分层的好处。
实现层面:HotSpot 在分配时就完成清零。小对象按字(8B/16B 粒度)批量写零;大对象(典型如大数组)用 memset 一类的高效率块清零指令。注意堆内存是"复用内存",不能依赖"新内存本来就是零"——OS 刚提交的新页常常是零页,但堆里循环使用的空间不是,所以 JVM 必须显式清零。
零值初始化发生在分配同一瞬间、构造器执行之前:字段默认 0 / null / false,boolean 实例字段占 1 字节。动机:堆内存复用会有脏数据 + 语言契约允许字段不赋值就读。零值是"内存层安全网",构造器赋值是"逻辑层真值",后者覆盖前者——所以"字段被赋值两次"不是 bug,是分层。
阶段四:对象头与内存布局
零值写完后,JVM 往这块内存的头部写元信息。对象头是 JVM 的"户口本"——GC 靠它判断对象死活与年龄,锁靠它判断对象有没有人占,方法调用靠它找到类的元信息。这一站是全文信息密度最高的一站。
对象内存布局:头、数据、对齐三件套
HotSpot 里每个 Java 对象的内存布局由三部分组成:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。HotSpot 要求对象总大小是 -XX:ObjectAlignmentInBytes(默认 8 字节)的整数倍,不够就补 Padding——所以对象的最小尺寸就是 16 字节(2 个对齐单元),new Object() 的真实大小就是 16B。
Mark Word:8 字节的"复用账本"
Mark Word 在 64 位 JVM 下固定 8 字节,但它的"户口内容"随对象状态变化——五种状态各存各的信息,靠最低 2 位的锁标志位区分:
| 状态 | 8 字节里存了什么(64 位) | 锁标志(低 2 位) |
|---|---|---|
| 无锁 | hashcode(31 位,惰性计算)+ GC 分代年龄(4 位)+ 偏向标志(1 位),其余位未用 | 01 |
| 偏向锁 | 偏向线程 ID(54 位)+ epoch(2 位)+ GC 年龄(4 位) | 01 |
| 轻量级锁 | 指向线程栈中锁记录(Lock Record)的指针(62 位) | 00 |
| 重量级锁 | 指向堆中 Monitor(ObjectMonitor)的指针(62 位) | 10 |
| GC 标记 | GC 期间的临时标志(标记/清除过程使用) | 11 |
三个必须说清的细节:
- hashcode 是惰性计算的:无锁态里预留了 31 位 hashcode 空间,但直到第一次调用
hashCode()(或System.identityHashCode())才真正计算并写入。刚 new 出来的对象,这块是空的。 - age(GC 分代年龄):每熬过一次 Minor GC 存活下来就 +1,达到晋升阈值(
-XX:MaxTenuringThreshold,默认 15)晋升老年代——age 就存在 Mark Word 这 4 个位里,晋升机制详见《JVM 堆内存:年轻代三区与老年代的生命周期》。 - 版本差异(必背):偏向锁从 JDK 15 起默认禁用(高竞争场景下偏向锁的撤销成本大于收益),从 JDK 17 起偏向锁相关选项被移除——所以现代 JDK 上实际存在的锁升级路径是:无锁 → 轻量级锁 → 重量级锁。
答:因为它们是"复用"关系,不是"并列"关系。同一块 8 字节,在不同时刻被不同状态解读:无锁/偏向前期存 hash + age;一旦上锁,整块改写成"指向锁结构的指针"(此时 hash 暂时不存放在 Mark Word 里,需要时 JVM 实时计算,解锁后再按需写回);GC 期间临时改写为标记位。8 字节是"按当前使用者整块改写"的账本,不是"每个字段各占固定槽位"的表格——这正是对象头能做到 8 字节的原因。追问再深一层:锁升级路径(无锁 → 轻量级 → 重量级,含 CAS 重试与 Monitor 的互斥量/等待队列)见并发卷的《synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁》。
类指针:对象的"户籍地"
对象头的第二块是类指针(Klass Pointer),指向方法区里该类的元信息(Klass:常量池、虚方法表、方法表、字段描述符)。它回答"这个对象是哪个类的实例",用途很多:虚方法分派(invokevirtual 靠它找到 vtable)、instanceof 判断、反射拿到字段/方法列表。压缩指针开启时它占 4 字节(未压缩 8 字节)。
压缩指针:4 字节的引用怎么撑起 32GB 堆
64 位 JVM 下对象引用"名义上"该占 8 字节,但 HotSpot 默认开启压缩指针(Compressed Oops,-XX:+UseCompressedOops 默认 true):堆小于 32GB 时,对象引用只占 4 字节。原理值得在面试里完整推一遍:
- 引用存的是相对于堆起始地址的偏移,不是完整 64 位地址——堆在内存中的具体位置由 OS 决定,VM 自己记录基址,省掉高位。
- 对象按 8 字节对齐(
-XX:ObjectAlignmentInBytes默认 8),偏移量的低 3 位恒为 0,不需要存——实际存的是 偏移 ÷ 8。 - 于是 32 位能表示 232 个不同的 8 字节对齐位置,寻址空间 = 232 × 8B = 32GB——"32GB 上限"就是这么来的。
- 读引用时做一次左移(shift 3)恢复完整地址;若
-XX:ObjectAlignmentInBytes=16,低 4 位恒 0,shift 4,寻址空间减半为 16GB。
收益:对象头类指针 4B(头部 16B 而非 24B)+ 所有引用字段 4B——对象普遍变小,同堆容量装更多对象,GC 扫描的数据量也降下来
这是"堆为什么默认不超过 32GB"的底层原因之一:堆到 32GB,压缩指针自动关闭,所有引用翻倍,内存占用和 GC 压力同时跳档——所以很多大堆服务会刻意把堆压在 31G 左右。堆与引用占用的全景见《JVM 运行时数据区全景:堆、栈、方法区、程序计数器》。
对象访问定位:直接指针 vs 句柄池
引用"怎么找到对象",JVM 规范允许两种方式:
- 直接指针(HotSpot 采用):对象引用直接指向对象头。优点:访问快,少一层间接;代价:GC 移动对象后要更新所有指向它的引用(靠根扫描 + 写屏障处理)。
- 句柄池(Handle Pool):引用指向一张句柄表,表里再存对象指针。优点:对象移动时只需改表,引用不变;代价:每次访问多一次间接寻址,还要维护这张表。JVM 规范允许,但 HotSpot 没有采用。
实例数据的字段布局:顺序由 JVM 决定,不是声明顺序
字段在"实例数据"里怎么排?JVM 规范(JVMS 2.13)只规定了一条:同宽度的字段要分配给更宽字段留下的空位(long/double 不会和 int 挤在一起浪费空间)。具体顺序由 JVM 实现自己定,HotSpot 的策略是:
- 按字段宽度分组:先放 8 字节宽字段(long/double),再放 4 字节(int/float/压缩后的引用),最后 1~2 字节(short/char/byte/boolean);
- 同宽度内:父类字段优先于子类字段,同一类里按声明顺序(LongSize 策略——宽字段优先布局,减少空洞)。
经典反直觉例子:即使 int i 声明在前,long l 也会排在前面——因为 long 要 8 字节对齐,放后面反而多出一个 4B 空洞。用 JOL(JOL 全称 Java Object Layout,org.openjdk.jol:jol)看一眼就记住了:
// 代码:class LayoutDemo { int i; long l; } (int 声明在前)
ClassLayout.parseInstance(new LayoutDemo()).toLayout()
// 示例输出(64 位,压缩指针开启):
LayoutDemo instance layout:
offset size type description
---------------------------
0 8 (object header) 1 word
8 4 (loss of alignment)
8 8 long LayoutDemo.l // long 排在前面!
16 4 int LayoutDemo.i
20 4 (4 bytes of padding)
Instance size: 24 bytes
Space wasted: 0 bytes (0%)
// 对照:数组 int[8] = 16B 头(含长度)+ 32B 元素 = 48 bytes
// 空对象 new Object() = 16 bytes(对象头即最小尺寸)
JOL 的用法:Maven 引入 org.openjdk.jol:jol(0.16 / 0.17 等版本),对任意对象调用 ClassLayout.parseInstance(obj).toLayout() 即可看到逐字节的布局——算对象大小永远以 JOL 输出为准,别手算(字段布局规则、压缩指针开关、数组长度字段,一个细节算错就差 4~8 字节)。三个常用结论:new Object() = 16B;int[8] = 48B;{int i; long l} = 24B。
"能不能通过调整字段声明顺序省内存?"——能,但收益有限。因为 HotSpot 按宽度分组布局,把 long/double 集中声明、引用和 int 放一起,确实可能减少 1~2 处 4B 空洞(对海量小对象有 5%~10% 量级的影响,通常)。但现代代码不建议为这点空间扭曲可读性——真要抠对象大小,先查"对象该不该存在"(能不能复用、能不能基本类型化),而不是字段排列。
对象 = 对象头 + 实例数据 + 对齐填充(8B 对齐,最小 16B)。头部:Mark Word 8B(五种状态复用,末 2 位锁标志:01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记;hash 惰性计算;age 默认 15 晋升;偏向锁 JDK 15 默认禁用、JDK 17 移除选项)+ 类指针 4B(压缩指针,堆 < 32GB)+ 填充。数组头再多 4B 长度。字段布局:宽字段优先、父先于子、同宽按声明——用 JOL 实测为准。
阶段五:执行 <init>——构造器的字节码真相
走到最后一步,JVM 终于要执行"用户代码"了。先回到字节码层面,看一条 new 到底被编译成了什么:
// 源码:Object obj = new Object();
// javap -c 反编译 main 方法:
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #3 // Method java/lang/Object.<init>:()V
7: astore_1 // 对象引用存入局部变量 obj
8: return
三条核心指令,各司其职:
new:触发前四个阶段——类加载检查、内存分配、零值初始化、设置对象头,然后把新对象的引用压上操作数栈。注意:new执行完,对象在堆里已经"存在"了,只是字段全是零值、构造器还没跑。dup:复制栈顶的引用。为什么要复制?因为下一条invokespecial会消费栈顶引用(把它当构造器的this参数),如果不复制,构造器执行完栈上就什么都没了,后面没法赋给局部变量。invokespecial:调用对象的<init>方法(构造器)。构造器执行期间,this指向的是一块"已经分配、已经清零、已经写好对象头"的完整内存。
换成带字段的类,看看构造器内部长什么样:
// 源码:class Person { String name; int age;
// Person(String name, int age) { this.name = name; this.age = age; } }
public Person(String, int);
// 构造器 <init> 的字节码:
0: aload_0 // 加载 this
1: invokespecial #1 // ① super() → Object.<init>(隐式插入,必须是第一句)
4: aload_0
5: aload_1
6: putfield #2 // ② this.name = name
9: aload_0
10: iload_2
11: putfield #3 // ③ this.age = age
14: return
<init> 的三段式结构
编译器生成的 <init> 永远是三段,顺序雷打不动:
- super()——调用父类构造器(字节码里第一条
invokespecial)。你不写它,编译器也会在第一行隐式插入super()。 - 字段初始化器 + 实例初始化块——按源码中出现顺序交织编译成
putfield(不是"先全部字段、再全部块")。 - 构造器方法体——你自己写的代码。
为什么 super() 必须排第一?Java 语言规范(JLS §8.8.7.1)强制规定:构造器的第一条语句必须是 super(...) 或 this(...)。原因是"先壳后瓤"——父类构造器可能初始化子类会依赖的字段,如果允许子类代码先跑,就可能读到尚未构造的父类状态,语义直接失控。
这也解释了一个经典面试题:父类构造器里读子类字段,读到什么?——零值。因为执行到父类 <init> 时,子类 <init> 里的字段初始化器还没跑,而第 5 站的零值初始化早已完成,所以子类字段此刻是 0 / null,不是"未定义"。
不会"泄漏",但会留下半成品。执行 invokespecial <init> 时,对象已经完整走完分配 + 零值 + 对象头(第 1 站的 ②③ 先于 ⑤),它实实在在占着堆内存。<init> 抛异常后:① 异常沿调用栈传播,局部变量不会被赋值(astore 没执行到);② 堆里这个对象没有任何引用指向它,成了垃圾,等待 GC 回收;③ 但构造器里已经执行过的副作用是真实的——资源已打开、事件已注册、静态计数器已加一。所以"构造器抛异常不泄漏对象"的完整说法是:对象会被 GC 回收,但副作用不会自动回滚,这是资源管理要格外小心的地方。
构造器还没执行完,this 就已经是一个"合法的引用"了——所以你在构造器里把 this 交给别人(注册监听器、赋值给静态字段、启动线程),等于把一个字段还是零值/未赋值完的半成品对象暴露给了别的线程。别的线程可能在你构造完成之前读到"半初始化"状态。正确姿势:构造器里不做任何发布动作,构造完成后再注册。锁与线程安全的细节见并发卷的《synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁》和《JMM 内存模型:happens-before 八条规则全解》。
最后把"字段被赋值两次"这件事钉死:第 5 站的零值初始化(JVM,内存层,保证"有值")→ 本阶段的 putfield(构造器,逻辑层,保证"值对")。零值是安全网,构造器赋值是覆盖——两次写入是分层设计的代价,不是冗余 bug。
new 的字节码真相:new(完成分配+零值+对象头,压引用)→ dup(复制引用,一份给 this、一份留给赋值)→ invokespecial(执行 <init>)。<init> 三段式:super()(必须第一句,JLS 8.8.7.1)→ 字段初始化器 + 初始化块(按源码顺序交织)→ 构造体。构造器抛异常:对象变垃圾被回收,但副作用不回滚;构造器里暴露 this = this 逸出,半成品对象泄露给其他线程。
限定词:"new 的对象一定在堆里"吗?
五个阶段讲完,该给"对象在堆里"这个认知打补丁了。面试官如果听你一路说"new 在堆上分配",最后一定会补一刀:
不严谨——这句话需要限定词。默认情况下(解释执行、C1 编译)确实如此:对象在堆(TLAB/Eden)分配。但 C2 编译器开启逃逸分析后,如果判定对象"不逃逸"(方法内创建、没被返回、没被存到静态字段/被其他线程访问),可以做两个优化:
- 标量替换(Scalar Replacement):把对象的字段拆成独立的标量变量(本地变量),"对象"不再作为一个整体被组装出来——堆上分配被彻底消除,GC 也没有目标。HotSpot 所谓"栈上分配"的效果,本质就是标量替换:不是把对象搬到栈上,而是让这个对象不再以对象形态存在。
- 锁消除(Lock Elision):对象是线程私有的(不逃逸),对它加的锁永远不会被竞争,编译器直接把锁去掉。
所以严谨的说法是:从 Java 语言和默认运行时视角看,new 出来的对象在堆里分配内存;但在 JIT(C2)+ 逃逸分析下,不逃逸的对象可以连堆分配都省掉(标量替换),锁也可以消除。面试时这句话的完整版本是:"对象默认分配在堆,这是语言语义的底线;JIT 可以在不改变语义的前提下,把不逃逸的对象'化整为零',消除堆分配——优化的是实现,不变的是语义。"
① 别说"JVM 会把对象分配到栈上"——HotSpot 没有真正的栈上分配,机制是标量替换(字段变成局部变量),说"栈上分配"会被追问到墙角。② 别把逃逸分析说成"一定生效"——它是 C2 的热方法优化,方法不热、对象逃逸(被 return、被存储、被线程共享)时都不生效,而且版本间优化策略会变。验证手段:JDK 8 可用 -XX:+UnlockDiagnosticVMOptions 加 -XX:+PrintEscapeAnalysis 打印分析结果(JDK 9+ 走统一 JIT 日志);标量替换与逃逸分析的完整机制见《JIT 编译优化:逃逸分析与标量替换》。
"new 一定在堆里"要加限定词:默认运行时 = 堆;C2 + 逃逸分析 + 对象不逃逸 = 标量替换消除堆分配、锁消除。机制是"化整为零",不是"搬到栈上"。这句话是区分"背过对象创建"和"理解对象创建"的分水岭。
总结:知识地图与面试模板
知识地图
本文全部知识点的结构(能默画出来就算内化):
- 五阶段链路:类加载检查 → 内存分配 → 零值初始化 → 设置对象头 → 执行 <init>。②③ 在分配同一瞬间完成(JVM 内部),⑤ 才执行用户逻辑;顺序是"先零值、后对象头"。
- 类加载检查:是"检查"不是"加载"——确认类已加载/链接/初始化,首次使用才走完整流程;new(非数组)是触发类初始化的主动引用;类元信息在方法区(Metaspace),Class 对象在堆里;数组类运行时生成。
- 分配策略:内存规整 → 指针碰撞(O(1));碎片 → 空闲列表(查表切块)。规整与否由收集算法决定(复制/整理 → 规整;标记-清除 → 碎片)。Eden 剩余不够 → 老年代 → Full GC → OOM。
- TLAB:每线程 Eden 私有缓冲区,线程内分配零同步;refill 时 CAS 竞争 + 旧 TLAB 剩余 = 浪费;大小自适应(TLABSize 手动固定),TLABWasteTarget 默认 1% 是浪费目标不是 Eden 占比;决策树:TLAB 够 → 用;不够但够开新 TLAB → refill;比新 TLAB 大 → 直接 Eden;Eden 不够 → 老年代。
- 零值初始化:字段 → 0 / null / false(boolean 实例字段 1B);动机:复用内存有脏数据 + 语言契约;零值是内存层安全网,构造器是逻辑层覆盖。
- 对象头:头 16B = Mark Word 8B + 类指针 4B + 填充 4B(压缩指针,堆 < 32GB,232×8B 上限);数组头 +4B 长度;Mark Word 五状态复用 8B(01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记),hash 惰性计算,age 默认 15 晋升,偏向锁 JDK 15 默认禁用 / JDK 17 移除选项;字段布局宽字段优先、父先于子、同宽按声明,JOL 实测为准(Object()=16B,int[8]=48B)。
- <init>:new → dup → invokespecial;三段式 super()(第一句)→ 字段初始化器 + 初始化块(源码顺序)→ 构造体;构造器抛异常对象变垃圾但副作用不回滚;this 逸出 = 暴露半成品对象。
- JIT 限定词:C2 + 逃逸分析 + 不逃逸 → 标量替换消除堆分配 + 锁消除;不是真正的栈上分配。
面试回答模板
"new 一个对象,JVM 走五个阶段:类加载检查——确认类已加载、链接、初始化,通常早就就绪;内存分配——内存规整用指针碰撞、碎片用空闲列表,多线程靠 TLAB 把竞争降频;零值初始化——字段先填 0 / null / false;设置对象头——Mark Word 加类指针,64 位压缩指针下头 16 字节;最后执行构造器——super 先行、字段初始化、构造体。前四步 JVM 自动完成,构造器才跑用户逻辑。"
30 秒版 + 三个"为什么":① 为什么 TLAB——CAS 重试无锁,但高频分配下所有线程挤同一根指针、缓存行反复弹跳;TLAB 给每线程一块 Eden 私有缓冲区,线程内零同步,竞争降频为"每个 TLAB 一次"的 refill,代价是可控浪费(TLABWasteTarget 默认 1%,注意这是浪费目标不是 Eden 占比);TLAB 大小自适应,不是固定值。② 为什么 Mark Word 只有 8 字节——五种状态(无锁/偏向/轻量级/重量级/GC 标记)复用同一块 8B,末 2 位锁标志区分;无锁态存 hash(惰性计算)+ 年龄,上锁后整块改成指向锁结构的指针——是"复用账本"不是"固定槽位"。③ 为什么字段先零值再赋值——堆内存复用会有脏数据,语言契约允许字段不赋值就读,JVM 必须在构造器(用户代码)之前保证内存处于安全状态;零值是安全网,构造器是覆盖。收尾加限定词:"默认对象分配在堆,但 C2 逃逸分析可以对不逃逸对象标量替换、彻底消除堆分配——机制是化整为零,不是栈上分配。"
参数速查
| 参数 | 默认值 | 作用 |
|---|---|---|
-XX:+UseTLAB | Server 模式 true | 启用线程本地分配缓冲区(生产 JVM 默认就有) |
-XX:TLABSize | 不设(自适应) | 手动固定 TLAB 大小(字节);默认由 JVM 按线程分配模式动态调整 |
-XX:TLABWasteTarget | 1 | TLAB 浪费目标:废弃剩余空间 ≤ TLAB 大小 × 1%(是浪费目标,不是 Eden 占比) |
-XX:ObjectAlignmentInBytes | 8 | 对象对齐粒度(字节);对象总大小必须是它的倍数,决定最小对象尺寸 16B |
-XX:+UseCompressedOops | true | 压缩对象引用为 4B;堆 < 32GB 时生效(232 × 8B 对齐) |
-XX:MaxTenuringThreshold | 15 | 晋升老年代的 GC 年龄阈值(age 存在 Mark Word 的 4 个位里) |
-XX:PretenureSizeThreshold | 0(关闭) | 超过该大小的对象直接分配老年代(仅 Serial / ParNew 生效) |
-XX:+PrintTLAB | false | JDK 8:GC 时打印 TLAB 统计(fills / waste / slowAllocs);JDK 9+ 用 -Xlog:gc+tlab |
高频追问速答
| 追问 | 答案要点 |
|---|---|
| new 出来的对象一定在堆里吗? | 默认运行时是;C2 + 逃逸分析下,不逃逸对象可被标量替换——拆成标量变量,堆分配彻底消除(不是"搬到栈上"),锁也可消除。优化的是实现,不变的是语义 |
| TLAB 多大?是固定的吗? | 不固定。默认自适应:JVM 按线程的对象大小分布动态调整;-XX:TLABSize 可手动固定;-XX:TLABWasteTarget 默认 1% 是"浪费目标"(废弃剩余 ≤ 1%),常被误说成"TLAB 是 Eden 的 1%"——错 |
| 为什么 Mark Word 只有 8 字节? | 状态复用:无锁/偏向存 hash(31b) + age(4b),轻量级/重量级整块改成指向锁结构的指针(62b),GC 时临时标记;末 2 位锁标志(01/00/10/11)区分状态。同一块 8B 按"当前使用者"整块改写,不是各字段固定槽位 |
| 两个对象 new 出来内存一定连续吗? | 不一定。同一线程 TLAB 内按分配顺序连续;跨线程各用各的 TLAB,地址取决于各自 TLAB 的位置;TLAB refill 后还换了区域。"连续"只在"同线程、同一 TLAB、先后分配"时成立 |
| 压缩指针怎么工作?32GB 上限怎么来的? | 引用存"堆内偏移 ÷ 对齐字节数"(8B 对齐 → 低 3 位恒 0 不存),32 位可表 232 个位置 × 8B = 32GB;读时左移 3 位恢复。对齐改 16B → 上限减半 16GB。这也是大堆服务把堆压在 31G 附近的原因 |
| 对象大小怎么算? | 对象头(普通对象 16B;数组 16B 含长度字段)+ 实例数据(字段按宽度分组布局:long/double 优先,父类先于子类,同宽按声明)+ 对齐填充(8B 倍数)。别手算——JOL 的 ClassLayout.parseInstance().toLayout() 实测为准:Object()=16B,int[8]=48B,{int;long}=24B |
一个 new = 五个阶段:查类 → 分空间 → 填零值 → 写头部 → 跑构造器。前四步是 JVM 的"出厂设置"(分配即安全:有确定的零值、有完整的对象头、有正确的类归属),第五步才是"用户装修"。掌握这条链路,对象在堆里的一生就看清了出生这一刻——下一篇我们接着看它的另一头:它怎么被判定为"死了"——引用计数为什么不行、可达性分析怎么标活对象,见《对象存活判定:引用计数 vs 可达性分析》。
Comments · 评论