一、一个对象背后发生了什么
在 Java 中创建一个对象只需要一行代码:MyClass obj = new MyClass();。但从 JVM 的角度来看,这行简单的代码背后,实际发生了一系列复杂的步骤。当 JVM 遇到 new 指令时,它不会直接跳到构造方法——在此之前,JVM 需要完成类加载检查、内存分配、零值初始化、对象头设置等一系列准备工作,最后才会执行你写的构造方法体。
理解这些步骤不仅仅是为了应付面试,更是为了深入理解 JVM 如何管理内存、为什么有些场景下对象创建会特别慢、以及如何通过 TLAB 和逃逸分析等机制优化对象创建的性能。
从 JVM 规范的角度,new 指令触发的对象创建过程可以分解为以下几个关键步骤:
- 类加载检查:检查
new指令的参数(指向常量池中一个类符号引用)是否能定位到一个已加载的类。 - 内存分配:在堆上为新对象分配一块大小确定的内存空间。
- 零值初始化:将分配到的内存空间初始化为零值(不包括对象头)。
- 对象头设置:设置 Mark Word 和 Klass Pointer。
- 构造方法执行:执行你写的
<init>方法(构造方法体)。
这五个步骤中,内存分配是性能关键路径,对象头设置涉及锁机制的初始化,而构造方法执行则是 Java 程序员最熟悉的部分。下面逐一深入。

二、类加载检查:确保类已经"就绪"
当 JVM 执行到 new 指令时,第一步不是分配内存,而是检查这个类是否已经加载。new 指令的操作数是一个指向运行时常量池中类符号引用的索引,JVM 通过这个索引找到对应的类,然后检查:
- 这个类是否已经被加载(Loading)?
- 是否已经通过验证(Verification)?
- 是否已经完成准备(Preparation)?
- 符号引用是否已经解析(Resolution)为直接引用?
- 类是否已经初始化(Initialization)——即静态变量赋值和静态代码块是否已经执行?
如果以上任何一个步骤没有完成,JVM 会先触发相应的类加载过程。这意味着,第一次创建某个类的对象时,创建耗时比后续创建要长得多,因为第一次包含了类加载的全部开销,而后续创建只需执行内存分配和对象初始化。
类加载的过程在 051 中已经介绍过,这里不再展开。需要记住的关键点是:类加载检查是对象创建的第一个屏障,它确保在创建对象之前,类的元数据已经完整地存在于方法区中,JVM 知道这个对象要分配多大内存、有哪些字段、构造方法是什么。
三、内存分配:从堆中"划一块地"
类加载检查通过后,JVM 就知道了新对象需要多少内存——这个大小在类加载阶段就已经确定,对于同一个类的所有实例,所需内存大小是相同的。接下来就是为新生对象在堆上分配内存。
分配方式:指针碰撞 vs 空闲列表
JVM 使用哪种分配方式,取决于堆内存是否规整——而堆是否规整又取决于使用的垃圾回收器是否支持压缩整理(Compact)。
指针碰撞(Bump the Pointer):如果堆内存是规整的——所有已使用的内存放在一边,空闲内存放在另一边,中间有一个指针作为分界指示器——那么分配内存就只是把指针向空闲方向移动一段与对象大小相等的距离。这种方式简单高效,适用于 Serial、ParNew、Parallel Scavenge 等带压缩整理功能的垃圾回收器。
空闲列表(Free List):如果堆内存不是规整的——已使用和空闲的内存交错分布——JVM 就必须维护一个"空闲列表",记录哪些内存块是可用的。分配时从列表中找一块足够大的空间分配给对象,然后更新列表。这种方式适用于 CMS 等基于标记-清除(Mark-Sweep)算法的垃圾回收器,因为标记-清除不会做内存压缩。

