Skip to content

04.1 - 对象创建过程

定位: 从 new 字节码指令到 JVM 堆上一个完整对象的 5 步流程 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 逐步骤讲解、各步骤 JVM 参数影响、"new 一个对象在 JVM 里发生了什么?"

一、这是什么?为什么需要它?

new 在 Java 里只是一个关键字。但在 JVM 层面,创建一个对象涉及类加载子系统、内存管理器、并发控制、编译器四个组件的协作。

为什么需要这么复杂? 因为对象创建必须保证:

  1. 类型安全 —— 不能创建未知类的对象(所以需要类加载检查)
  2. 线程安全 —— 多线程同时 new 对象不能出现数据竞争
  3. 内存正确性 —— 构造函数运行前所有字段必须有确定值(零值初始化)
  4. 性能可接受 —— JVM 使用 TLAB 等优化让对象分配接近栈分配的效率

Java 的对象创建和 C++ 有本质区别:C++ 的 new 只是单纯分配内存 + 构造函数调用;Java 则多了一层运行时类型体系的保障——类加载检查保证了每个对象都有合法的类型、零值初始化保证了 Java 程序员永远不会看到未初始化的字段(这是 Java 比 C++ 更安全的核心原因之一)。

二、原理拆解

2.1 步骤 1: 类加载检查

new 字节码指令


┌─ 在运行时常量池中查找类的符号引用 ──→ 未找到 → 抛出 NoClassDefFoundError

▼ (找到符号引用)
┌─ 检查该类是否已加载 / 解析 / 初始化 ──→ 已加载 → 跳过

▼ (未加载)
┌─ 执行类加载流程:
│  加载 → (验证 → 准备 → 解析) → 初始化
│  (详见 [[02.1-类加载生命周期]])


进入下一步: 分配内存

为什么第一步是类加载检查?

因为 JVM 需要确保对象的类型信息已经存在于方法区中。没有类元数据,就无法确定对象要占多大空间、有哪些字段、方法入口在哪里。

这也是 Java 中 newClass.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 → 0
  • long → 0L
  • reference → null
  • boolean → 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
└──────────────────────────────┘  └──────────────────────────────┘

为什么需要这步?

  1. 安全性:Java 语言规范要求对象的实例字段在使用前必须可预测。C 语言中局部变量/堆内存不初始化时是"脏数据"——Java 通过零值初始化从根本上杜绝了这种未定义行为
  2. 运行时依赖:零值初始化在构造函数之前完成,这样构造函数中可以依赖字段的默认值(即使没有显式赋值也能安全读取)
  3. 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> 是编译器生成的实例初始化方法,包含:

  1. super() 调用(默认或显式)
  2. 实例变量初始化(int x = 10 这种声明时赋值)
  3. 构造函数体代码
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>中赋值
    }
}

&lt;init&gt; 是 JVM 层面的概念。字节码中每个构造函数对应一个 &lt;init&gt; 方法,执行完 &lt;init&gt; 后对象才真正"可用"。

三、图解全景

                        ┌──────────────────────────┐
                        │   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) 可以展示对象在堆上的精确内存布局:

java
// 引入 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 下分配方式的差异

bash
# 使用 Serial GC (指针碰撞)
java -XX:+UseSerialGC -XX:+PrintGCDetails -version

# 使用 CMS GC (空闲列表)
java -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -version

CMS 已经不推荐使用(JDK 9 起标记为 deprecated),但这恰好印证了为什么 CMS 退场的一个重要原因:空闲列表分配比指针碰撞慢,而且内存碎片化导致无法使用指针碰撞。G1 和 ZGC 回归了"区域规整"的设计,本质上又能用指针碰撞了。

五、面试视角

追问答案要点
new 一个对象在 JVM 中发生什么?6 步:①类加载检查 ②内存分配 ③并发安全 ④零值初始化 ⑤对象头设置 ⑥&lt;init&gt; 执行
内存分配为什么要用两种方式?指针碰撞用于 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 收集器决定指针碰撞还是空闲列表
  • ← 返回 **对象分配索引**

Knowledge4J — Java 知识库