Skip to content

04.2 - 对象内存布局

定位: HotSpot 虚拟机中对象在堆上的 3 段式内存结构 —— 对象头 · 实例数据 · 对齐填充 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 对象头结构、MarkWord 各字段含义、压缩指针原理、GC 年龄上限 15 的原因

一、这是什么?为什么需要它?

每个 Java 对象在堆上都是一段连续的内存。这段内存不是随意摆放的——它有严格的三段式结构。理解这个结构是理解 JVM 锁升级、GC 年龄、类型识别、内存占用 的基石。

┌─────────────────────────────────────┐  ← low address
│         对象头 (Object Header)        │
│  ┌─────────────────────────────────┐ │
│  │  MarkWord          (8 bytes)    │ │  ← GC 年龄 / 锁状态 / hashCode
│  ├─────────────────────────────────┤ │
│  │  Klass Pointer     (4-8 bytes)  │ │  ← 指向方法区类元数据
│  └─────────────────────────────────┘ │
├─────────────────────────────────────┤
│         实例数据 (Instance Data)       │  ← 实例字段值(按宽度排序)
├─────────────────────────────────────┤
│         对齐填充 (Padding)            │  ← 补足到 8 字节对齐
└─────────────────────────────────────┘  ← high address

HotSpot 为什么这样布局? 这段结构不是 JVM 规范的强制要求,而是 HotSpot 的设计选择。它追求的目标是:

  1. 运行时信息高效访问:MarkWord 把 GC 年龄、锁、hashCode 这些高频更新数据放在对象最开头,偏移量为 0,单条指令即可访问
  2. 类型快速识别:Klass Pointer 让 JVM 能从任何对象实例快速定位到它的类元数据(虚方法表、字段偏移等)
  3. 内存对齐:8 字节对齐保证 64 位 CPU 访问效率

二、原理拆解

2.1 MarkWord —— 8 字节的动态元数据

MarkWord 是整个 JVM 中最精妙的设计之一。8 个字节根据不同的锁状态,承载完全不同的含义

无锁状态 (01):
┌──────────────┬─────────────────────┬────────┬──────┬────────┬────────┐
│  unused:25   │  identity_hashcode  │unused:1│ age  │biased  │  lock  │
│              │     (31 bits)       │        │ (4b) │(1b) =0 │  (2b)=01│
└──────────────┴─────────────────────┴────────┴──────┴────────┴────────┘

偏向锁 (01, biased=1):
┌─────────────────────────────────────┬──────┬────────┬────────┐
│          thread ID + epoch           │ age  │biased  │  lock  │
│             (54 bits)                │ (4b) │(1b) =1 │  (2b)=01│
└─────────────────────────────────────┴──────┴────────┴────────┘

轻量级锁 (00):
┌───────────────────────────────────────────────────┬────────┐
│      指向栈中 Lock Record 的指针 (62 bits)          │  lock  │
│                                                   │  (2b)=00│
└───────────────────────────────────────────────────┴────────┘

重量级锁 (10):
┌───────────────────────────────────────────────────┬────────┐
│      指向 ObjectMonitor 对象的指针 (62 bits)        │  lock  │
│                                                   │  (2b)=10│
└───────────────────────────────────────────────────┴────────┘

GC 标记 (11):
┌───────────────────────────────────────────────────┬────────┐
│      留空 (GC 线程使用)                             │  lock  │
│                                                   │  (2b)=11│
└───────────────────────────────────────────────────┴────────┘

为什么 8 字节能存这么多信息? 因为 MarkWord 被设计为一个"多路复用"字段——不同状态下 bit 布局不同。同一块内存,在无锁时存 hashCode,在偏向锁时存线程 ID,在重量级锁时存 ObjectMonitor 指针。这是一个极端的内存复用优化。

GC 年龄: 为什么最大值是 15?

  age 字段 = 4 位 (bits)
  2^4 - 1 = 15
  → MaxTenuringThreshold 的取值范围: 0 - 15

这不是"JVM 故意设的",而是硬件约束。MarkWord 总共只有 64 位,每个比特都很珍贵,age 只分配了 4 bit。如果想让对象年龄超过 15,需要更多 bit —— 更不可能找到地方放。

bash
# 尝试设为 16 → 直接报错
$ java -XX:MaxTenuringThreshold=16 -version
MaxTenuringThreshold of 16 is invalid; must be between 0 and 15

Identity HashCode 的延迟计算

