Java 面试笔记III · 02 / 14

Lesson 25 · JVM 原理与调优

对象创建全过程:类加载检查 → TLAB → 内存分配 → 初始化

高级·🔥 极高·#JVM·#内存

第 1 站

面试官:new Object() 在 JVM 内部到底做了什么?

面试官放下简历,问了一个看似"入门级"的问题:

"new Object() 这行代码执行时,JVM 内部经历了哪些步骤?"

80% 的候选人回答不超过两句——"在堆上分配内存,然后调用构造方法"。面试官已经在心里把你归到了会写代码但不懂底层那一档。

事实是,一个 new 指令从字节码到最终的对象落地,至少涉及五个关键阶段:类加载检查、内存分配策略选择(指针碰撞还是空闲列表?TLAB 还是直接分配?)、零值初始化、对象头设置、构造方法执行。每个阶段背后都藏着 JVM 的精心设计。

读完这篇文章,你将能够:

  • 完整描述 new 指令从字节码到内存的 5 步链路
  • 解释指针碰撞空闲列表的适用场景,以及 TLAB 如何解决并发分配问题
  • 画出 64 位 JVM 下对象头的位级布局(含 Compressed Oops)
  • 说出 <init> 方法的执行顺序,并理解字段初始化器的编译行为

发车之前,先问自己:

  • TLAB 分配失败后会怎么做?直接 Eden 分配还是直接老年代?
  • 为什么 Java 对象的字段一定有默认值,而 C++ 没有?
  • 对象头里的 Mark Word 在刚创建时存了什么?

带着问题,我们从字节码出发。

第 2 站

字节码层面:new 指令做了什么?

先看一段最简单的 Java 代码:

ObjectDemo.java
public class ObjectDemo {
    public static void main(String[] args) {
        Object obj = new Object();
    }
}

javap -c ObjectDemo 反编译后,main 方法的字节码如下:

ObjectDemo.class(javap -c 反编译)
// 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> 执行。
第 3 站

内存分配:指针碰撞、空闲列表与 TLAB

对象创建的核心问题是:在堆上找到一块足够大的连续内存。JVM 根据当前使用的垃圾收集器,采用不同的分配策略。

3.1 指针碰撞(Pointer Bumping)

当堆内存是规整的——即已用内存和空闲内存各占一侧——JVM 只需移动一个指针就能完成分配。这个指针叫做 Allocation Pointer,指向可用内存的起始地址。

指针碰撞(Pointer Bumping) 已用内存 空闲内存 Allocation Pointer 新对象 32 bytes 指针右移 = 分配完成
图 1 指针碰撞:移动指针即完成分配,Serial / ParNew 收集器默认使用

适用场景:SerialParNew 收集器。因为它们执行 GC 后会压缩存活对象,使堆内存保持规整。

3.2 空闲列表(Free List)

当堆内存不规整时——已用和空闲内存交错分布——指针碰撞就失效了。JVM 需要维护一张空闲列表,记录每块可用内存的位置和大小,分配时遍历列表找到足够大的块。

适用场景:CMS(Concurrent Mark Sweep) 收集器。CMS 采用标记-清除算法,不压缩堆,因此内存天然碎片化。

深入一步 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 相关 JVM 参数
# 启用 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 中分配❌ 无需同步
2aTLAB 空间足够 → 移动 TLAB 内指针,分配完成❌ 无需同步
2bTLAB 空间不足 → 对象大于 TLAB 剩余空间
3大对象直接在 Eden 区分配(CAS 竞争 Allocation Pointer)✅ CAS 操作
4小对象申请新的 TLAB(CAS 竞争),旧 TLAB 剩余空间归还 Eden✅ CAS 操作
面试要点 被问到"对象内存分配"时,先说分配策略(指针碰撞 vs 空闲列表),再说并发优化(TLAB),最后提一句大对象直接走 Eden 分配。三层递进,展示你的理解深度。
第 4 站

对象头布局:Mark Word + Klass Pointer

内存分配完成后,JVM 要做的第一件事是设置对象头。在 64 位 HotSpot JVM 下,一个普通对象的内存布局如下:

64 位 JVM 对象内存布局(开启 Compressed Oops) 一个普通 Java 对象 对象头(Object Header) Mark Word(8 字节 = 64 bits) 锁状态 | hashCode | GC 年龄 | 偏向线程 ID lock(2) biased_lock(1)+thread(54) epoch(2) age(4) hash(31) Klass Ptr 4 字节(压缩) 对齐填充 Padding(4 字节,保证对象头 8 字节对齐) 实例数据(Instance Data) 各字段值:int(4B), long(8B), reference(4B/8B) ... 注意:开启 Compressed Oops 后,引用占 4 字节而非 8 字节 对齐填充(Padding) HotSpot 要求对象大小是 8 字节的整数倍 ← 8 bytes ← 4 bytes ← 可变
图 2 64 位 JVM 对象内存布局(Compressed Oops 开启时,Klass Pointer 压缩为 4 字节)

