04.1 - 对象创建过程
定位: 从
new字节码指令到 JVM 堆上一个完整对象的 5 步流程 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 逐步骤讲解、各步骤 JVM 参数影响、"new 一个对象在 JVM 里发生了什么?"
一、这是什么?为什么需要它?
new 在 Java 里只是一个关键字。但在 JVM 层面,创建一个对象涉及类加载子系统、内存管理器、并发控制、编译器四个组件的协作。
为什么需要这么复杂? 因为对象创建必须保证:
- 类型安全 —— 不能创建未知类的对象(所以需要类加载检查)
- 线程安全 —— 多线程同时
new对象不能出现数据竞争 - 内存正确性 —— 构造函数运行前所有字段必须有确定值(零值初始化)
- 性能可接受 —— JVM 使用 TLAB 等优化让对象分配接近栈分配的效率
Java 的对象创建和 C++ 有本质区别:C++ 的 new 只是单纯分配内存 + 构造函数调用;Java 则多了一层运行时类型体系的保障——类加载检查保证了每个对象都有合法的类型、零值初始化保证了 Java 程序员永远不会看到未初始化的字段(这是 Java 比 C++ 更安全的核心原因之一)。
二、原理拆解
2.1 步骤 1: 类加载检查
new 字节码指令
│
▼
┌─ 在运行时常量池中查找类的符号引用 ──→ 未找到 → 抛出 NoClassDefFoundError
│
▼ (找到符号引用)
┌─ 检查该类是否已加载 / 解析 / 初始化 ──→ 已加载 → 跳过
│
▼ (未加载)
┌─ 执行类加载流程:
│ 加载 → (验证 → 准备 → 解析) → 初始化
│ (详见 [[02.1-类加载生命周期]])
│
▼
进入下一步: 分配内存为什么第一步是类加载检查?
因为 JVM 需要确保对象的类型信息已经存在于方法区中。没有类元数据,就无法确定对象要占多大空间、有哪些字段、方法入口在哪里。
这也是 Java 中 new 和 Class.forName() 的不同之处:new 隐含了"先加载类再创建对象"的语义,而反射可以只加载类不创建对象。
2.2 步骤 2: 分配内存
确定对象所需内存大小(类元数据中已记录)后,JVM 需要在堆中分配一块连续内存。
有两种分配方式,取决于堆内存的规整程度:
指针碰撞 (Bump the Pointer): 空闲列表 (Free List):
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ 已分配对象 │ 空闲空间 │ │ 已│空闲│已│空闲│ 已│空闲 │
│ ↑ │ │ 分 分 分 │
│ 分配指针(当前边界) │ │ 配 配 配 │
└─────────────────────────────┘ └─────────────────────────────┘
分配 = 指针向后移动对象大小 分配 = 从空闲列表找足够大的块
仅需 1 条指令 (指针加法) 需遍历空闲列表 O(n) 复杂度- 指针碰撞:堆是规整的(已分配对象在一边,空闲在另一边),只需移动"边界指针"。对应 Serial、ParNew 等带压缩整理的 GC 收集器
- 空闲列表:堆是不规整的(已分配和空闲交错),需要维护一个空闲块链表。对应 CMS(基于 Mark-Sweep)等不压缩整理的 GC 收集器
WHY 两种方式? 堆的规整程度完全取决于 GC 收集器是否压缩整理 (Compact)。Mark-Compact 类 GC 会把存活对象推到一端,另一端是连续空闲空间 → 指针碰撞。Mark-Sweep 类 GC 不移动对象 → 碎片化 → 空闲列表。
2.3 步骤 3: 并发分配的安全保障
多线程同时 new 对象时,如果都操作同一个分配指针或空闲列表,会产生竞态条件。JVM 有两种解决方案:
方案 A: CAS + 失败重试 (乐观锁)
┌─────────────────────────────────────────┐
│ Thread A: CAS(指针, 指针+sizeA) 成功 │
│ Thread B: CAS(指针, 指针+sizeB) 失败 │
│ → 重试: CAS(新指针, 新指针+sizeB)│
└─────────────────────────────────────────┘
优点: 不需要额外内存
缺点: 高并发下 CAS 冲突频繁 → 自旋浪费 CPU
方案 B: TLAB (Thread Local Allocation Buffer)
┌─────────────────────────────────────────┐
│ Eden │
│ ┌──────────┬──────────┬───────────────┐ │
│ │ TLAB T1 │ TLAB T2 │ 共享区域 │ │
│ │ (线程1) │ (线程2) │ (大对象等) │ │
│ └──────────┴──────────┴───────────────┘ │
│ │
│ T1 在自己 TLAB 内: 指针 bump (零同步) │
│ T2 在自己 TLAB 内: 指针 bump (零同步) │
│ 只有 TLAB 耗尽才 CAS 申请新 TLAB │
└─────────────────────────────────────────┘
优点: 绝大多数分配无竞争
缺点: TLAB 碎片浪费 (小空间不足时)TLAB 是首选方案(默认开启,-XX:+UseTLAB)。只有 TLAB 用尽且剩余空间不足以分配当前对象时,才会退回 CAS 方案。
详见 **分配策略** 中的 TLAB 深入分析。
2.4 步骤 4: 初始化零值
内存分配完成后,JVM 将分配到的内存空间全部初始化为零值:
int→ 0long→ 0Lreference→ nullboolean→ false
分配后 (原始内存可能有残留数据): 零值初始化后:
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ MarkWord: 0xDEADBEEF... │ │ MarkWord: 0x00000000... │ (待设置)
│ int field: 0x7A43B19C │ │ int field: 0x00000000 │ ← 0
│ ref field: 0xFFEEDDCC... │ │ ref field: 0x00000000... │ ← null
│ long field: 0x123456789ABC │ │ long field: 0x00000000... │ ← 0L
└──────────────────────────────┘ └──────────────────────────────┘为什么需要这步?
- 安全性:Java 语言规范要求对象的实例字段在使用前必须可预测。C 语言中局部变量/堆内存不初始化时是"脏数据"——Java 通过零值初始化从根本上杜绝了这种未定义行为
- 运行时依赖:零值初始化在构造函数之前完成,这样构造函数中可以依赖字段的默认值(即使没有显式赋值也能安全读取)
- GC 辅助:引用类型字段初始为 null,GC 在扫描对象时不会因为脏数据误以为某个引用指向有效对象
零值初始化不意味着"不需要构造函数"。它只是把字段设为语言层面的零点。如果业务上需要特定初始值(如
count = 10),仍然需要在构造函数或字段声明处赋值。
2.5 步骤 5: 设置对象头
对象头(Object Header)是每个 Java 对象开头的一小段元数据,包含:
- MarkWord:存储 GC 年龄、锁状态、identity hashcode 等运行时信息
- Klass Pointer:指向方法区中该对象的类元数据(Class Metadata)
- 数组长度(仅数组对象):记录数组元素个数
对象头 (64位 JVM, 未开启压缩指针):
┌────────────────────────────────────────────────┐
│ MarkWord (8 bytes / 64 bits) │
│ ┌──────────────────────────────────────────┐ │
│ │ GC 年龄 | 锁状态 | hashCode | 偏向线程ID │ │
│ └──────────────────────────────────────────┘ │
│ Klass Pointer (8 bytes, 未压缩) │
│ ┌──────────────────────────────────────────┐ │
│ │ → 指向方法区 (Metaspace) 中的类元数据 │ │
│ └──────────────────────────────────────────┘ │
│ 数组长度 (仅数组对象, 4 bytes) │
└────────────────────────────────────────────────┘详见 **对象内存布局** 中 MarkWord 各字段的详细位分布。
2.6 步骤 6: 执行 <init> 构造函数
这是开发者唯一能直接控制的步骤。<init> 是编译器生成的实例初始化方法,包含:
super()调用(默认或显式)- 实例变量初始化(
int x = 10这种声明时赋值) - 构造函数体代码
class Person {
String name; // 步骤 3 零值: null
int age = 18; // 步骤 3 零值: 0 → <init>中: 18
final double PI = 3.14; // 步骤 3 零值: 0.0 → <init>中: 3.14
Person(String name) {
this.name = name; // <init>中赋值
}
}
<init>是 JVM 层面的概念。字节码中每个构造函数对应一个<init>方法,执行完<init>后对象才真正"可用"。
三、图解全景
┌──────────────────────────┐
│ new 字节码指令触发 │
└──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ① 类加载检查 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 检查常量池符号引用 → 检查类是否已加载/解析/初始化 │ │
│ │ ↓ │ │
│ │ 如果未加载: 跳转到类加载子系统 → 加载 →(验证→准备→解析)→初始化 │ │
│ │ ↓ │ │
│ │ 类已可用: 进入下一步 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ② 分配内存 (两种方式, 取决于 GC 收集器) │ │
│ │ ┌────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ GC 带压缩整理 │ │ GC 不带压缩整理 (CMS) │ │ │
│ │ │ → 指针碰撞 │ │ → 空闲列表 │ │ │
│ │ │ 一步指针移动即可 │ │ 遍历链表找合适空闲块 │ │ │
│ │ └────────────────────┘ └────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ③ 并发安全: TLAB (首选) 或 CAS + 失败重试 (后备) │ │
│ │ TLAB 内 → 无锁指针 bump │ │
│ │ TLAB 外 → CAS 竞争分配指针 / 空闲列表 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ④ 零值初始化: 整个内存区域清 0 │ │
│ │ int=0, long=0L, ref=null, boolean=false │ │
│ │ (保证构造函数运行时字段值可预测) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ⑤ 设置对象头 │ │
│ │ MarkWord: GC age=0, 无锁状态, hashCode 未计算 │ │
│ │ KlassPointer: 指向方法区类元数据 │ │
│ │ 数组长度 (仅数组对象) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ⑥ 执行 <init> 构造函数 │ │
│ │ super() → 实例变量初始化 → 构造函数体 │ │
│ │ (开发者唯一可控步骤) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│
▼
┌──────────────────────────┐
│ 对象创建完成, reference │
│ 指向堆上的对象 │
└──────────────────────────┘四、实战验证
验证 1: JOL 验证对象创建后的内存状态
JOL (Java Object Layout) 可以展示对象在堆上的精确内存布局:
// 引入 JOL 依赖: org.openjdk.jol:jol-core
import org.openjdk.jol.info.ClassLayout;
public class ObjectCreationLayout {
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}输出(64位 JVM, 未开启压缩指针):
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000000 (指向 Object 类的元数据)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space loss: 4 bytes (padding)- MarkWord:
0x0000000000000001— GC age=0, 无锁, hashCode 未计算 - KlassPointer: 指向
java.lang.Object的类元数据 - Padding: 4 字节对齐到 8 的倍数
验证 2: 不同 GC 下分配方式的差异
# 使用 Serial GC (指针碰撞)
java -XX:+UseSerialGC -XX:+PrintGCDetails -version
# 使用 CMS GC (空闲列表)
java -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -versionCMS 已经不推荐使用(JDK 9 起标记为 deprecated),但这恰好印证了为什么 CMS 退场的一个重要原因:空闲列表分配比指针碰撞慢,而且内存碎片化导致无法使用指针碰撞。G1 和 ZGC 回归了"区域规整"的设计,本质上又能用指针碰撞了。
五、面试视角
| 追问 | 答案要点 |
|---|---|
| new 一个对象在 JVM 中发生什么? | 6 步:①类加载检查 ②内存分配 ③并发安全 ④零值初始化 ⑤对象头设置 ⑥<init> 执行 |
| 内存分配为什么要用两种方式? | 指针碰撞用于 GC 带压缩整理的场景(Serial/ParNew);空闲列表用于 GC 不压缩整理的场景(CMS)。堆的规整程度取决于 GC。 |
| TLAB 和 CAS 有什么区别? | TLAB 是 Eden 中线程私有的分配缓冲区,TLAB 内零同步;CAS 是后备方案,用于 TLAB 外或 TLAB 耗尽时 |
| 零值初始化不是也初始化了吗,为什么还要构造函数? | 零值初始化只设语言层面的默认值(0/null/false)。业务语义的初始化(count=10、name="unknown")需要在构造函数或字段声明处赋值 |
| 对象头里存的 Klass Pointer 指向哪里? | 指向方法区(Metaspace)中该类的 Class 元数据。通过 Klass Pointer,JVM 才能知道这个对象属于什么类型、有哪些方法可调用 |
为什么 new 前类可能还没加载? | 类在 Java 中是"按需加载"的。JVM 只在第一次主动使用时才加载类(懒加载)。new 是最常见的主动引用,触发类加载。 |
| 创建数组对象和普通对象有什么不同? | 数组对象头多了一个 4 字节的 length 字段。数组的类([I、[Ljava.lang.Object; 等)由 JVM 动态生成。 |
📚 相关链接
- **类加载生命周期** — 对象创建前的类加载流程
- **对象内存布局** — 对象头设置后的完整内存布局
- **分配策略** — TLAB 和栈上分配的深入分析
- **Java堆** — 堆分代结构——对象分配的目标地址
- **垃圾收集器详解** — GC 收集器决定指针碰撞还是空闲列表
- ← 返回 **对象分配索引**