对象内存布局
问题
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 位(锁标志) |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashCode | unused | GC 年龄 | 0 | 01 |
| 偏向锁 | ThreadID (54位) | Epoch (2位) | GC 年龄 | 1 | 01 | |
| 轻量级锁 | 指向栈中锁记录的指针 (62位) | 00 | ||||
| 重量级锁 | 指向 Monitor 的指针 (62位) | 10 | ||||
| GC 标记 | 空 | 11 |
- 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)
对象真正存储的有效信息—字段值。字段存储顺序受 分配策略 影响:
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 / byte | 1 字节 |
char / short | 2 字节 |
int / float | 4 字节 |
long / double | 8 字节 |
| 引用类型 | 4 字节(压缩指针)或 8 字节 |
字段重排序(-XX:FieldsAllocationStyle):HotSpot 默认按从大到小排定字段顺序(longs/doubles → ints/floats → shorts/chars → bytes/booleans → references),而非代码中的声明顺序。这样做是为了减少填充,节省内存。
对齐填充(Padding)
HotSpot 要求对象大小必须是 8 字节的整数倍。如果对象头 + 实例数据不是 8 的倍数,需要填充。
// 对象内存计算示例
public class PaddingExample {
private int a; // 4 字节
}
// 内存布局(压缩指针开启):
// 对象头: 12 字节 (Mark Word 8 + Klass Pointer 4)
// 实例数据: 4 字节 (int a)
// 对齐填充: 0 字节 (12 + 4 = 16,已是 8 的倍数)
// 总计: 16 字节
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 位)可以寻址 的堆空间。
- 堆 < 32GB:自动开启指针压缩,引用 4 字节
- 堆 ≥ 32GB:指针压缩失效,引用变为 8 字节
- 因此从 31GB 跳到 33GB 的堆可能导致实际可用内存反而减少(指针占用增加约 50%)
- 建议堆大小不要超过 32GB,或者直接跳到 48GB 以上
使用 JOL 查看对象布局
JOL(Java Object Layout) 是 OpenJDK 提供的查看对象内存布局的工具:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
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 Word | 8 字节 |
| 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 位能表示的最大值是 。
Q4: 什么是指针压缩?为什么堆最好不超过 32GB?
答案:
64 位 JVM 中引用默认占 8 字节,指针压缩(CompressedOops)将其压缩为 4 字节。原理是利用 8 字节对齐的特性,地址右移 3 位存储。
4 字节(32 位)+ 3 位移位 = 可寻址 。堆超过 32GB 后指针压缩失效,所有引用变为 8 字节,对象内存占用增加约 50%。可能出现 33GB 堆的可用空间反而比 31GB 更少的情况。
Q5: 什么是字段重排序?
答案:
HotSpot 默认不按照代码中字段的声明顺序存储,而是按字段大小从大到小排列(longs → ints → shorts → bytes → references)。这样做可以减少对齐填充,节省内存。
可以通过 -XX:FieldsAllocationStyle 参数改变策略,但一般不需要调整。
Q6: 数组对象和普通对象在内存中有什么区别?
答案:
数组对象比普通对象多了一个 4 字节的数组长度字段,位于类型指针之后。
| 普通对象 | 数组对象 | |
|---|---|---|
| Mark Word | 8 字节 | 8 字节 |
| Klass Pointer | 4 字节 | 4 字节 |
| 数组长度 | — | 4 字节 |
| 实例数据/数组元素 | 字段值 | 元素值 |
| 对齐填充 | 凑齐 8 倍数 | 凑齐 8 倍数 |
这也是为什么数组最大长度受 int 范围限制()—— 数组长度字段占 4 字节。