跳到主要内容

对象内存布局

问题

Java 对象在内存中是如何存储的?对象头包含哪些信息?什么是指针压缩?

答案

对象的内存结构

在 HotSpot 虚拟机中,一个 Java 对象在堆内存中由三部分组成:

组成部分说明大小
对象头Mark Word + 类型指针(+ 数组长度)12~16 字节(压缩指针下)
实例数据对象的字段值(包括父类继承的)取决于字段类型和数量
对齐填充保证对象大小是 8 字节的整数倍0~7 字节

对象头(Object Header)

对象头是对象内存中最复杂的部分:

1. Mark Word

存储对象运行时数据,大小与虚拟机位数相同(64 位 JVM 为 8 字节):

64 位 JVM 的 Mark Word 结构:

锁状态25 位31 位1 位4 位1 位(偏向锁标记)2 位(锁标志)
无锁unusedhashCodeunusedGC 年龄001
偏向锁ThreadID (54位)Epoch (2位)GC 年龄101
轻量级锁指向栈中锁记录的指针 (62位)00
重量级锁指向 Monitor 的指针 (62位)10
GC 标记11
Mark Word 的关键信息
  • hashCode:调用 hashCode() 后才会计算并存入(未调用时为 0)
  • GC 分代年龄:4 位,最大值 15,所以 -XX:MaxTenuringThreshold 最大 15
  • 锁标志位:用于标识当前对象的锁状态
  • 偏向锁线程 ID:偏向锁记录持有锁的线程(如果启用偏向锁)

2. 类型指针(Klass Pointer)

指向对象的类型元数据(方法区/元空间中的 Klass 结构),JVM 通过它确定对象是哪个类的实例:

指针压缩类型指针大小
开启(默认 < 32GB 堆)4 字节
关闭8 字节

3. 数组长度(仅数组对象)

如果对象是数组,对象头还包含 4 字节的数组长度信息。

完整的对象头大小:

对象类型指针压缩开启指针压缩关闭
普通对象12 字节(8 + 4)16 字节(8 + 8)
数组对象16 字节(8 + 4 + 4)24 字节(8 + 8 + 4 + 4 填充)

实例数据(Instance Data)

对象真正存储的有效信息—字段值。字段存储顺序受 分配策略 影响:

FieldOrder.java
public class FieldOrder {
private long l; // 8 字节
private int i; // 4 字节
private short s; // 2 字节
private byte b; // 1 字节
private boolean f; // 1 字节
private Object ref; // 4 字节(压缩指针)或 8 字节
}

各类型字段大小:

类型大小
boolean / byte1 字节
char / short2 字节
int / float4 字节
long / double8 字节
引用类型4 字节(压缩指针)或 8 字节

字段重排序-XX:FieldsAllocationStyle):HotSpot 默认按从大到小排定字段顺序(longs/doubles → ints/floats → shorts/chars → bytes/booleans → references),而非代码中的声明顺序。这样做是为了减少填充,节省内存。

对齐填充(Padding)

HotSpot 要求对象大小必须是 8 字节的整数倍。如果对象头 + 实例数据不是 8 的倍数,需要填充。

PaddingExample.java
// 对象内存计算示例
public class PaddingExample {
private int a; // 4 字节
}

// 内存布局(压缩指针开启):
// 对象头: 12 字节 (Mark Word 8 + Klass Pointer 4)
// 实例数据: 4 字节 (int a)
// 对齐填充: 0 字节 (12 + 4 = 16,已是 8 的倍数)
// 总计: 16 字节
PaddingExample2.java
public class PaddingExample2 {
private int a; // 4 字节
private byte b; // 1 字节
}

// 内存布局(压缩指针开启):
// 对象头: 12 字节
// 实例数据: 5 字节 (int 4 + byte 1)
// 对齐填充: 7 字节 (12 + 5 = 17,需要填充到 24)
// 总计: 24 字节

指针压缩(Compressed Oops)

64 位 JVM 默认开启指针压缩(-XX:+UseCompressedOops),将 8 字节的引用压缩为 4 字节:

参数说明条件
-XX:+UseCompressedOops压缩对象引用指针堆 < 32GB 时自动开启
-XX:+UseCompressedClassPointers压缩类型指针依赖 UseCompressedOops