identity_hashcode 不是构造函数中计算的——它是惰性计算 (lazy) 的,只有首次调用 System.identityHashCode(obj)obj.hashCode()(且该类的 hashCode() 没有被覆盖时)才生成。一旦生成,31 位的 hashcode 就被写入 MarkWord。

影响锁升级:如果对象的 identity hashcode 已经被计算过(MarkWord 中已有 hashCode),它就不能进入偏向锁状态了——因为偏向锁要重用 hashCode 的 bit 来存线程 ID。这就是为什么 hashCode() 被覆盖的对象仍然可以偏向,但 identity hashcode 被调用过的对象失去了偏向能力。

2.2 Klass Pointer —— 指向方法区类元数据

Klass Pointer 指向方法区(Metaspace)中该对象的类元数据。JVM 通过它才能知道:

  • 对象的真实类是什么(获取 Class<?> 对象)
  • 对象的虚方法表在哪里(实现多态分派)
  • 对象有哪些字段及偏移量
对象实例                          Metaspace (方法区)
┌──────────────────┐            ┌────────────────────────┐
│  MarkWord        │            │  Klass / Class 元数据    │
│  KlassPtr ───────────────→    │  ├─ 类名: java.lang.Xxx │
│  实例数据         │            │  ├─ 虚方法表 (vtable)    │
└──────────────────┘            │  ├─ 字段偏移量表         │
                                 │  ├─ 接口方法表 (itable)  │
                                 │  └─ 常量池 (动态解析后)  │
                                 └────────────────────────┘

压缩指针 (Compressed Oops) 的原理

64 位指针占用 8 字节。每个对象都有一个 Klass Pointer,加上对象的引用字段(reference 类型),指针总占用相当可观。压缩指针把 8 字节指针压缩为 4 字节

未压缩 (heap >= 32GB 或 -XX:-UseCompressedOops):
Klass Pointer = 8 bytes
每个引用字段  = 8 bytes

压缩后 (heap < 32GB, 默认开启):
Klass Pointer = 4 bytes
每个引用字段  = 4 bytes

数学原理

对象地址按 8 字节对齐 → 地址的低 3 位永远是 0
  0x0000_0000_0000_0000  (8 的倍数: 0, 8, 16, 24...)
  0x0000_0000_0000_0008
  0x0000_0000_0000_0010  ← 低 3 bit 永远是 0

压缩指针 = 实际地址 >> 3  (右移 3 位, 去掉低 3 个 0)
实际地址 = 压缩指针 << 3  (左移 3 位, 补回 3 个 0)

32 位 (4 字节) 能表示的地址空间: 2^32 = 4G
左移 3 位后: 4G × 8 = 32GB

推理:因为对象 8 字节对齐,地址低 3 位永远是 0。JVM 存储时去掉这 3 个"送分"的 0,只存高 33+ 位(压缩到 4 字节)。读取时左移 3 位还原。4 字节指针能覆盖 4G × 8 = 32GB 的堆空间。

bash
# 手动控制压缩指针
-XX:+UseCompressedOops    # 启用 (JDK 7+ 默认开启)
-XX:-UseCompressedOops    # 禁用 (堆 > 32GB 时自动禁用)
-XX:+UseCompressedClassPointers  # 仅压缩 Klass Pointer (默认)

堆 > 32GB 时:压缩指针只能覆盖 32GB 地址范围。超过 32GB 后 JVM 自动关闭压缩指针,每个对象的 Klass Pointer 和引用字段都变成 8 字节——所以堆从 32GB 增加到 34GB,你反而丢失了 ~10% 的可用空间,因为指针膨胀了!

2.3 实例数据 —— 字段排列顺序

实例数据存储的是对象中声明的实例字段值。HotSpot 对其顺序做了优化——不是按声明顺序排列,而是按宽度分组排列

字段排序规则 (HotSpot):
1. doubles / longs     (8 字节)  ← 最宽的排最前
2. ints / floats       (4 字节)
3. shorts / chars      (2 字节)
4. bytes / booleans    (1 字节)
5. references          (4/8 字节) ← 指针排最后
6. 父类字段在子类字段之前

为什么这样排序?

