主题
对象的诞生与布局:创建、内存分配与布局
本文是 JVM & GC 系统学习系列的 L2 核心篇。前置:JVM 总览与运行时数据区、类加载子系统。 学完可以配合面试题食用:对象创建过程与内存分配策略、JIT 编译与逃逸分析
从一行 new 说起
写 Java 的人每天都要 new 对象,但很少有人盯着 new User() 想过:这一行代码执行时,JVM 内部到底做了几件事?对象放在堆的哪个位置?一个对象 16 字节,字段实际只占 4 字节,剩下的去哪了?
这些问题串起来就是对象的一生前半段:怎么创建、在哪分配、长什么样、怎么被引用。上一篇讲了类加载,类就绪之后,接下来就是拿这个类模板造对象。先把全景图放这,后面逐段展开:
mermaid
sequenceDiagram
participant J as Java 线程
participant V as 虚拟机
participant H as 堆
J->>V: new User()
V->>V: ① 常量池定位符号引用,类检查(已加载?)
Note over V: 未加载则先走类加载(上篇内容)
V->>H: ② 分配内存(TLAB 优先)
V->>V: ③ 零值初始化 + ④ 设置对象头
V->>J: ⑤ 执行 <init>(构造函数)
J->>H: 返回引用,对象可用对象创建五步
虚拟机遇到 new 指令时,先把能确定的都确定下来,最后才把控制权交还给 Java 代码。顺序是固定的:
- 类检查。拿 new 指令的参数去常量池定位类符号引用,检查这个类是否已加载、解析、初始化过。没有就先执行类加载——这是为什么"第一次 new"比后续慢的原因之一。
- 分配内存。从堆里划出一块确定大小的空间,大小在类加载完成后就能算出来(下一节展开)。
- 零值初始化。把分配到的内存全部清零。这一步解释了一个经典现象:字段不赋初值也有默认值——
int是 0,引用是 null,因为内存早就清零过了,<init>里没显式赋值的字段保持零值。 - 设置对象头。写入 Mark Word(哈希码、GC 分代年龄、锁状态等)和类型指针。注意:零值和对象头都在构造函数执行之前设置,所以构造函数里能正常使用
this。 - 执行
<init>。也就是按源码顺序执行实例初始化:字段初始化表达式、实例代码块、构造函数体。到这一步,对象才算按程序员的意图"诞生"。
第 5 步是 Java 语言层的行为(JLS 规定),前 4 步才是虚拟机层的。所以严格说 new 指令本身只负责前 4 步,<init> 是紧随其后的方法调用——这也是 JDK 7 之后不能在构造器里"泄漏 this"这类问题值得注意的原因:第 4 步完成后对象头已就绪,外部已经能把它当正式对象用。
内存怎么划:指针碰撞、空闲列表与 TLAB
分配的第二步"划内存",听起来简单,实际要回答两个问题:堆长什么样、怎么划得快。
堆的形态决定分配方式。 如果堆内存绝对规整——所有活对象在一头,空闲内存在另一头,中间放一根指针作为分界点——那么分配就是把指针往空闲方向挪一段距离,这个做法叫指针碰撞(bump the pointer),一次原子移动就完事。如果堆是碎片化的——活对象和空闲内存犬牙交错——就只能维护一张空闲列表(free list),分配时从列表里找一块够大的,更新列表。Serial、ParNew 这类带压缩整理的收集器配合的是指针碰撞;CMS 这种基于标记-清除、不整理碎片的收集器只能用空闲列表。这也是 CMS 长期运行后碎片问题会拖慢分配速度的根源之一。
并发分配怎么办。 即使用上了指针碰撞,堆是所有线程共享的,两个线程同时挪指针,得有一个输了。最直接的解法是 CAS 加失败重试,能做,但高并发分配场景下竞争激烈。HotSpot 的做法是给每个线程预分配一块私有TLAB(Thread Local Allocation Buffer):线程在自己的 TLAB 内分配直接指针碰撞,没有任何同步;TLAB 用完再申请下一块,只有申请那一刻才需要 CAS(或 Eden 还有空间时直接扩)。本质上是用"线程本地缓存"这个经典思路,把同步成本从每次分配摊薄到每个 TLAB 一次。
mermaid
flowchart LR
A[new 指令] --> B{能否栈上分配?}
B -- 逃逸分析通过 --> C[栈上/标量替换<br/>不进堆,方法结束即消亡]
B -- 逃逸或分析失败 --> D{TLAB 还有空间?}
D -- 是 --> E[TLAB 内指针碰撞<br/>无同步]
D -- 否 --> F{Eden 可分配新 TLAB?}
F -- 是 --> G[CAS 申请新 TLAB<br/>回到 E]
F -- 否 --> H[堆上 CAS 分配<br/>仍失败则触发 GC]所以真实的分配优先级是:能上栈不上堆(编译期决定),进堆先进 TLAB,TLAB 装不下走 Eden,Eden 满了才 GC。
逃逸分析失败的对象怎么办。 JIT 通过逃逸分析判断对象是否逃逸出方法/线程:不逃逸的可以做标量替换(干脆不生成对象,拆成独立变量)或栈上分配。但分析是保守的——只要有一条路径可能把引用传出去,就算逃逸,老老实实走堆分配。也就是说逃逸分析是分配路径的快车道,不是必经之路:大多数业务对象(作为返回值、存入集合、赋给字段)都会逃逸,最终还是要靠 TLAB + 分代收集兜底。两层机制解决的是不同问题:逃逸分析消灭"根本不用存在的对象",分代回收处理"确实要活一段时间的对象"。
对象长什么样:Mark Word、类型指针与字段重排
分配到的这块内存,内部有三个部分(从低地址到高地址):
- 对象头(Header):Mark Word + 类型指针。64 位系统上 Mark Word 占 8 字节;类型指针理论 8 字节,但开压缩指针(
-XX:+UseCompressedClassPointers,默认开)后只要 4 字节。数组对象还多一个记录数组长度的 4 字节。 - 实例数据(Instance Data):各字段的值。HotSpot 默认会把字段重新排列——相同宽度的放一起,长的在前(longs/doubles -> ints/floats -> shorts/chars -> bytes/booleans -> 引用),父类字段在子类字段前面。重排是为了内存对齐、减少空洞。
- 对齐填充(Padding):HotSpot 要求对象起始地址是 8 字节的整数倍,不够就补零凑齐。这就是"16 字节对象只有 4 字节字段"的答案:8 字节 Mark Word + 4 字节压缩类型指针 + 4 字节 int 字段正好 16,但如果再加一个 byte,就得到 24——多出来的 7 字节是填充。
Mark Word 是个多用途复用结构,同一块 8 字节在不同状态下存不同内容(无锁态存哈希码和分代年龄,轻量级锁态存指向栈上锁记录的指针,GC 标记时有专用格式)。这里只留个印象,锁那部分展开在并发模块。
对象怎么被引用:句柄 vs 直接指针
Object o = new Object() 之后,局部变量表里的 o 存什么?两种设计:
- 句柄访问:堆里划一块句柄池,引用存句柄地址,句柄里再存两个指针——对象实例数据和类型数据。对象被 GC 搬家(复制/整理算法很爱搬)时只改句柄里的实例指针,引用本身纹丝不动。
- 直接指针:引用直接存对象地址。访问快(少一次寻址),代价是对象被搬走后所有引用都要更新,这件事 GC 移动对象时本来就要顺手做。
HotSpot 用的是直接指针,拿"GC 移动时要更新引用"换每次访问少一次寻址。JVM 规范没有强制,属于实现取舍。
动手实操:用 JOL 看对象布局
光说不练假把式。JOL(Java Object Layout)能直接打印对象的内存布局,下面这个实验观察压缩指针开关前后的对象大小变化:
xml
<!-- pom.xml -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class JolDemo {
static class MyObject {
int id; // 4B
byte flag; // 1B
String name; // 引用
}
public static void main(String[] args) {
// 先看当前 VM 的压缩指针状态
System.out.println(VM.current().details());
MyObject o = new MyObject();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}默认参数(堆小于 32GB,压缩指针开启)下,输出大致是:
text
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header: Mark Word) 0x00000000
4 4 (object header: Mark Word) 0x00000000
8 4 (object header: Klass Pointer)
12 4 int MyObject.id 0
16 1 byte MyObject.flag 0
17 3 (alignment gap)
20 4 java.lang.String MyObject.name null
24 0 (object size compared to …) 24 bytes逐行解读:
- 偏移 0-7 是 Mark Word(两行 4 字节拼起来);偏移 8-11 是压缩后的类型指针,4 字节——这就是
-XX:+UseCompressedClassPointers的效果。 id和flag被重排紧凑排列在 12-16;name引用在压缩普通对象指针(-XX:+UseCompressedOops)下也只占 4 字节。- 偏移 17-19 是 3 字节对齐填充,把总大小凑到 8 的倍数。
再关掉压缩指针对比:java -XX:-UseCompressedOops -XX:-UseCompressedClassPointers JolDemo,类型指针变 8 字节、引用变 8 字节,同一对象从 24 字节涨到 32 字节。一堆小对象场景(尤其是引用密集的集合类)内存占用差距接近翻倍,这就是压缩指针作为默认值的原因,也是堆超过 32GB 后对象"变大"、有效容量反而不升的原因之一。
常见误区与小结
- "字段声明顺序就是内存顺序"——不是。HotSpot 会按字段宽度分组重排,JOL 打出来的顺序经常和源码不一致,读布局要以 JOL 为准。
- "字段默认值是构造函数赋的"——不是。零值初始化在
<init>之前完成,构造函数只是可能覆盖它;所以字段显式初始化int a = 0其实是 redundant write。 - "TLAB 让分配绝对无锁"——TLAB 内确实无同步,但 TLAB 用完要 CAS 申请新的,竞争只是被摊薄,不是消失。
- "逃逸分析失败的路径会再试一次"——没有重试。逃逸即放弃,对象直接走堆分配;标量替换的对象根本不产生分配。
- "CMS 也能指针碰撞"——不能。标记-清除留下的碎片使堆不规整,CMS 只能维护空闲列表,这也是它分配慢、碎片化后被迫 Full GC 的原因。
小结:对象创建五步是类加载之后、GC 之前的主干路径,TLAB 解决并发分配的速度问题,对象布局(Mark Word/类型指针/字段重排/对齐)决定了内存占用和后续锁、GC 的实现基础,访问方式(HotSpot 用直接指针)是贯穿引用与 GC 的纽带。下一篇进入回收侧的起点:怎么判定一个对象该活还是该死——GC 判定与算法演进。
参考
参考:《深入理解 Java 虚拟机(第 3 版)》第 2 章 HotSpot 虚拟机对象探秘;OpenJDK JOL 官方示例(github.com/openjdk/jol);JEP 147: Reduce Class-File Size…