原理:JVM 对象都是 8 字节对齐的,所以地址的低 3 位始终是 0。指针压缩通过右移 3 位存储,使用时左移 3 位恢复,用 4 字节(32 位)可以寻址 232×8=32GB2^{32} \times 8 = 32\text{GB} 的堆空间。

堆大小与指针压缩
  • 堆 < 32GB:自动开启指针压缩,引用 4 字节
  • 堆 ≥ 32GB:指针压缩失效,引用变为 8 字节
  • 因此从 31GB 跳到 33GB 的堆可能导致实际可用内存反而减少(指针占用增加约 50%)
  • 建议堆大小不要超过 32GB,或者直接跳到 48GB 以上

使用 JOL 查看对象布局

JOL(Java Object Layout) 是 OpenJDK 提供的查看对象内存布局的工具:

pom.xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
JOLExample.java
import org.openjdk.jol.info.ClassLayout;

public class JOLExample {
private int a;
private long b;
private boolean c;
private Object ref;

public static void main(String[] args) {
JOLExample obj = new JOLExample();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}

输出示例:

OFF  SZ      TYPE DESCRIPTION                VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00060828
12 4 int JOLExample.a 0
16 8 long JOLExample.b 0
24 1 boolean JOLExample.c false
25 3 (alignment/padding gap)
28 4 java.lang.Object JOLExample.ref null
Instance size: 32 bytes

对象的访问定位

JVM 规范没有规定栈上的引用如何定位堆中的对象,HotSpot 使用直接指针方式:

方式说明使用者
句柄访问栈→句柄池→对象实例 + 类型信息少数 JVM
直接指针栈→对象实例(内含类型指针→类型信息)HotSpot

直接指针优点:访问速度快(少一次间接寻址)
句柄优点:对象移动时只需修改句柄中的指针,栈上引用不变


常见面试问题

Q1: 一个 Object 对象占多少字节?

答案

new Object() 在开启指针压缩的 64 位 JVM 中占 16 字节

部分大小
Mark Word8 字节
Klass Pointer(压缩后)4 字节
实例数据0 字节(Object 没有字段)
对齐填充4 字节(凑齐 16 字节)
总计16 字节

Q2: 对象头中的 Mark Word 包含哪些信息?

答案

Mark Word 在 64 位 JVM 中占 8 字节,主要包含:

  • hashCode(31 位,无锁状态):调用 hashCode() 后生成
  • GC 分代年龄(4 位):最大 15,这也是 MaxTenuringThreshold 最大 15 的原因
  • 锁标志位(2 位):标识无锁(01)、偏向锁(01)、轻量级锁(00)、重量级锁(10)、GC标记(11)

在不同锁状态下,Mark Word 的内容会变化。进入偏向锁后,hashCode 被替换为线程 ID;进入重量级锁后,整个 Mark Word 指向 Monitor 对象。

Q3: 为什么 MaxTenuringThreshold 最大是 15?

答案

因为对象头 Mark Word 中用于存储 GC 分代年龄的字段只有 4 位(bit),4 位能表示的最大值是 241=152^4 - 1 = 15

Q4: 什么是指针压缩?为什么堆最好不超过 32GB?

答案

64 位 JVM 中引用默认占 8 字节,指针压缩(CompressedOops)将其压缩为 4 字节。原理是利用 8 字节对齐的特性,地址右移 3 位存储。

4 字节(32 位)+ 3 位移位 = 可寻址 235=32GB2^{35} = 32\text{GB}。堆超过 32GB 后指针压缩失效,所有引用变为 8 字节,对象内存占用增加约 50%。可能出现 33GB 堆的可用空间反而比 31GB 更少的情况。

Q5: 什么是字段重排序?

答案

HotSpot 默认不按照代码中字段的声明顺序存储,而是按字段大小从大到小排列(longs → ints → shorts → bytes → references)。这样做可以减少对齐填充,节省内存。

可以通过 -XX:FieldsAllocationStyle 参数改变策略,但一般不需要调整。

Q6: 数组对象和普通对象在内存中有什么区别?

答案

数组对象比普通对象多了一个 4 字节的数组长度字段,位于类型指针之后。

普通对象数组对象
Mark Word8 字节8 字节
Klass Pointer4 字节4 字节
数组长度4 字节
实例数据/数组元素字段值元素值
对齐填充凑齐 8 倍数凑齐 8 倍数

这也是为什么数组最大长度受 int 范围限制(23112^{31} - 1)—— 数组长度字段占 4 字节。

相关链接