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 有两种处理方式:

  1. CAS + 失败重试:用 CAS 原子地更新分配指针,失败则重试。简单,但高并发下竞争激烈。

  2. 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 标记11GC 标记信息(由 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 的工程意义

  1. 内存节省:64 位 JVM 开启压缩指针后,引用字段内存开销与 32 位 JVM 相同(均为 4 字节),配合 64 位的大堆,兼顾了内存效率与寻址能力。

  2. 缓存友好:同样大小的对象,压缩指针版本能在 CPU 缓存行中存下更多引用字段,缓存命中率更高。

  3. 为什么 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。


参考文献

  1. Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”, Section 2.7: Representation of Objects
  2. 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 2.3 章:HotSpot 虚拟机对象探秘
  3. Aleksey Shipilev, “JVM Anatomy Quark #9: JVM Object Header”, shipilev.net, 2018
  4. Aleksey Shipilev, “Down the Rabbit Hole: CompressedOops”, shipilev.net, 2014
  5. OpenJDK Wiki, “CompressedOops”, wiki.openjdk.org
  6. JOL (Java Object Layout) 工具文档, openjdk.org/projects/code-tools/jol/

思考题

  1. HotSpot 中 Java 对象的内存布局分为三部分:对象头(Mark Word + Klass Pointer)、实例数据和对齐填充。在 64 位 JVM 开启压缩指针(-XX:+UseCompressedOops)时,Klass Pointer 从 8 字节压缩为 4 字节。压缩指针的原理是什么?当堆大小超过 32GB 时为什么压缩指针会失效?
  2. 对象创建时,JVM 优先在 TLAB(Thread-Local Allocation Buffer)中分配。TLAB 是每个线程在 Eden 区的私有分配区域,避免了多线程竞争。如果一个对象的大小超过了当前 TLAB 的剩余空间,JVM 有哪几种处理策略?TLAB 的大小是固定的还是动态调整的?
  3. HotSpot 使用’直接指针’方式访问对象(引用直接指向对象地址),而 IBM OpenJ9 使用’句柄’方式(引用指向句柄表,句柄再指向对象)。GC 移动对象后,直接指针方式需要更新所有引用,而句柄方式只需更新句柄表。为什么 HotSpot 仍然选择直接指针而非句柄?