对象的创建全过程
引言
在 Java 中,我们每天都在用new关键字创建对象,一句Object obj = new Object()背后,JVM 究竟做了哪些工作?对象的创建并非简单地"分配一块内存然后填上数据",它涉及类加载、内存分配、并发安全、内存初始化、对象头设置以及构造方法执行等多个环节。
理解对象的创建全过程,是深入 JVM 内存模型的起点,也是理解后续 GC、锁优化、内存对齐等专题的基础。本篇将以 HotSpot JVM 为例,拆解对象从new指令到可用对象的完整流程。
一、对象创建的五步流程总览
当 JVM 遇到new字节码指令时,大致会经过以下五个步骤:
+------------------------------------------------------------------+ | new 指令执行流程 | +------------------------------------------------------------------+ | | | 1. 类加载检查 | | 常量池中能否找到类的符号引用 → 是否已加载/解析/初始化 | | | | | | 未加载 | | v | | 执行类加载过程 | | | | 2. 分配内存 | | 在 Java 堆中划分出确定大小的内存块 | | +--> 指针碰撞 (Serial / ParNew,带压缩) | | +--> 空闲列表 (CMS,不带压缩) | | +--> TLAB 本地线程分配缓冲 (提升并发效率) | | | | 3. 初始化零值(不含对象头) | | 内存分配后,将分配空间初始化为零值 | | | | 4. 设置对象头 | | Mark Word (哈希码/GC分代年龄/锁状态) | | Klass Pointer (类型指针,指向类元数据) | | (数组对象还有数组长度) | | | | 5. 执行 <init> 构造方法 | | 即 Java 代码层面的构造函数 | | 此时对象才真正"可用" | | | +------------------------------------------------------------------+下面逐步深入每个环节。
二、第一步:类加载检查
JVM 遇到new指令时,首先会去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类加载过程。
类加载过程包括:加载 → 验证 → 准备 → 解析 → 初始化。这一步确保了在分配内存之前,JVM 已经知道了要创建的对象属于哪个类、有多大、有哪些字段。
// 第一次使用 User 类,触发类加载publicclassObjectCreationDemo{publicstaticvoidmain(String[]args){// new 指令 → 检查 User 是否已加载// 若未加载,则先加载 User.classUseruser=newUser("Alice",25);}}classUser{privateStringname;privateintage;publicUser(Stringname,intage){this.name=name;this.age=age;}}为什么要在分配内存前先做类加载?因为对象所需内存大小在类加载完成后才能确定。JVM 需要根据类的字段布局计算出对象的大小,才能在堆中划分出精确的内存块。类加载阶段完成的元信息(字段元数据、方法元数据)是后续内存分配的依据。
三、第二步:分配内存
类加载完成后,JVM 就可以为新对象分配内存了。对象所需内存的大小在类加载完成后即可完全确定,分配的过程就是把一块确定大小的内存从 Java 堆中划分出来。
3.1 两种分配方式
根据 Java 堆是否规整(即内存是否连续规整、无碎片),有两种分配方式:
指针碰撞(Bump the Pointer):假设 Java 堆中内存是绝对规整的,所有用过的内存放一边,空闲的内存放另一边,中间放着一个指针作为分界点的指示器。分配内存就是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
+----------------------------------------------------------+ | 指针碰撞 (Bump the Pointer) | +----------------------------------------------------------+ | | | 已分配内存 | 空闲空间 | | [obj1][obj2][obj3] | ................................. | | ^ | | | | | 分配指针 | | | | 分配 obj4:指针右移 obj4 大小 | | [obj1][obj2][obj3][obj4] | ........................... | | ^ | | | | | 新指针 | +----------------------------------------------------------+空闲列表(Free List):如果 Java 堆中的内存不是规整的,已使用的内存和空闲内存相互交错,JVM 就需要维护一个列表,记录哪些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
+----------------------------------------------------------+ | 空闲列表 (Free List) | +----------------------------------------------------------+ | | | 堆内存: | | [obj1] ... [obj2] [free 16B] [obj3] [free 32B] [obj4] | | | | 空闲列表: | | +--------+--------+ | | | addr | size | | | +--------+--------+ | | | 0x1008 | 16B | | | | 0x1030 | 32B | | | | 0x10A0 | 64B | | | +--------+--------+ | | | | 分配时:遍历列表找到 size >= 对象大小 的块 | +----------------------------------------------------------+选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的 GC 收集器是否带有空间压缩整理能力决定:
| GC 收集器 | 堆是否规整 | 分配方式 |
|---|---|---|
| Serial / ParNew (带压缩) | 规整 | 指针碰撞 |
| CMS (不带压缩) | 不规整 | 空闲列表 |
| G1 (Region 内规整) | Region 内规整 | TLAB + 指针碰撞 |
| ZGC / Shenandoah | 不规整 | 空闲列表 |
3.2 并发安全问题:CAS + TLAB
对象创建在虚拟机中非常频繁,即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的。可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:
方案一:CAS + 失败重试。对分配动作进行原子操作,保证更新指针时的线程安全。CAS(Compare-And-Swap)是一种无锁机制,它比较内存中的值与预期值,若相同则更新为新值,否则重试。HotSpot 在分配内存时使用 CAS 保证指针更新的原子性。
方案二:TLAB(Thread Local Allocation Buffer)。把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完了并需要分配新的 TLAB 时才需要 CAS。
+------------------------------------------------------------------+ | TLAB 内存分配 | +------------------------------------------------------------------+ | | | Java 堆 | | +--------------------+--------------------+-------------------+ | | | Thread-1 的 TLAB | Thread-2 的 TLAB | 其他堆空间 | | | | [obj][obj][obj] | [obj][obj] | ............... | | | | ^空闲指针 | ^空闲指针 | | | | +--------------------+--------------------+-------------------+ | | | | 线程在自己的 TLAB 内分配:无竞争,极快 | | TLAB 耗尽后:CAS 申请新的 TLAB | | | +------------------------------------------------------------------+TLAB 是 HotSpot 提升对象分配效率的核心机制。相关参数:
# 开启 TLAB(默认开启)-XX:+UseTLAB# 设置 TLAB 占单个 Eden 区的比例(默认 1%)-XX:TLABWasteTargetPercent=1# 查看 TLAB 相关信息-XX:+PrintTLAB为什么 TLAB 能大幅提升性能?绝大多数对象在 TLAB 中分配,线程无需竞争全局指针,分配动作退化为指针碰撞,效率接近"栈上分配"。只有大对象(无法放入 TLAB)或 TLAB 耗尽时才需要全局 CAS。
3.3 大对象的分配
对于大型对象(如长数组、大字符串),JVM 会直接在堆中分配,而不经过 TLAB。因为大对象如果放入 TLAB 会很快耗尽缓冲区,导致频繁的 TLAB 重分配。
# 设置大对象阈值(G1 中超过 Region 一半的对象被视为 Humongous 对象)# G1 默认 Region 大小为堆的 1/2048,最小 1MB,最大 32MB-XX:G1HeapRegionSize=16m四、第三步:初始化零值
内存分配完成后,JVM 会将分配到的内存空间(不包括对象头)都初始化为零值。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初值就能直接使用。
publicclassZeroValueDemo{// 以下字段在 <init> 执行前已被零值初始化privateintcount;// 0privatebooleanflag;// falseprivatelongtimestamp;// 0Lprivatedoublescore;// 0.0privateObjectref;// nullprivatecharch;// '\u0000'publicvoidprint(){// 不赋初值也能正常使用System.out.println(count);// 0System.out.println(flag);// falseSystem.out.println(ref);// null}}各类型零值表:
| 数据类型 | 零值 |
|---|---|
| int | 0 |
| long | 0L |
| short | (short)0 |
| char | ‘\u0000’ |
| byte | (byte)0 |
| boolean | false |
| float | 0.0f |
| double | 0.0d |
| 引用类型 | null |
注意:如果使用了-XX:ZeroTLAB参数(默认关闭),TLAB 分配的内存不会被零值初始化,而是由后续步骤负责。但默认情况下,零值初始化在 TLAB 分配时就会完成。
五、第四步:设置对象头
零值初始化完成后,JVM 接下来要对对象进行必要的设置,将这些信息存放在对象头(Object Header)中。对象头主要包括:
- Mark Word:存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等。
- Klass Pointer:类型指针,指向对象的类元数据(Class 元数据),JVM 通过这个指针确定对象是哪个类的实例。
- 数组长度(仅数组对象有):如果是数组对象,对象头中还会有一块用于记录数组长度的数据。
+----------------------------------------------------------+ | 对象头 (Object Header) 结构 | +----------------------------------------------------------+ | | | +-------------------+--------------------+ | | | Mark Word | 32bit / 64bit | | | +-------------------+--------------------+ | | | Klass Pointer | 32bit / 64bit | 压缩后 4B | | +-------------------+--------------------+ | | | array length | 32bit (仅数组) | | | +-------------------+--------------------+ | | | | 普通对象头大小: | | 64位 JVM 开启指针压缩: 8B (Mark Word) + 4B = 12B | | 64位 JVM 未开启压缩: 8B (Mark Word) + 8B = 16B | | 32位 JVM: 4B (Mark Word) + 4B = 8B | +----------------------------------------------------------+Mark Word 在不同锁状态下会复用存储空间,这是后续"锁升级"专题的核心内容。本篇只需理解:对象头在这个阶段被设置,其中 Klass Pointer 指向刚刚加载的类元数据,Mark Word 中的哈希码、GC 分代年龄等会被设为初始值(哈希码延迟到第一次调用hashCode()时才计算并写入)。
相关 JVM 参数:
# 开启指针压缩(64位 JVM 默认开启,堆 < 32GB 时有效)-XX:+UseCompressedOops# 关闭指针压缩-XX:-UseCompressedOops六、第五步:执行 构造方法
前面四步完成后,从 JVM 的角度看,一个新的对象已经产生了;但从 Java 程序的视角看,对象的创建才刚刚开始——构造方法还没有执行。<init>方法是 JVM 层面对应 Java 构造函数的方法。在这个阶段,JVM 会执行:
- 调用父类构造方法(
super()) - 对实例字段赋初始值(显式赋值)
- 执行构造方法体中的代码
publicclassInitOrderDemo{privateinta=10;// 显式赋值,在 <init> 中执行privateintb=initB();// 方法调用赋值privatestaticintc=20;// 类初始化阶段执行,不在 <init>publicInitOrderDemo(){// 3. 构造方法体System.out.println("构造方法: a="+a+", b="+b);}privateintinitB(){// 2. 显式赋值时调用returna*2;}publicstaticvoidmain(String[]args){// 完整执行顺序:// 1. 类加载检查(InitOrderDemo 已加载)// 2. 分配内存(TLAB)// 3. 零值初始化(a=0, b=0)// 4. 设置对象头// 5. <init>:// a) super() 隐式调用// b) a = 10// c) b = initB() = 20// d) 打印 "构造方法: a=10, b=20"InitOrderDemodemo=newInitOrderDemo();}}<init>方法执行完毕后,一个真正可用的 Java 对象才算创建完成。
七、完整流程代码示例
下面用一个完整的例子串联整个流程,并使用 JOL(Java Object Layout)工具观察对象结构:
// 需引入依赖:org.openjdk.jol:jol-core:0.17importorg.openjdk.jol.info.ClassLayout;publicclassObjectCreationFullDemo{publicstaticvoidmain(String[]args){// new 指令触发完整创建流程Personperson=newPerson("Bob",30);// 查看对象内存布局System.out.println(ClassLayout.parseInstance(person).toPrintable());}}classPerson{privateStringname;// 引用类型,4B(压缩指针)privateintage;// 4BpublicPerson(Stringname,intage){this.name=name;this.age=age;}}输出示例(64位 JVM,开启指针压缩):
# OFFSET SIZE TYPE DESCRIPTION VALUE# 0 4 (object header: Mark) 0x00000001# 4 4 (object header: Mark) 0x00000000# 8 4 (object header: Klass) 0x2000c005# 12 4 int Person.age 30# 16 4 ref Person.name "Bob"# 20 4 (alignment padding)# Instance size: 24 bytes可以看到,Mark Word 占 8 字节(OFFSET 0-7),Klass Pointer 占 4 字节(压缩后),实例数据 8 字节,对齐填充 4 字节(24 字节是 8 的整数倍)。
实践要点
TLAB 是性能关键:生产环境务必确保
-XX:+UseTLAB开启(默认开启)。高并发场景下若关闭 TLAB,所有线程竞争全局指针,分配性能会急剧下降。大对象分配需谨慎:避免在循环中创建大数组或大字符串,大对象无法走 TLAB 快速路径,且可能直接进入老年代,增加 GC 压力。可通过
-XX:PretenureSizeThreshold(仅 Serial/ParNew)控制大对象直接进入老年代的阈值。对象头是内存开销:每个对象都有对象头,对于小对象(如只含一个 int 字段的对象),对象头占比超过 60%。在设计缓存、集合时需考虑对象头的内存开销,适当使用基本类型数组替代包装类型。
指针压缩的边界:
-XX:+UseCompressedOops在堆小于 32GB 时有效,超过 32GB 会自动关闭,此时对象头从 12 字节膨胀到 16 字节,内存开销显著增加。这也是为什么 32GB 常被视为堆大小的一个"性能拐点"。构造方法中的陷阱:
<init>执行期间对象处于"半初始化"状态,若在构造方法中调用可被重写的方法(虚方法)或启动新线程传递this,可能导致子类读到未初始化完成的字段值。这是常见的"构造方法泄漏 this"问题。
小结
- 对象创建经历五步:类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行
<init>,前三步在 JVM 层面完成,后两步连接到 Java 代码层面。 - 内存分配方式由堆是否规整决定:指针碰撞要求规整堆,空闲列表适应碎片堆;TLAB通过线程私有缓冲区大幅减少竞争,是 HotSpot 的默认优化手段。
- 并发安全通过CAS + TLAB组合保障:TLAB 内无锁分配,TLAB 耗尽时 CAS 申请补充。
- 对象头在第四步设置,包含Mark Word和Klass Pointer,是后续锁、GC、类型判断的基础。
<init>构造方法执行后,对象才真正"可用";理解<init>的执行顺序(super → 字段赋值 → 构造体)有助于排查字段初始化问题。
下一篇我们将深入对象头的内部结构,用 JOL 工具实测对象在不同状态下的内存布局。
更多内容:JVM调优实战