03 对象的创建、内存布局与访问定位
摘要:
当 Java 代码执行一条 new Object() 时,JVM 内部究竟发生了什么?一个 Java 对象在内存中是如何排列其字节的?obj.field 这样一次字段访问,底层经历了怎样的指针追踪?本文逐层解答这三个问题。对象创建:new 指令触发的五步流程——类加载检查、内存分配(指针碰撞/空闲列表)、零值初始化、设置对象头、执行 <init>;对象内存布局:逐字节解析对象头(Mark Word 的 64 位结构 + Klass Pointer)、实例数据和对齐填充,以及 CompressedOops(压缩普通对象指针)如何用 4 字节表示 32GB 堆内的任意对象引用;对象访问定位:HotSpot 选择直接指针而非句柄池的工程权衡。这些知识是理解 04 synchronized 的锁升级——偏向锁、轻量级锁与重量级锁 中 Mark Word 位变化、04 垃圾回收基础——可达性分析、安全点与安全区域 中 GC Roots 遍历,以及生产中 jol(Java Object Layout)工具输出的基础。
第 1 章 从 new 到对象可用的五步流程
1.1 第一步:类加载检查
当 JVM 遇到一条 new 字节码指令时,它首先检查这条指令的参数(一个常量池中的符号引用,指向目标类)能否在常量池中定位到对应的类,并检查这个类是否已经被加载、解析和初始化过。
如果没有,则必须先触发该类的类加载过程(见 10 类加载机制——双亲委派模型与打破它的场景)。类加载完成后,JVM 才知道这个类的内存布局,知道需要为对象分配多少字节的内存空间。
一个容易被忽视的细节:对象大小在类加载的准备阶段就已经完全确定,并不是在创建对象时动态计算的。JVM 在准备阶段会根据类的字段描述符,计算出该类实例需要的确切字节数(包括对齐填充),并保存在类的元数据中。后续每次 new 这个类时,直接用这个预计算好的大小分配内存,速度极快。
1.2 第二步:分配内存
类加载检查通过后,JVM 为新生对象在 Java 堆中分配内存。所需内存大小在类加载时已经确定,分配过程本质上是从堆中划出一块对应大小的区域。
分配内存有两种策略,选择哪种取决于堆中内存是否规整(是否带有碎片):
策略 1:指针碰撞(Bump-the-Pointer)
前提:堆内存是规整的——所有已用内存在左边,所有空闲内存在右边,中间有一个指针作为分界点。
分配方式:将指针向空闲方向移动对象大小的距离。简单、快速、无碎片。
适用收集器:Serial、ParNew(都使用带压缩的复制/整理算法,堆总是规整的)
策略 2:空闲列表(Free List)
前提:堆内存不规整,已用和空闲内存相互交错,JVM 维护一张记录空闲内存块的列表。
分配方式:从空闲列表中找到一块足够大的区域分配,并更新列表。
适用收集器:CMS(使用标记-清除算法,不压缩,堆会产生碎片)
并发安全的分配:
多线程同时分配对象时,指针碰撞的”移动指针”操作不是原子的,需要同步。JVM 有两种处理方式:
-
CAS + 失败重试:用 CAS 原子地更新分配指针,失败则重试。简单,但高并发下竞争激烈。
-
TLAB(Thread-Local Allocation Buffer):每个线程预先在 Eden 区申请一块私有缓冲区,线程在自己的 TLAB 中分配时完全无需同步。只有 TLAB 用完需要申请新 TLAB 时才需要同步。这是 HotSpot 的默认策略(
-XX:+UseTLAB)。
1.3 第三步:零值初始化
内存分配完成后,JVM 将分配的内存空间(不包括对象头)全部初始化为零值:int → 0,boolean → false,Object → null,long → 0L……
这一步保证了 Java 对象在程序员显式赋值之前,其字段就已经有了确定的初始值,不会出现 C 语言那样的”未初始化变量包含随机值”的问题。这就是为什么你可以在 Java 中直接调用 new int[100],得到的数组元素保证全是 0。
零值初始化与局部变量的区别:局部变量(存储在虚拟机栈中)不会自动零值初始化,使用前必须显式赋值。编译器(javac)会在编译期通过数据流分析强制检查局部变量是否被初始化,未初始化就使用会导致编译错误 "variable might not have been initialized"。而实例字段(存储在堆中)受零值初始化保护,不需要显式初始化就可以使用。
1.4 第四步:设置对象头
零值初始化完成后,JVM 需要填写对象头(Object Header) 中的元数据:
- 将对象的类型信息(指向
Klass的指针,即 Klass Pointer)写入对象头 - 根据当前 JVM 的状态设置 Mark Word(哈希码、GC 分代年龄、锁状态标志)
对象头的结构是本文第 2 章的核心内容,后面详细展开。
1.5 第五步:执行 <init> 方法
以上步骤完成后,从 JVM 的角度看,一个对象已经”存在”了(内存分配好了,对象头也填写完毕)。但从程序员的角度看,对象还没有按照程序员的意图完成初始化。
最后一步,JVM 执行对象的 <init> 方法(由 javac 将构造方法、实例变量初始化语句和实例初始化块合并生成),按照程序员编写的逻辑设置实例字段的值、调用父类构造方法等。
执行完 <init> 之后,一个真正意义上”可以使用”的对象才产生。
设计哲学
将”对象创建”分为”JVM 层面的内存分配和对象头初始化”与”程序层面的
<init>初始化”两个阶段,是 JVM 设计精巧之处。前者是纯机械的内存操作,速度极快(TLAB 场景下只需要移动一个指针);后者才是真正执行用户代码,时间取决于构造方法的复杂度。这个分层也使得 JVM 可以在分配完内存后立即设置对象头(如锁状态、GC 信息),而不需要等待<init>完成。
第 2 章 对象的内存布局
2.1 三个组成部分
一个 Java 对象在堆内存中,由三个部分依次排列:
对象内存布局(64 位 JVM,开启 CompressedOops):
┌─────────────────────────────────────────────────────────────┐
│ 对象头(Header) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Mark Word(8 字节,64 位) │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ Klass Pointer(4 字节,开启压缩指针后) │ │
│ └────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 实例数据(Instance Data) │
│ 按字段类型紧密排列(可能有字段重排序优化) │
├─────────────────────────────────────────────────────────────┤
│ 对齐填充(Padding) │
│ 将对象总大小补齐为 8 字节的整数倍(0~7 字节) │
└─────────────────────────────────────────────────────────────┘
2.2 Mark Word——对象头的核心
Mark Word 是对象头中信息密度最高的部分,只有 8 字节(64 位 JVM),却要承载多种状态下的不同信息,做到这一点靠的是多路复用(Multiplexing)——根据最低几位的标志位(tag bits),整个 Mark Word 有完全不同的解释方式。
64 位 JVM 中 Mark Word 的状态机(以 HotSpot JDK 17 为例):
| 锁状态 | 最低位(bit 0~2) | Mark Word 内容 |
|---|---|---|
| 无锁(Normal) | 001 | 对象 HashCode(31 位)+ GC 年龄(4 位)+ 偏向锁标志(1 位,0)+ 标志位 |
| 偏向锁 | 101 | 线程 ID(54 位)+ Epoch(2 位)+ GC 年龄(4 位)+ 偏向锁标志(1 位,1) |
| 轻量级锁 | 00 | 指向线程栈中 Lock Record 的指针(62 位) |
| 重量级锁 | 10 | 指向 Monitor 对象的指针(62 位) |
| GC 标记 | 11 | GC 标记信息(由 GC 内部使用) |
核心概念:Mark Word 的多路复用
一个 Java 对象从无锁 → 偏向锁 → 轻量级锁 → 重量级锁的整个加锁升级过程,本质上就是 Mark Word 内容的变化过程。04 synchronized 的锁升级——偏向锁、轻量级锁与重量级锁 中对此有详细剖析。GC 年龄(4 位,最大值 15,对应
-XX:MaxTenuringThreshold=15)也编码在 Mark Word 中——这就是为什么 MaxTenuringThreshold 的上限是 15,因为 4 位最多表示 0~15。
HashCode 的惰性计算:
Object.hashCode() 返回的身份哈希码(Identity HashCode,与 equals 无关的那个基于对象地址计算的哈希码)只在第一次调用 hashCode() 时才计算,计算后存入 Mark Word(无锁状态下的 31 位 HashCode 字段)。
这带来一个有趣的约束:当对象处于偏向锁状态时,Mark Word 中没有空间存放 HashCode(偏向锁占用了大部分位)。如果对一个已偏向的对象调用 hashCode(),JVM 必须撤销偏向锁(revoke biased lock),将其降为无锁状态,才能在 Mark Word 中存入 HashCode。这就是为什么大量调用 hashCode() 的对象(如作为 HashMap 的 key)不适合使用偏向锁——偏向锁的撤销开销会超过它带来的收益。
GC 年龄的极限:
对象每在 Survivor 区经历一次 Minor GC,Mark Word 中的 GC 年龄字段(4 位)加 1。年龄达到 -XX:MaxTenuringThreshold(默认 15,也是最大值,因为 4 位只能表示 0~15)时,对象晋升到老年代。如果想将 MaxTenuringThreshold 设置为 16,JVM 会报参数不合法——这正是 Mark Word 的位布局决定的。
2.3 Klass Pointer——类型信息的入口
Klass Pointer 是对象头的第二部分,它是一个指向 JVM 内部 Klass 对象(C++ 实现的类元数据结构)的指针,代表”这个对象是哪个类的实例”。
通过 Klass Pointer,JVM 可以:
- 知道对象的类型(用于
instanceof判断、多态分派) - 找到对象所有字段的布局信息(用于 GC 扫描对象中的引用字段)
- 找到对象的虚方法表(vtable,用于虚方法调用分派)
在 64 位 JVM 中,如果不进行压缩,一个 Klass Pointer 需要 8 字节。开启压缩普通对象指针(CompressedOops) 后,Klass Pointer 只需要 4 字节。
2.4 CompressedOops——用 4 字节寻址 32GB
什么是 CompressedOops(压缩普通对象指针)?
在 64 位 JVM 中,所有对象引用(Ordinary Object Pointer,OOP)原本是 64 位(8 字节)的指针。如果堆中有 10 万个对象,每个对象平均有 5 个引用字段,那么仅引用字段就占用约 10万 × 5 × 8 = 4MB。相比之下,32 位 JVM 中相同场景只占用 2MB。
64 位的引用带来了大量额外的内存开销,同时降低了 CPU 缓存利用率(缓存行中能存储的引用数量减少了一半)。
CompressedOops 的解决思路:
Java 对象在堆中的内存地址是 8 字节对齐的(对象大小必须是 8 的倍数)。这意味着堆中任意对象的起始地址的低 3 位(bit 0~2)永远是 0。
既然低 3 位永远是 0,为什么要存储它们?可以把这 3 位”借”出来——将 32 位的压缩指针(称为 narrow OOP)左移 3 位,就得到真实的 64 位地址:
真实地址(64 位)= 压缩指针(32 位)<< 3 + 堆基址(heap base)
理论寻址范围:2^32 × 8 = 32GB
用 32 位(4 字节)的压缩指针,就能寻址 32GB 以内的整个堆。当堆大小 ≤ 32GB 时(-Xmx ≤ 32g),JVM 默认开启 CompressedOops(-XX:+UseCompressedOops);超过 32GB 则自动关闭,改用 8 字节原始指针。
CompressedOops 的工程意义:
-
内存节省:64 位 JVM 开启压缩指针后,引用字段内存开销与 32 位 JVM 相同(均为 4 字节),配合 64 位的大堆,兼顾了内存效率与寻址能力。
-
缓存友好:同样大小的对象,压缩指针版本能在 CPU 缓存行中存下更多引用字段,缓存命中率更高。
-
为什么 32GB 是”悬崖”:很多公司将 JVM 堆大小上限设为 31GB 或 30GB,就是为了保持在 32GB 以内,确保 CompressedOops 持续生效。一旦堆超过 32GB,压缩指针关闭,所有引用字段从 4 字节变 8 字节,内存占用可能反而比 30GB 堆更大(对象数量不变但每个对象更大),这是反直觉的性能”悬崖”。
生产避坑
一个常见的误解:把堆从 30GB 增大到 34GB,以为能缓解内存压力。实际上,一旦超过 32GB,CompressedOops 关闭,所有对象的引用字段内存占用翻倍,导致有效载荷(业务数据)占比下降,GC 压力反而可能上升。正确做法:要么保持 ≤ 32GB,要么直接部署多个 JVM 实例(每个实例 ≤ 32GB),水平扩展。
2.5 实例数据——字段的紧凑排列
实例数据区存储对象真正的有效数据——类定义的所有实例字段(不包括 static 字段,static 字段存在方法区)。
字段排列顺序与重排序优化:
JVM 不保证字段在内存中的顺序与源码中声明的顺序相同。HotSpot 默认按照字段类型大小进行排序(从大到小)以减少内存对齐损耗:
默认字段排列顺序(HotSpot):
long / double(8 字节)
int / float(4 字节)
char / short(2 字节)
byte / boolean(1 字节)
Object 引用(CompressedOops:4 字节;原始:8 字节)
父类的字段在子类字段之前。父类字段末尾可能有空洞(为了对齐),子类字段可能”插入”父类的空洞中(由 -XX:+CompactFields 控制,JDK 8+ 默认开启)。
用 JOL 工具查看真实布局:
// 使用 Java Object Layout (JOL) 工具分析对象布局
// 依赖:org.openjdk.jol:jol-core
public class LayoutExample {
boolean flag; // 1 byte
int value; // 4 bytes
long bigValue; // 8 bytes
String str; // 4 bytes(CompressedOops)
public static void main(String[] args) {
System.out.println(ClassLayout.parseClass(LayoutExample.class).toPrintable());
}
}典型输出(64 位 JVM,开启 CompressedOops,JDK 17):
LayoutExample object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header: mark) ← Mark Word 低 4 字节
4 4 (object header: mark) ← Mark Word 高 4 字节
8 4 (object header: class) ← Klass Pointer(4 字节,压缩)
12 4 int value ← int 字段(4 字节,排在 long 前,因为有空位)
16 8 long bigValue ← long 字段(8 字节,8 字节对齐)
24 4 String str ← 引用字段(4 字节,压缩)
28 1 boolean flag ← boolean 字段(1 字节)
29 3 (alignment/padding gap) ← 对齐填充(3 字节,使总大小=32,是8的倍数)
Instance size: 32 bytes
注意:虽然源码中 boolean flag 先声明,但 JVM 将 int value 排到 flag 前面(字段重排序),充分利用了 Klass Pointer 后面的 4 字节空隙(否则需要 4 字节填充)。
2.6 对齐填充——整理内存的”垫片”
Java 对象的大小必须是 8 字节的整数倍。这是 CompressedOops 能够工作的前提(低 3 位为 0 才能”免费”存储),同时也有利于 CPU 缓存行对齐(64 字节缓存行恰好能容纳整数个对象)。
当对象头 + 实例数据的总字节数不是 8 的整数倍时,JVM 在末尾填充若干字节(0~7 字节)的对齐填充(Padding),使总大小达到 8 的整数倍。对齐填充没有任何实际意义,纯粹是为了满足内存对齐要求。
最小对象的大小:
一个完全没有实例字段的 Object 对象(new Object())在 64 位 JVM + 压缩指针下的大小是:
Mark Word(8 字节)+ Klass Pointer(4 字节)= 12 字节
→ 补齐至 8 的倍数 = 16 字节(填充 4 字节)
所以 new Object() 在 HotSpot JVM 中占用 16 字节,不是直觉上以为的 0 字节或 8 字节。
第 3 章 数组的内存布局
数组的内存布局与普通对象略有不同:在对象头之后、实例数据之前,有一个额外的 Array Length 字段(4 字节),记录数组的长度:
数组对象内存布局:
┌─────────────────────────────────────────────────────────┐
│ Mark Word(8 字节) │
├─────────────────────────────────────────────────────────┤
│ Klass Pointer(4 字节,压缩) │
├─────────────────────────────────────────────────────────┤
│ Array Length(4 字节) │
├─────────────────────────────────────────────────────────┤
│ 数组元素 [0] [1] [2] ... [n-1] │
│ (每个元素按其类型占相应字节数,int[] 每个 4 字节) │
└─────────────────────────────────────────────────────────┘
new int[100] 在 64 位 JVM(CompressedOops)下的大小:
- 对象头:8(Mark Word)+ 4(Klass)+ 4(Array Length)= 16 字节
- 数组元素:100 × 4(int)= 400 字节
- 总计:416 字节(恰好是 8 的倍数,无需填充)
第 4 章 对象的访问定位
4.1 为什么需要”定位”
Java 程序通过栈上的引用(reference) 来操作堆上的对象。但 JVM 规范只规定了引用是一个指向对象的类型,并没有规定引用的具体结构——是直接存放对象的堆地址,还是存放指向”对象描述信息”的某种间接引用?
这就引出了两种实现方式:句柄方式和直接指针方式。
4.2 句柄方式(Handle Pool)
工作原理:JVM 在堆中划出一块区域作为句柄池(Handle Pool),每个对象对应池中的一个句柄。句柄中包含两个指针:
- 指向对象实例数据的指针(对象在堆 Eden/Old 区的地址)
- 指向对象类型数据的指针(Klass Pointer,指向方法区的类元数据)
栈上的 reference 存储的是句柄池中对应句柄的地址,而不是对象的直接地址。
栈 reference → 句柄池[i] ──┬── → 实例数据(堆)
└── → 类型数据(方法区)
优点:当 GC 移动对象(压缩整理后对象地址变化)时,只需更新句柄池中的实例数据指针,所有持有该对象 reference 的栈帧、其他对象,都不需要更新——它们存储的是句柄地址,句柄地址没变。
缺点:每次对象访问(字段读取、方法调用)需要两次内存间接寻址(reference → 句柄 → 对象),相比直接指针多一次指针追踪,性能较低。句柄池本身也需要额外的内存空间。
4.3 直接指针方式(Direct Pointer)
工作原理:栈上的 reference 直接存储对象在堆中的地址(直接指针)。对象头中的 Klass Pointer 存储类型信息指针。
栈 reference ──────────→ 实例数据(堆)
对象头中有 Klass Pointer → 类型数据(方法区)
优点:字段访问只需一次内存间接寻址(reference → 对象),速度快。不需要额外的句柄池空间。
缺点:GC 移动对象时,必须更新所有指向该对象的引用(栈上、其他对象的引用字段),扫描更新代价高(但实际上 GC 本来就需要遍历所有引用,顺手更新并无额外负担)。
4.4 HotSpot 的选择——直接指针
HotSpot VM 选择了直接指针方式。理由很简单:对象访问是程序中最频繁的操作之一(方法调用、字段读写),每次访问节省一次指针解引用的收益非常显著,远大于 GC 时更新指针的代价(GC 本来就要遍历所有引用)。
这也解释了为什么 HotSpot 的对象头需要包含 Klass Pointer——因为没有了句柄池,类型信息只能存在对象头中。
设计哲学
句柄方式是”稳定性优先”的设计(GC 更新代价低),直接指针是”性能优先”的设计(访问代价低)。HotSpot 选择直接指针,是对”对象访问频率远高于 GC 频率”这一事实的理性回应。
第 5 章 用 JOL 验证上述理论
Java Object Layout(JOL)是 OpenJDK 官方提供的对象布局分析工具,可以精确显示对象的内存布局:
<!-- Maven 依赖 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class JolDemo {
static class Demo {
int a;
long b;
boolean c;
}
public static void main(String[] args) {
// 查看 JVM 信息(包括指针大小)
System.out.println(VM.current().details());
// 未加锁时的对象布局
Demo obj = new Demo();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 加锁后 Mark Word 的变化
synchronized (obj) {
System.out.println("Locked: " + ClassLayout.parseInstance(obj).toPrintable());
}
}
}这个工具输出的是实测结果,不是理论值,可以用来验证不同 JVM 参数(开启/关闭 CompressedOops、不同 JDK 版本)对对象布局的影响,是学习和调试 JVM 内存问题的利器。
第 6 章 总结
一个 Java 对象的完整生命周期,从内存视角看是:
创建:类加载检查 → 堆内存分配(指针碰撞/空闲列表,通过 TLAB 减少并发竞争)→ 零值初始化 → 设置对象头(Mark Word + Klass Pointer)→ 执行 <init>。
内存布局:对象头(Mark Word 8 字节 + Klass Pointer 4/8 字节)+ 实例数据(字段重排序以减少填充)+ 对齐填充(补齐至 8 的整数倍)。Mark Word 通过最低位标志位实现多路复用,同时承载 HashCode、GC 年龄、锁状态等多种信息。
CompressedOops:将 32 位压缩指针左移 3 位寻址真实地址,以 32GB 为上限,在堆 ≤ 32GB 时自动启用,大幅降低 64 位 JVM 的引用字段内存开销。
访问定位:HotSpot 选择直接指针方案,reference 直接指向对象实例数据,对象头中的 Klass Pointer 再指向类型元数据,一次间接寻址完成对象访问,性能最优。
下一篇 04 垃圾回收基础——可达性分析、安全点与安全区域 将从对象的”出生”转向”死亡”——GC 如何判断对象是否已死,安全点与安全区域如何让 JVM 在不破坏一致性的前提下发起 GC。
参考文献
- Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”, Section 2.7: Representation of Objects
- 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 2.3 章:HotSpot 虚拟机对象探秘
- Aleksey Shipilev, “JVM Anatomy Quark #9: JVM Object Header”, shipilev.net, 2018
- Aleksey Shipilev, “Down the Rabbit Hole: CompressedOops”, shipilev.net, 2014
- OpenJDK Wiki, “CompressedOops”, wiki.openjdk.org
- JOL (Java Object Layout) 工具文档, openjdk.org/projects/code-tools/jol/
思考题
- HotSpot 中 Java 对象的内存布局分为三部分:对象头(Mark Word + Klass Pointer)、实例数据和对齐填充。在 64 位 JVM 开启压缩指针(
-XX:+UseCompressedOops)时,Klass Pointer 从 8 字节压缩为 4 字节。压缩指针的原理是什么?当堆大小超过 32GB 时为什么压缩指针会失效?- 对象创建时,JVM 优先在 TLAB(Thread-Local Allocation Buffer)中分配。TLAB 是每个线程在 Eden 区的私有分配区域,避免了多线程竞争。如果一个对象的大小超过了当前 TLAB 的剩余空间,JVM 有哪几种处理策略?TLAB 的大小是固定的还是动态调整的?
- HotSpot 使用’直接指针’方式访问对象(引用直接指向对象地址),而 IBM OpenJ9 使用’句柄’方式(引用指向句柄表,句柄再指向对象)。GC 移动对象后,直接指针方式需要更新所有引用,而句柄方式只需更新句柄表。为什么 HotSpot 仍然选择直接指针而非句柄?