按声明顺序 (有间隙):             按宽度排序 (紧凑):
┌──────────────────────┐        ┌──────────────────────┐
│ byte a  (偏移 0)      │        │ long d   (偏移 0)     │
│ padding (偏移 1-3)    │ ← 对齐  │ int b    (偏移 8)     │
│ int b   (偏移 4-7)    │        │ short c  (偏移 12)    │
│ long d  (偏移 8-15)   │        │ byte a   (偏移 14)    │
│ short c (偏移 16-17)  │        │ (无 padding 间隙!)    │
│ padding (偏移 18-19)  │ ← 对齐  └──────────────────────┘
│ ref e   (偏移 20-27)  │
└──────────────────────┘

总计: 28 字节 + ...               总计: 16 字节

如果按声明顺序,每个字段需要对齐到它自身宽度的倍数(int 对齐到 4,long 对齐到 8),产生大量 padding。按宽度排序将所有同宽度的字段放在一起,最大化压缩 padding 浪费。

这是编译器的优化,不是 JVM 规范要求——其他 JVM 实现可能有不同的排列策略。

2.4 对齐填充 —— 为什么必须 8 字节对齐?

HotSpot 要求对象起始地址必须是 8 字节的整数倍。对象实例数据 + 对象头的大小不一定恰好是 8 的倍数——不够就补 padding。

对象头: 12 字节 (8 字节 MarkWord + 4 字节压缩 KlassPtr)
实例数据: 5 字节
总和: 17 字节 → 需要 7 字节 padding → 总大小 24 字节

为什么必须 8 字节对齐?

  1. CPU 访问效率:64 位 CPU 访问 8 字节对齐的内存是最快的。不对齐的访问可能需要两次内存读取——CPU 自动处理对齐访问的硬件开销,但不对齐时要么触发异常(某些架构),要么降速(x86 有额外延迟)
  2. 原子操作longdouble 的 8 字节读写在 64 位 JVM 上保证原子性——前提是对齐到 8 字节
  3. 压缩指针依赖:正如 2.2 节所述,压缩指针正是利用 8 字节对齐的 3 位 0 来实现压缩的。如果没有对齐,压缩指针就不成立

三、图解全景

┌─────────────────────────────────────────────────────────────────────────┐
│                    HotSpot 对象内存布局 (64位, 压缩指针开启)               │
│                                                                         │
│  ┌─ 对象头 ──────────────────────────────────────────────────────────┐  │
│  │  ┌──────────────────────────────────────────────────────────────┐ │  │
│  │  │  MarkWord (8 字节 / 64 位)                                   │ │  │
│  │  │                                                              │ │  │
│  │  │  无锁(01,bias=0):  unused:25 | hashCode:31 | unused:1 |     │ │  │
│  │  │                     age:4 | 0 | 01                            │ │  │
│  │  │                                                              │ │  │
│  │  │  偏向锁(01,bias=1): threadID:54 | epoch | age:4 | 1 | 01    │ │  │
│  │  │                                                              │ │  │
│  │  │  轻量锁(00):       ptr_to_LockRecord:62 | 00                │ │  │
│  │  │                                                              │ │  │
│  │  │  重量锁(10):       ptr_to_ObjectMonitor:62 | 10             │ │  │
│  │  │                                                              │ │  │
│  │  │  GC标记(11):       (GC 线程使用) | 11                       │ │  │
│  │  └──────────────────────────────────────────────────────────────┘ │  │
│  │  ┌──────────────────────────────────────────────────────────────┐ │  │
│  │  │  Klass Pointer (4 字节, 压缩开启)                            │ │  │
│  │  │  指向 Metaspace → 类元数据 / 虚方法表 / 字段偏移             │ │  │
│  │  └──────────────────────────────────────────────────────────────┘ │  │
│  │  ┌──────────────────────────────────────────────────────────────┐ │  │
│  │  │  [数组长度] (4 字节, 仅数组对象)                             │ │  │
│  │  └──────────────────────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌─ 实例数据 ───────────────────────────────────────────────────────┐  │
│  │  按宽度排序:                                                     │  │
│  │  ┌───────┬───────┬───────┬───────┬────────┐                     │  │
│  │  │long/d │int/f  │short/c│byte/b │ ref    │   ← 同宽度放一起    │  │
│  │  │8 bytes│4 bytes│2 bytes│1 byte │4/8byte│                     │  │
│  │  └───────┴───────┴───────┴───────┴────────┘                     │  │
│  │  父类字段在子类字段之前                                          │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌─ 对齐填充 ──────────────────────────────────────────────────────┐  │
│  │  填充到 8 字节整数倍                                              │  │
│  │  例如: 对象总计 22 字节 → 填充 2 字节 → 24 字节                  │  │
│  └───────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

