Lesson 25 · JVM 原理与调优
对象创建全过程:类加载检查 → TLAB → 内存分配 → 初始化
面试官:new Object() 在 JVM 内部到底做了什么?
面试官放下简历,问了一个看似"入门级"的问题:
80% 的候选人回答不超过两句——"在堆上分配内存,然后调用构造方法"。面试官已经在心里把你归到了会写代码但不懂底层那一档。
事实是,一个 new 指令从字节码到最终的对象落地,至少涉及五个关键阶段:类加载检查、内存分配策略选择(指针碰撞还是空闲列表?TLAB 还是直接分配?)、零值初始化、对象头设置、构造方法执行。每个阶段背后都藏着 JVM 的精心设计。
读完这篇文章,你将能够:
- 完整描述
new指令从字节码到内存的 5 步链路 - 解释指针碰撞与空闲列表的适用场景,以及 TLAB 如何解决并发分配问题
- 画出 64 位 JVM 下对象头的位级布局(含 Compressed Oops)
- 说出
<init>方法的执行顺序,并理解字段初始化器的编译行为
发车之前,先问自己:
- TLAB 分配失败后会怎么做?直接 Eden 分配还是直接老年代?
- 为什么 Java 对象的字段一定有默认值,而 C++ 没有?
- 对象头里的 Mark Word 在刚创建时存了什么?
带着问题,我们从字节码出发。
字节码层面:new 指令做了什么?
先看一段最简单的 Java 代码:
public class ObjectDemo {
public static void main(String[] args) {
Object obj = new Object();
}
}
用 javap -c ObjectDemo 反编译后,main 方法的字节码如下:
// main 方法字节码
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #3 // Method java/lang/Object."<init>":()V
7: astore_1
8: return
短短 5 条字节码,却隐藏了 JVM 内部的大量工作。我们把 new 指令的执行过程拆解为 5 个阶段:
| 阶段 | 动作 | 对应字节码 |
|---|---|---|
| ① 类加载检查 | 检查 java/lang/Object 是否已加载、链接、初始化 | new #2 |
| ② 分配内存 | 根据对象大小在堆上分配空间(指针碰撞 / 空闲列表) | new #2 |
| ③ 零值初始化 | 将所有字段设为默认值(int→0, reference→null) | new #2 |
| ④ 设置对象头 | 写入 Mark Word、Klass Pointer 等元信息 | new #2 |
| ⑤ 调用 <init> | 执行构造方法(字段初始化 → 初始化块 → 构造体) | invokespecial #3 |
注意 dup 指令——它复制栈顶的对象引用。一份留给 invokespecial 作为 this 参数消费,另一份留给后续的 astore_1 赋值给局部变量。这是 JVM 基于栈的执行模型的典型特征。
new 指令 ≠ 分配内存。它还包含类加载检查、零值初始化和对象头设置,这些都是 JVM 自动完成 的,不等 <init> 执行。内存分配:指针碰撞、空闲列表与 TLAB
对象创建的核心问题是:在堆上找到一块足够大的连续内存。JVM 根据当前使用的垃圾收集器,采用不同的分配策略。
3.1 指针碰撞(Pointer Bumping)
当堆内存是规整的——即已用内存和空闲内存各占一侧——JVM 只需移动一个指针就能完成分配。这个指针叫做 Allocation Pointer,指向可用内存的起始地址。
适用场景:Serial 和 ParNew 收集器。因为它们执行 GC 后会压缩存活对象,使堆内存保持规整。
3.2 空闲列表(Free List)
当堆内存不规整时——已用和空闲内存交错分布——指针碰撞就失效了。JVM 需要维护一张空闲列表,记录每块可用内存的位置和大小,分配时遍历列表找到足够大的块。
适用场景:CMS(Concurrent Mark Sweep) 收集器。CMS 采用标记-清除算法,不压缩堆,因此内存天然碎片化。
-XX:UseCMSCompactAtFullCollection 参数可以在 Full GC 时压缩堆,但代价是 STW 时间显著增加。G1 和 ZGC 虽然使用 Region 化内存管理,但每个 Region 内部仍然使用指针碰撞。3.3 TLAB:Thread Local Allocation Buffer
无论用哪种分配策略,对象分配都是高频操作。如果每次分配都加锁同步 Allocation Pointer,多线程环境下的性能将惨不忍睹。TLAB 就是为了解决这个问题。
核心思想:JVM 在 Eden 区为每个线程预分配一小块内存(默认约 32KB),线程在自己的 TLAB 内分配对象时无需任何同步。只有当 TLAB 用完时,才需要竞争 Eden 区的 Allocation Pointer 来获取新的 TLAB。
# 启用 TLAB(HotSpot 默认开启)
-XX:+UseTLAB
# 设置 TLAB 大小(默认约为 32KB,由 JVM 自动调整)
-XX:TLABSize=65536
# TLAB 分配失败时的行为:refill(重新分配 TLAB)还是直接在 Eden 分配
# 大对象会直接走 Eden 分配(超出 TLAB 容量的对象)
# 查看 TLAB 统计信息
-XX:+PrintTLAB
# 示例输出:
# Thread 0x00007f8a1c008800 tlab: fills 12 waste 2345 slow alloc 3
# fills = TLAB 用完后重新分配的次数
# waste = TLAB 中未使用就废弃的字节数
# slow alloc = TLAB 和 Eden 都分配失败的回退次数
TLAB 的分配流程:
| 步骤 | 操作 | 是否需要同步 |
|---|---|---|
| 1 | 尝试在当前线程的 TLAB 中分配 | ❌ 无需同步 |
| 2a | TLAB 空间足够 → 移动 TLAB 内指针,分配完成 | ❌ 无需同步 |
| 2b | TLAB 空间不足 → 对象大于 TLAB 剩余空间 | — |
| 3 | 大对象直接在 Eden 区分配(CAS 竞争 Allocation Pointer) | ✅ CAS 操作 |
| 4 | 小对象申请新的 TLAB(CAS 竞争),旧 TLAB 剩余空间归还 Eden | ✅ CAS 操作 |
对象头布局:Mark Word + Klass Pointer
内存分配完成后,JVM 要做的第一件事是设置对象头。在 64 位 HotSpot JVM 下,一个普通对象的内存布局如下:
4.1 Mark Word 详解
Mark Word 是对象头中最"热闹"的部分。刚创建的对象处于无锁态,Mark Word 存储的是:
// 64 bits Mark Word — 无锁态(刚创建的对象)
┌──────────────────────────────────────────────────────────────┐
│ unused (25 bits) │ hashCode (31 bits) │ age (4) │ 0 │ 01 │
└──────────────────────────────────────────────────────────────┘
// ↑ ↑ ↑
// GC分代年龄 偏向标志 锁标志(01=无锁/偏向)
关键细节:
- hashCode:首次调用
hashCode()时才计算并写入(惰性计算),并非创建时就生成 - age:GC 分代年龄,每经历一次 Minor GC 存活就 +1,达到阈值(默认 15)晋升老年代
- 锁标志位:最后 2 位标识锁状态(01=无锁/偏向,00=轻量级锁,10=重量级锁,11=GC 标记)
4.2 Compressed Oops
在 64 位 JVM 下,指针本应占 8 字节。但 Compressed Oops(-XX:+UseCompressedOops,默认开启)将对象引用压缩为 4 字节,条件:堆大小 < 32GB。这意味着对象头从 16 字节(8+8)缩减为 12 字节(8+4),再加上 4 字节对齐填充,总共仍是 16 字节——但节省了大量引用字段的空间。
零值初始化:为什么 Java 字段总有默认值?
对象头设置完成后,JVM 会将分配的内存空间(不含对象头)全部初始化为零值。这一步保证了 Java 对象的字段即使没有显式赋值,也有确定的默认值。
| 数据类型 | 默认值 | 底层表示 |
|---|---|---|
int / short / byte / char | 0 / '\u0000' | 全 0 位 |
long | 0L | 64 位全 0 |
float | 0.0f | IEEE 754 正零 |
double | 0.0d | IEEE 754 正零 |
boolean | false | 0(JVM 用 int 存储) |
reference | null | 全 0 位 |
这一步发生在 <init> 方法执行之前。这也是为什么在构造方法里,即使你还没给字段赋值,访问字段也不会报错——它们已经是零值了。
public class ZeroInit {
int a;
boolean flag;
String name;
public ZeroInit() {
// 此时字段已经是零值
System.out.println(a); // 输出: 0
System.out.println(flag); // 输出: false
System.out.println(name); // 输出: null
}
}
实现层面,HotSpot 对小型对象使用逐字清零(memset),对大型对象(如大数组)则可能使用更高效的批量清零指令。在 TLAB 场景下,分配新 TLAB 时就会对整块内存做零值初始化。
<init> 方法:构造函数的字节码真相
零值初始化完成后,JVM 调用 invokespecial 执行对象的 <init> 方法。这个方法由编译器自动生成,融合了三个部分:
- 父类构造方法调用(
super()) - 字段初始化器和实例初始化块(按源码出现顺序)
- 构造方法体
看一个综合示例:
public class InitOrder extends Base {
int x = 10; // ① 字段初始化器
int y;
{ y = 20; } // ② 实例初始化块
public InitOrder(int z) {
super(); // ③ 隐式/显式 super 调用
this.y = z; // ④ 构造方法体
System.out.println("done");
}
}
编译器生成的 <init> 字节码等价于:
// 编译器实际生成的 <init>(I)V 方法等价伪码:
public InitOrder(int z) {
// Step 1: super() — 必须最先执行
super();
// Step 2: 字段初始化器(按声明顺序)
this.x = 10;
// Step 3: 实例初始化块
this.y = 20;
// Step 4: 构造方法体(用户代码)
this.y = z;
System.out.println("done");
}
用 javap -p -c InitOrder 可以看到真实的字节码:
// InitOrder.<init>(int) 字节码
0: aload_0 // 加载 this
1: invokespecial #1 // super() → Base.<init>
4: aload_0 // 加载 this
5: bipush 10
7: putfield #2 // this.x = 10(字段初始化器)
10: aload_0
11: bipush 20
13: putfield #3 // this.y = 20(初始化块)
16: aload_0
17: iload_1 // 加载参数 z
18: putfield #3 // this.y = z(构造体)
21: getstatic #4 // System.out
24: ldc #5 // "done"
26: invokevirtual #6 // println("done")
29: return
因为父类构造方法可能初始化一些字段,子类的字段初始化器和构造体可能依赖这些字段。如果允许 super() 不放在第一行,就可能出现"子类代码访问了尚未初始化的父类状态"的问题。Java 语言规范(JLS §8.8.7.1)强制要求 super 必须是 <init> 的第一条语句。
总结:从 new 到对象落地的完整链路
让我们用一张完整的流程图回顾整个过程:
关键 JVM 参数速查
| 参数 | 默认值 | 说明 |
|---|---|---|
-XX:+UseTLAB | 开启 | 启用线程本地分配缓冲区 |
-XX:TLABSize=N | ~32KB | TLAB 大小(字节) |
-XX:+UseCompressedOops | 开启 | 压缩对象引用指针(堆 < 32GB 时) |
-XX:+PrintTLAB | 关闭 | 打印 TLAB 使用统计 |
-XX:PretenureSizeThreshold=N | 0(自适应) | 大于此值的对象直接分配在老年代 |
-XX:MaxTenuringThreshold=N | 15 | 对象晋升老年代的 GC 年龄阈值 |
new 指令触发 6 步链路——类加载检查 → 内存分配(指针碰撞/空闲列表,TLAB 优化并发)→ 零值初始化 → 设置对象头 → 执行 <init> 构造方法。面试官追问时,从分配策略、TLAB 机制、对象头布局三个维度展开即可。