4.1 Mark Word 详解

Mark Word 是对象头中最"热闹"的部分。刚创建的对象处于无锁态,Mark Word 存储的是:

Mark Word 位布局(64 位 JVM,无锁态)
// 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 字节——但节省了大量引用字段的空间。

对象头大小 = Mark Word (8B) + Klass Pointer (4B 压缩 / 8B 未压缩) + Padding (保证 8B 对齐)
第 5 站

零值初始化:为什么 Java 字段总有默认值?

对象头设置完成后,JVM 会将分配的内存空间(不含对象头)全部初始化为零值。这一步保证了 Java 对象的字段即使没有显式赋值,也有确定的默认值。

数据类型默认值底层表示
int / short / byte / char0 / '\u0000'全 0 位
long0L64 位全 0
float0.0fIEEE 754 正零
double0.0dIEEE 754 正零
booleanfalse0(JVM 用 int 存储)
referencenull全 0 位

这一步发生在 <init> 方法执行之前。这也是为什么在构造方法里,即使你还没给字段赋值,访问字段也不会报错——它们已经是零值了。

ZeroInit.java — 验证零值初始化
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
    }
}
对比 C/C++ C/C++ 的局部变量和堆分配对象(malloc)不会自动初始化,访问未初始化变量会得到随机值。Java 选择在 JVM 层面强制零值初始化,牺牲少量性能换来了内存安全——你永远不可能读到一块"脏"内存。

实现层面,HotSpot 对小型对象使用逐字清零(memset),对大型对象(如大数组)则可能使用更高效的批量清零指令。在 TLAB 场景下,分配新 TLAB 时就会对整块内存做零值初始化。

第 6 站

<init> 方法:构造函数的字节码真相

零值初始化完成后,JVM 调用 invokespecial 执行对象的 <init> 方法。这个方法由编译器自动生成,融合了三个部分:

  1. 父类构造方法调用(super()
  2. 字段初始化器实例初始化块(按源码出现顺序)
  3. 构造方法体

看一个综合示例:

InitOrder.java
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> 等价代码
// 编译器实际生成的 <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 可以看到真实的字节码:

javap 输出(<init>(I)V)
// 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() 必须在第一行?

因为父类构造方法可能初始化一些字段,子类的字段初始化器和构造体可能依赖这些字段。如果允许 super() 不放在第一行,就可能出现"子类代码访问了尚未初始化的父类状态"的问题。Java 语言规范(JLS §8.8.7.1)强制要求 super 必须是 <init> 的第一条语句

面试要点 <init> 的执行顺序:super() → 字段初始化器 → 实例初始化块 → 构造方法体。字段初始化器和初始化块按源码顺序交织执行,不是先全部字段再全部块。
第 7 站

总结:从 new 到对象落地的完整链路

让我们用一张完整的流程图回顾整个过程:

对象创建完整流程 ① JVM 遇到 new 指令 ② 类加载检查 检查目标类是否已 加载 → 链接 → 初始化 ③ 分配内存 指针碰撞 Serial / ParNew(规整堆) 空闲列表 CMS(碎片化堆) TLAB 优化:线程私有缓冲区分配 → 无需同步 → 大对象直接 Eden 分配 ④ 零值初始化 int→0 reference→null boolean→false ⑤ 设置对象头(Mark Word + Klass Pointer) 写入锁状态标志、GC 年龄、类元数据指针 ⑥ 执行 <init>: super() → 字段初始化 → 初始化块 → 构造体
图 3 从 new 指令到对象落地的完整 6 步链路

关键 JVM 参数速查

参数默认值说明
-XX:+UseTLAB开启启用线程本地分配缓冲区
-XX:TLABSize=N~32KBTLAB 大小(字节)
-XX:+UseCompressedOops开启压缩对象引用指针(堆 < 32GB 时)
-XX:+PrintTLAB关闭打印 TLAB 使用统计
-XX:PretenureSizeThreshold=N0(自适应)大于此值的对象直接分配在老年代
-XX:MaxTenuringThreshold=N15对象晋升老年代的 GC 年龄阈值
一句话总结:new 指令触发 6 步链路——类加载检查 → 内存分配(指针碰撞/空闲列表,TLAB 优化并发)→ 零值初始化 → 设置对象头 → 执行 <init> 构造方法。面试官追问时,从分配策略、TLAB 机制、对象头布局三个维度展开即可。