并发分配的线程安全问题
对象创建是高频操作,多线程同时分配对象时,修改指针碰撞的分界指针或更新空闲列表,都涉及线程安全问题。JVM 通过两种机制来保证并发分配的安全性:
CAS + 失败重试:使用 CAS(Compare And Swap)操作来原子地更新指针。如果 CAS 失败(说明另一个线程抢先修改了指针),就重试。这种方式在并发量不高时足够高效,但高并发下 CAS 竞争激烈会导致性能下降。
TLAB(Thread Local Allocation Buffer):这是 JVM 解决并发分配性能问题的核心方案。每个线程在 Eden 区预先分配一小块私有内存(TLAB),线程在自己的 TLAB 里分配对象时不需要任何同步操作——只需在 TLAB 内部做指针碰撞即可。只有当 TLAB 用完了,才需要去 Eden 区申请一块新的 TLAB,这个申请过程才需要同步。TLAB 默认开启,大小通常占 Eden 的 1%,可以通过 -XX:+UseTLAB 和 -XX:TLABSize 调整。

四、零值初始化:确保字段有确定的"起点"
内存分配完成后,JVM 会将分配到的内存空间(不包括对象头)全部初始化为零值。这一步确保了 Java 对象的实例字段在未显式赋值之前,就已经有了确定的默认值,而不是内存中残留的随机数据。
各种类型的零值如下:
int、short、byte、long→0float、double→0.0boolean→falsechar→'\u0000'- 引用类型 →
null
这一步是 JVM 自动完成的,不需要执行任何 Java 代码。它保证了即使你的构造方法没有显式初始化某个字段,这个字段的值也是一个确定的值(而不是垃圾数据)。这也是 Java 的"内存安全"特性的一个重要体现——在 C/C++ 中,未初始化的局部变量可能包含任意值,而 Java 通过零值初始化彻底消除了这种不确定性。
需要注意的是,零值初始化只发生一次,之后即使对象被 GC 回收后内存被重新分配给另一个对象,这块内存中的数据也会被再次零值初始化,上一个对象的数据不会"泄漏"到新对象中。
五、对象头设置:给对象"上户口"
零值初始化完成后,JVM 开始设置对象头。这一步包括两个关键操作:
设置 Mark Word:Mark Word 的初始状态取决于 JVM 的配置。在默认配置下(偏向锁延迟开启),Mark Word 初始化为无锁状态(标志位 001),hashCode 此时尚未生成(等到第一次调用 hashCode() 时才计算并写入 Mark Word),GC 分代年龄为 0。如果开启了偏向锁(-XX:+UseBiasedLocking),Mark Word 初始化为偏向锁状态(标志位 101),但偏向线程 ID 初始为 0,表示"匿名偏向"——还没有偏向任何线程,第一个获取锁的线程会成为偏向线程。
设置 Klass Pointer:将对象头中的类型指针指向方法区(元空间)中该类的元数据对象。这个指针让 JVM 能够在运行时通过任何一个对象找到它所属的类——getClass()、instanceof、方法分派等操作都依赖这个指针。
对象头设置完成后,从 JVM 的视角来看,这个对象已经是一个"合法"的对象了——它有完整的对象头,有自己的类元数据引用,可以正常参与 GC 和锁操作。但此时,对象的实例字段仍然是零值,因为构造方法还没有执行。

六、构造方法执行:程序员写的代码终于上场了
对象头设置完成后,JVM 执行 invokespecial 指令调用 <init> 方法——也就是我们写的构造方法。<init> 方法的执行顺序是:
- 调用父类构造方法:如果当前类有父类(除了
Object),首先调用父类的<init>方法。这是 Java 语言规范要求的,确保父类的字段先被正确初始化。 - 按声明顺序初始化实例字段:按照类中字段的声明顺序,依次执行字段的初始化表达式和实例初始化块(
{}代码块)。如果有多个实例初始化块,按照它们在源代码中出现的顺序执行。 - 执行构造方法体:执行构造方法中大括号
{}内的代码。
这段代码执行完毕后,对象才算真正"创建完成"——所有的实例字段都已经被赋上了程序员指定的值(或保持了零值,如果程序员没有显式赋值的话)。
从 JVM 字节码的角度来看,<init> 方法并不是一个普通的 Java 方法,它是编译器将构造方法、实例初始化块和字段初始化表达式合并后生成的一个特殊方法。如果你用 javap -v 反编译一个类,可以看到 <init> 方法的字节码,里面包含了上述所有操作。

