new 一个对象时,JVM 内部发生了什么?
简化版
new 一个对象大致五步:① 检查类是否已加载(没加载先触发类加载)→ ② 在堆上分配内存 → ③ 内存零值初始化(字段先置默认值)→ ④ 设置对象头(类型指针、Mark Word 等)→ ⑤ 执行构造方法(<init>,按你的代码赋真正的值)。之后引用变量指向这个对象。
详细版
以 User u = new User() 为例,JVM 干了这些事:
- 类加载检查:看
User的类元信息在方法区里有没有。没有就先走类加载(加载→验证→准备→解析→初始化),确保类就绪。 - 分配内存:在堆上划一块
User大小的内存。两种分配方式:- 指针碰撞:内存规整时,把「已用/未用」的分界指针往后挪对象大小即可;
- 空闲列表:内存有碎片时,从一个记录空闲块的列表里找一块够大的。 用哪种取决于所用 GC 是否会整理内存(标记-整理/复制 → 规整 → 指针碰撞)。
- 零值初始化:把分配到的内存全部置零(默认值)。所以此时字段
int是 0、引用是 null——这也是为什么实例字段不赋初值也能用。 - 设置对象头:往对象头里写入类型指针(指向类元信息)、Mark Word(哈希码、GC 分代年龄、锁标志等)。
- 执行
<init>(构造方法):这才轮到你写的构造逻辑和字段显式赋值真正执行,对象变成「你期望的样子」。
完整版教学
一、两次「初始化」别搞混
面试常追问:对象创建过程里有两次赋值,区别是什么?
- 第 3 步「零值初始化」:JVM 把内存清零,字段得到默认值(0、false、null)。这一步保证「对象字段即使不显式赋值也有确定初值」。
- 第 5 步「构造方法执行」:执行
<init>,包括字段的显式初始化(int age = 18)和构造器里的代码,字段得到你写的值。
所以 int age = 18 这个字段,实际经历「先被置 0 → 再被赋 18」两步。理解这个能解释很多「半初始化对象」的并发问题(见下)。
二、并发下的坑:对象「半初始化」
第 2~5 步不是原子的,且步骤 2(分配内存)和 5(初始化)之间可能被指令重排。这正是双重检查锁单例(DCL)必须给字段加 volatile 的原因:
instance = new Singleton();
// 实际拆成:① 分配内存 → ② 执行构造初始化 → ③ 引用指向内存
// 可能被重排成 ①③②:引用已非 null,但对象还没初始化完
如果重排成 ①③②,另一个线程可能拿到「引用不为 null 但对象还没构造完」的半成品对象。volatile 通过内存屏障禁止这个重排(详见「volatile 的作用和原理」那道题)。
三、从语义对象到优化结果——两个关键机制
Java 语义上执行了对象创建,但 JIT 在证明对象不逃逸且行为等价时,可能消除实际对象分配。需要区分语言语义与优化后的机器执行:
- 逃逸分析 + 标量替换:如果 JIT 证明对象不会逃逸,可能把字段拆成若干标量并消除完整对象分配。最终值可能进入寄存器或栈槽,不能简单概括为创建了一个完整的“栈上对象”。
- TLAB(线程本地分配缓冲):堆是共享的,多线程同时分配内存要同步,很慢。于是每个线程在 Eden 里预先分一小块私有区域(TLAB),线程在自己的 TLAB 里分配对象无需加锁,快得多。TLAB 用完了再向堆申请。
记忆点:常见快路径是在 Eden 的 TLAB 中做指针碰撞;不逃逸对象可能被标量替换,从而根本不形成完整堆对象。
四、对象在内存里长什么样
常见 HotSpot 堆对象由对象头、实例数据、对齐填充三部分组成。对象头保存类型元数据指针及 Mark Word 等运行时状态,实例数据承载字段,对齐填充则让总大小满足当前 JVM 的对象对齐粒度。对象对齐常见为 8 字节,但它属于可配置的实现细节,不是 Java 规范固定值。构造器执行前 JVM 已建立可识别的对象头;具体字节数必须结合位数、压缩类指针和 JDK 版本判断。
五、分配快路径、慢路径与可见性
线程能在 TLAB 中分配时,常见动作只是移动私有的顶部指针:
new_top = tlab_top + object_size
若 new_top <= tlab_end:分配成功
否则:申请新 TLAB 或进入共享堆慢路径
假设 TLAB 剩余 256 字节,新对象按对齐后需要 64 字节,则本次快路径只需更新指针;若对象需要 320 字节,就无法放入当前 TLAB。它可能申请新 TLAB、直接在共享区域分配,或触发 GC,具体由 JVM 决定。
对象引用不能在构造完成前不安全地发布给其他线程。构造期间执行 registry.add(this) 就可能让别的线程观察到尚未完成初始化的字段。正确发布可使用锁、volatile 引用、静态初始化或并发容器提供的 happens-before 关系。
对象大小还会改变路径:当前 TLAB 只剩 96 字节时,按对齐后 64 字节的对象仍能走快路径,而 128 字节对象必须申请新 TLAB 或进入慢路径。这里的数字只用于展示判定方法,真实 TLAB 大小与浪费阈值由 HotSpot 自适应策略和参数决定。分析分配性能时还要区分首次解释执行与 JIT 预热后的稳态,否则逃逸分析尚未生效会造成错误结论。
对象对齐后大小 ≤ TLAB 剩余空间 → TLAB 快路径
对象对齐后大小 > TLAB 剩余空间 → 新 TLAB 或共享堆慢路径
六、对象“在哪里”要区分语义与优化
| 层次 | 应该怎样描述 |
|---|---|
| Java 语义 | new 创建具有类身份和字段状态的对象 |
| 解释器/未优化执行 | 常见在 Java 堆分配对象存储 |
| JIT 优化后 | 不逃逸对象可能被标量替换,完整分配可被消除 |
| 运行时分配路径 | 常见优先使用 TLAB,失败再走慢路径 |
逃逸分析本身不是“把对象搬到栈上”的同义词。HotSpot 常见收益是标量替换和锁消除;最终机器码可能只保留几个局部标量,甚至把整个对象计算消除。
面试中说“对象都在堆上”过于绝对,说“逃逸分析后一定栈上分配”同样不准确;应说明语义、常见实现和 JIT 优化三个层次。
对象创建性能分析还应区分解释执行、C1/C2 编译阶段以及预热后的稳态结果。
七、常见误区与追问
- 误区:执行构造方法就是对象创建的第一步。 类检查、内存分配、默认值处理和对象头设置通常发生在
<init>之前。 - 误区:逃逸分析会把所有不逃逸对象完整地放到线程栈。 常见优化是标量替换或消除分配,是否实施取决于 JIT 证明和成本。
- 误区:有 TLAB 就完全不存在分配竞争。 TLAB 快路径减少竞争,申请新 TLAB 和共享区域分配仍需协调。
- 追问:为什么实例字段有默认值而局部变量没有? 对象存储在构造代码前具有零值语义;局部变量则必须通过编译器确定赋值检查。
- 追问:大对象也一定进入 TLAB 吗? 不一定;超过当前 TLAB 或相关阈值时通常走共享分配路径,具体取决于 JVM。
- 追问:对象头什么时候写入? 分配后、构造器执行前,JVM 要建立类型元数据指针和初始 Mark Word 等状态。
- 追问:如何验证分配是否被消除? 可结合 JIT 编译日志、诊断参数或 JMH 与合适分析工具,不能仅凭源码判断。
八、加强记忆
记忆主线是“类检查、分配、默认值、对象头、构造器”,但要把它当作常见 HotSpot 语义路径而非每次都原样执行的机器步骤。常规小对象优先在 Eden 的 TLAB 中做指针碰撞,放不下时再申请缓冲区或进入共享堆慢路径。JIT 若证明对象不逃逸,可能通过标量替换消除完整分配,这不等于固定创建一个栈上对象。最后用“构造中不泄露 this、发布时建立 happens-before”记住并发可见性的边界。