四、实战验证

验证 1: JOL 展示混合字段类型的对象布局

java
import org.openjdk.jol.info.ClassLayout;

public class ObjectLayoutDemo {
    // 按不规律顺序声明字段
    boolean flag = true;        // 1 byte
    int count = 100;            // 4 bytes
    long id = 12345L;           // 8 bytes
    String name = "test";       // reference (4 bytes with compressed)
    short level = 1;            // 2 bytes
    byte tag = 42;              // 1 byte
    
    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(ObjectLayoutDemo.class).toPrintable());
    }
}

预期输出(64位, 压缩指针开启):

ObjectLayoutDemo object internals:
 OFFSET  SIZE    TYPE DESCRIPTION                    VALUE
      0     4         (object header: mark)          0x00000001 (non-biasable; age: 0)
      4     4         (object header: class)          0x0000c345 (指向 ObjectLayoutDemo 元数据)
      8     8   long  ObjectLayoutDemo.id             12345
     16     4    int  ObjectLayoutDemo.count          100
     20     2  short  ObjectLayoutDemo.level          1
     22     1   byte  ObjectLayoutDemo.tag            42
     23     1   byte  ObjectLayoutDemo.flag           1 (true)
     24     4        (loss due to the next object alignment)
     28     4   ref   ObjectLayoutDemo.name           (compressed oop)
     32               (object alignment boundary)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

观察重点:

  • long id(8 字节)排在最前
  • int count 排第二
  • shortbyteboolean 紧凑排列在 20-23 偏移位置
  • 引用字段 name 排在实例数据最后
  • 4 字节 padding 补齐到 32 字节(8 的倍数)

验证 2: 压缩指针的影响

bash
# 开启压缩指针 (默认)
java -XX:+UseCompressedOops -jar jol-demo.jar
# KlassPointer: 4 bytes → 对象总大小更小

# 关闭压缩指针
java -XX:-UseCompressedOops -jar jol-demo.jar
# KlassPointer: 8 bytes → 每个引用字段也变大

# 堆超过 32GB (压缩指针自动关闭)
java -Xmx34g -jar jol-demo.jar
# 注意: 堆仅增大 2GB, 但每个对象膨胀约 10-15%

五、面试视角

追问答案要点
对象头包含什么?MarkWord(8字节:GC年龄、锁状态、hashCode、偏向线程ID)+ Klass Pointer(4-8字节:指向方法区类元数据)+ 数组长度(仅数组对象有)
MarkWord 在不同锁状态下存什么?无锁:hashCode + age;偏向锁:线程ID + epoch;轻量锁:LockRecord指针;重量锁:ObjectMonitor指针;GC标记:留空给GC线程。锁标记位(最后2位)区分状态。
为什么压缩指针能把 8 字节压到 4 字节?对象 8 字节对齐,地址低 3 位永远是 0。JVM 存储时去掉这 3 个 0(右移 3 位),读取时补回(左移 3 位)。4 字节 × 8 = 32GB,所以 ≤32GB 堆时可用。
GC 年龄为什么最大 15?MarkWord 的 age 字段只有 4 bit。2^4 - 1 = 15。这是硬件位宽约束,不是 JVM 拍脑袋定的。
堆超过 32GB 有什么问题?压缩指针自动关闭,KlassPointer 和所有引用字段从 4 字节膨胀到 8 字节。多出的堆空间部分被指针膨胀抵消(约 10-15% 损失)。
identity hashCode 对锁升级有什么影响?hashCode 一旦计算就会写入 MarkWord。偏向锁需要 hashCode 的 bit 来存线程 ID——所以对象 hash 被调用后就无法进入偏向锁状态,直接进入轻量锁。
为什么字段要按宽度重新排序?减少 padding 空间。同宽度字段紧贴排列避免对齐间隙。这是编译器的空间优化,非 JVM 规范强制。

📚 相关链接

  • **对象创建过程** — 对象创建时对象头是如何设置的
  • **方法区与元空间** — Klass Pointer 指向的目标区域
  • **Java堆** — 对象在堆中的存放位置
  • **方法内联和逃逸分析** — 对象布局影响 JIT 标量替换决策
  • **垃圾收集器详解** — GC 依赖 MarkWord 的 GC 年龄和标记信息
  • ← 返回 **对象分配索引**

Knowledge4J — Java 知识库