七、面试追问链
从"Java 中 new 一个对象经历了哪些步骤"这个基础问题出发,逐层追问:第一步类加载检查具体检查什么如果类未加载怎么办、内存分配时指针碰撞和空闲列表的区别是什么什么情况下用哪种、TLAB 如何解决并发分配的性能问题、零值初始化保证了什么为什么 Java 不需要像 C++ 那样手动初始化、对象头在创建时设置什么内容 Mark Word 初始状态是什么和偏向锁有什么关系、构造方法 的执行顺序是什么父类构造方法和实例初始化块谁先执行、如果类中有静态字段静态字段的赋值是在对象创建时做的吗、对象创建过程中哪些步骤是最耗时的如何进行优化。这条链覆盖了面试官在"对象创建过程"这个话题上的核心考察点。

延伸阅读
- 《Java 虚拟机规范》(Java SE 21 Edition)第 4.10.2 节(类实例创建)和第 6.3 节(new 指令),理解 JVM 规范层面的对象创建定义
- 第 052 篇「Java 对象内存布局」详细介绍了对象头、实例数据和对齐填充的结构,可与本篇对象头设置部分对照阅读
- 「04 JVM 原理与调优」系列下一篇(054)将深入「对象访问与引用类型:句柄/直接指针以及强、软、弱、虚引用」
问答(不计入阅读时长)
Q1:从 JVM 执行引擎的角度来看,**new**指令和**invokespecial**指令分别负责对象创建的哪个阶段?两者的执行顺序能否颠倒?
A:new 指令负责内存分配、零值初始化和对象头设置,运行结束后,一个"空白"但合法的对象就已经在堆上存在了。invokespecial 指令负责调用 <init> 方法(构造方法),执行字段初始化和构造方法体。两者的执行顺序不能颠倒——必须先有对象,才能调用它的构造方法。如果先执行 invokespecial,操作数栈上还没有对象引用,JVM 会直接抛出 VerifyError。字节码层面,一个典型的 new MyClass() 会编译为三条指令:new(分配对象,引用压入栈顶)→ dup(复制栈顶引用)→ invokespecial <init>(使用一个引用调用构造方法,另一个引用留给赋值语句)。
Q2:指针碰撞分配和空闲列表分配,哪一种更快?为什么生产环境中更常见的却是 TLAB + 指针碰撞的组合?
A:指针碰撞明显更快——它只需要一次指针加法操作,而空闲列表需要遍历链表查找合适大小的空闲块,时间复杂度从 O(1) 变成 O(n)。但指针碰撞要求堆内存规整(已用和空闲内存连续分布),这只有在使用带压缩整理功能的 GC(如 Serial、Parallel)时才能保证。CMS 等标记-清除 GC 不压缩内存,只能用空闲列表。生产环境中更常见 TLAB + 指针碰撞的组合,是因为 TLAB 把"线程通过空闲列表从不规整堆中分配"的成本分摊到了"整个 TLAB 用完后一次性分配",而 TLAB 内部的分配就变成了简单的指针碰撞——这就是"把偶尔的复杂操作分摊到大量简单操作上"的经典优化思路。
Q3:如果 JVM 关闭了 TLAB(**-XX:-UseTLAB**),多线程同时分配对象时会发生什么?性能会有多大影响?
A:关闭 TLAB 后,每个 new 操作都需要直接去 Eden 区通过 CAS 操作更新分配指针。当线程数较少时,CAS 竞争不激烈,性能影响可能只有 5-10%;但当线程数增多(比如 32 线程以上),CAS 竞争会急剧上升,每次 CAS 失败都需要重试,CPU 自旋等待时间显著增加,对象分配可能成为瓶颈,性能下降可达 30-50%。TPC-C 等基准测试中,关闭 TLAB 后高并发场景的吞吐量下降非常明显。这也是为什么 TLAB 默认开启且生产环境几乎不需要关闭它的原因。
Q4:零值初始化保证了 Java 对象字段在未赋值前一定是零值,那为什么 Java 规范还要求局部变量必须显式初始化才能使用?
A:这是两个不同的层面。零值初始化是 JVM 在堆上分配对象内存时对对象实例字段(成员变量)的操作,对象在堆上分配后,所有的实例字段都被置为零值。但局部变量存在于栈帧的局部变量表中,它们不是"对象字段",不受零值初始化保护。JVM 规范对局部变量没有零值初始化要求——局部变量表的 Slot 可能包含之前其他方法调用残留的数据。Java 编译器通过"确定赋值"(Definite Assignment)分析来强制要求局部变量在使用前必须显式赋值,否则编译报错。这是编译器层面的安全保证,而不是 JVM 运行时的保证。成员变量(实例字段和静态字段)有默认零值,局部变量没有——这是面试中常见的区分点。
Q5:对象创建时,静态字段的赋值是在什么时候完成的?是在创建第一个对象时做的吗?
A:不是。静态字段的赋值发生在类初始化阶段(<clinit> 方法),这是在类加载的最后一个步骤完成的,不是在对象创建时做的。类初始化只执行一次——当类被首次主动使用时(如 new、访问静态字段、调用静态方法、反射等),JVM 会先确保类已经初始化,然后再执行对象创建。所以,静态字段的赋值发生在第一个对象创建之前,而不是在对象创建时。如果只访问静态字段而不创建对象,静态字段也会被初始化。这个区别是面试中高频考点:<clinit>(类初始化)和 <init>(对象初始化)是两个完全不同的方法。
Q6:为什么对象创建时是先做零值初始化再设置对象头,而不是反过来?
A:零值初始化和对象头设置的顺序是 JVM 实现层面的设计选择。先做零值初始化再设置对象头,是为了保证在对象头设置完成(对象变得"可见")之前,对象的所有字段已经被清零。如果反过来,先设置对象头,那么在零值初始化完成之前,如果另一个线程恰好看到了这个对象(比如通过 GC 遍历),它会看到对象头已经有了,但字段中包含的是随机垃圾数据——这会导致严重的内存安全问题。先清零再设置对象头,确保对象在任何时刻被观察到时,要么不存在(对象头未设置),要么字段已经是确定的零值。
Q7:**<init>**方法中,如果父类构造方法中调用了被子类重写的方法,会发生什么?为什么这是 Java 编程中的一个经典陷阱?
A:这会导致子类重写的方法在子类字段尚未初始化完成时被调用。因为父类构造方法先执行,如果父类构造方法中调用了某个方法,而子类重写了这个方法,那么多态机制会让调用转到子类的重写方法。此时子类的 <init> 方法还没执行到子类字段的初始化,子类字段还是零值(null、0 等),而不是期望的构造参数或初始化值。这会导致 NPE 或逻辑错误,而且很难排查。Java 语言规范明确警告不要在构造方法中调用可被重写的方法。Effective Java 第 19 条也专门讨论了这个问题。面试官问这个问题,是想考察你是否理解对象初始化顺序和多态之间的交互陷阱。
Q8:如果对象创建时堆内存不足,JVM 会立刻抛出 OOM 吗?还是会在 OOM 之前尝试做一些补救措施?
A:JVM 不会立刻抛出 OOM。在真正抛出 OutOfMemoryError 之前,JVM 会先尝试以下步骤:(1)触发一次 Minor GC,回收新生代中的垃圾对象,看是否能释放足够空间;(2)如果 Minor GC 后仍然不够,且对象较大(超过 -XX:PretenureSizeThreshold),会尝试直接在老年代分配;(3)如果老年代空间也不足,且允许 GC,则触发一次 Full GC;(4)如果 Full GC 后仍然无法分配,才会抛出 OutOfMemoryError: Java heap space。如果堆内存本身已经达到 -Xmx 上限,即使 Full GC 也无法释放足够空间,OOM 就不可避免了。这也是为什么生产环境中看到 OOM 时,通常意味着要么堆内存确实不够用(需要调大 -Xmx),要么存在内存泄漏(对象无法被 GC 回收)。