对象内存布局深度解析
在写代码的时候,你有没有想过:一个 Java 对象到底占多少内存?new Object() 真的只占用 16 字节吗?为什么要对齐填充?
这些问题都和 JVM 对象的内存布局有关。很多同学知道对象存在堆里,但问到对象头包含什么、字段怎么排列、为什么要对齐,就容易答不上来。
今天我们把这个知识点彻底讲透。
一、真实面试场景
候选人小张在面试阿里的时候,被问到这样一个问题:
"你知道一个对象在内存中是怎么布局的吗?"
小张说:"对象存在堆里,有对象头和实例数据..."
面试官追问:"对象头包含什么?Mark Word 是什么?"
小张开始支支吾吾。
面试官又问:"那为什么要对齐填充?为什么对象大小要是 8 的倍数?"
小张完全答不上来。
【面试官心理】
这道题我用来测试候选人对 JVM 底层机制的深入理解。知道"对象头"的占 50%,能说清 Mark Word 结构的占 20%,能解释对齐填充原因的只有 10%。
二、对象内存布局总览
在 HotSpot 虚拟机中,一个 Java 对象的内存布局分为三部分:
graph LR
OBJ["对象内存布局"]
OBJ --> HEADER["对象头 (Object Header)"]
OBJ --> INSTANCE["实例数据 (Instance Data)"]
OBJ --> PADDING["对齐填充 (Padding)"]
3.1 Mark Word
Mark Word 是对象头中最核心的部分,占 8 字节(64位 JVM)。它是一个"可变"的数据结构,用于存储对象自身运行时的数据:
public class MarkWordDemo {
public static void main(String[] args) {
Object obj = new Object();
// Object 对象在没有偏向锁/轻量级锁的情况下:
// Mark Word 存储:对象哈希码(25位) + GC年龄(4位) + 锁标志位(4位) + 偏向锁标志(1位)
// 当对象被用作锁对象时,Mark Word 会根据锁状态发生变化
synchronized (obj) {
// 进入同步块,Mark Word 可能变成轻量级锁或偏向锁
}
}
}
3.2 Class Pointer(类型指针)
Class Pointer 指向对象的类元数据(方法区中的 Class 对象),JVM 通过这个指针确定对象是哪个类的实例。
public class ClassPointerDemo {
public static void main(String[] args) {
User user = new User();
// user 对象的 Class Pointer 指向 User.class 元数据
// JVM 通过这个指针确定 user 是 User 类的实例
System.out.println(user.getClass()); // class User
}
}
3.3 对象头大小总结
四、实例数据(Instance Data)
4.1 字段排列规则
HotSpot 虚拟机默认按照以下顺序排列字段:
- 父类字段(按照继承顺序,从最顶层父类开始)
- 子类字段
- 同一类中,按照声明顺序排列
字段排序原则:为了减少对齐填充,字段按照以下优先级排列:
- 宽类型在前:long、double
- 中等类型:int、float
-窄类型:char、short、byte、boolean
- 引用类型
public class FieldAlignment {
// 父类
String name; // 引用类型
int id; // int
public static void main(String[] args) {
// 实际内存布局(开启压缩指针):
// 对象头:12 字节
// id (int): 4 字节
// name (引用): 4 字节(如果是对象引用)
// 对齐填充:0-3 字节,确保总大小是 8 的倍数
}
}
4.2 各类型字段大小
4.3 ❌ 常见错误:字段排列导致的内存浪费
public class BadFieldOrder {
// 错误排列:窄类型在前,导致大量对齐填充
boolean flag1;
boolean flag2;
boolean flag3;
long id; // 前面 3 个 boolean 需要 3 字节,long 需要对齐到 8
// 对齐填充 5 字节
// 实际占用:(3 + 5) + 8 = 16 字节的填充
}
正确排列:
public class GoodFieldOrder {
// 正确排列:宽类型在前
long id; // 8 字节
boolean flag1; // 1 字节
boolean flag2; // 1 字节
boolean flag3; // 1 字节
// 对齐填充:1 字节
// 总共:12 字节
// 相比错误排列节省了 4 字节
}
五、对齐填充(Padding)
5.1 为什么需要对齐?
CPU 访问内存时,以字长(如 64 位 = 8 字节)为单位访问效率最高。如果对象的起始地址不对齐,访问一个 8 字节的 long 类型可能需要两次内存访问。
graph TB
subgraph "对齐前(地址 7)"
BYTE1["B"]
BYTE2["B"]
BYTE3["B"]
BYTE4["B"]
BYTE5["B"]
BYTE6["B"]
BYTE7["L"]
BYTE8["L"]
style BYTE7 fill:#ff9999
style BYTE8 fill:#ff9999
end
subgraph "对齐后(地址 8)"
PAD["Pad"]
LONG1["L"]
LONG2["L"]
LONG3["L"]
LONG4["L"]
style PAD fill:#cccccc
end
5.2 对齐规则
HotSpot 要求对象大小是 8 字节的倍数。如果对象头 + 实例数据不是 8 的倍数,则添加对齐填充。
public class PaddingDemo {
public static void main(String[] args) {
// 开启压缩指针时:
// new Object() 占用 16 字节
// 对象头:12 字节
// 对齐填充:4 字节
// 总共:16 字节
Object obj = new Object();
// new Integer(0) 占用多少?
// 对象头:12 字节
// int value:4 字节
// 对齐填充:0 字节(12 + 4 = 16)
// 总共:16 字节
}
}
六、数组对象布局
数组对象比普通对象多一个 length 字段:
graph TB
ARRAY["数组对象内存布局"]
ARRAY --> HEADER["对象头 (12/16 字节)"]
ARRAY --> LENGTH["数组长度 (4 字节)"]
ARRAY --> ELEMENTS["数组元素"]
ARRAY --> PADDING["对齐填充"]
public class ArrayLayoutDemo {
public static void main(String[] args) {
// int[] 数组的内存布局(开启压缩指针):
// 对象头:12 字节
// 数组长度:4 字节
// 元素:n * 4 字节
// 对齐填充:确保总大小是 8 的倍数
int[] arr = new int[10];
// 大小:12 + 4 + 40 = 56 字节
}
}
七、压缩指针(Compressed Oops)
7.1 什么是压缩指针?
在 64 位 JVM 中,引用类型占用 8 字节。但如果开启了压缩指针(-XX:+UseCompressedOops,JDK 8 默认开启),引用类型可以压缩到 4 字节。
7.2 压缩指针的限制
# 开启压缩指针(默认)
java -XX:+UseCompressedOops -Xmx30g -jar app.jar
# 关闭压缩指针
java -XX:-UseCompressedOops -Xmx30g -jar app.jar
💡
如果你的堆大小在 4GB-32GB 之间,建议开启压缩指针,可以显著减少对象引用占用的内存。但注意,这会增加 CPU 开销,因为每次访问引用都需要解压缩。
八、生产场景与优化
8.1 ❌ 错误示范:忽视对象大小
public class BadCache {
// 使用 ArrayList 存储数百万个 Point 对象
private List<Point> cache = new ArrayList<>();
public void addPoints() {
for (int i = 0; i < 1_000_000; i++) {
// Point 类有两个 int 字段
// 每个 Point 对象:12(头) + 4 + 4 + 4(对齐) = 24 字节
// 100万个 Point:24MB
cache.add(new Point(i, i));
}
}
}
class Point {
int x;
int y;
}
8.2 ✅ 正确示范:优化对象布局
public class GoodCache {
// 方案1:使用基本类型数组
private int[] xCoords;
private int[] yCoords;
// 方案2:使用压缩数据类
// 如果对象大小是 8 的倍数,可以节省对齐填充
class Point {
long xy; // 将两个 int 合并为一个 long
// 8 字节,无对齐填充
public int getX() {
return (int) (xy & 0xFFFFFFFFL);
}
public int getY() {
return (int) (xy >> 32);
}
}
}
8.3 工具推荐
使用 Jol(Java Object Layout)查看对象布局:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class JolDemo {
public static void main(String[] args) {
System.out.println(VM.current().details());
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
System.out.println(ClassLayout.parseInstance(new int[0]).toPrintable());
}
}
输出示例:
# WARNING: Unable to instrument all classes. HotSpot debugging VM required.
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION
0 8 (object header: mark)
8 4 (object header: class)
12 4 (object header: array length)
12 0 (object header: instance size)
12 4 (object header: alignment/padding gap)
Instance size: 16 bytes
九、面试追问链
第一层:基础概念
面试官问:"一个对象在内存中的布局是什么样的?"
标准回答:对象内存布局分为三部分:对象头(Mark Word + Class Pointer)、实例数据、对齐填充。对象头用于存储对象运行时的状态信息和类型指针。
第二层:Mark Word
面试官追问:"Mark Word 包含什么?它是如何变化的?"
需要说明:Mark Word 包含哈希码、GC 年龄、锁状态等信息。当对象被用作锁时,Mark Word 会根据锁类型(偏向锁、轻量级锁、重量级锁)发生变化。
第三层:对齐填充
面试官追问:"为什么要对齐填充?"
需要说明:CPU 以字长为单位访问内存效率最高,对齐可以确保对象大小是 8 的倍数,减少访问内存的次数。
第四层:压缩指针
面试官追问:"什么是压缩指针?有什么限制?"
需要说明:压缩指针将 8 字节的引用压缩为 4 字节,适用于堆大小 < 32GB 的情况。
【面试官心理】
这道题我用来测试候选人对 JVM 底层实现细节的理解程度。能说出对象头组成的占一半,能解释 Mark Word 变化的占 30%,能分析字段排列和对齐填充的只有 10%。这道题虽然偏底层,但对于性能调优很有帮助。
【学习小结】
- 对象内存布局:对象头 + 实例数据 + 对齐填充
- 对象头:Mark Word (8字节) + Class Pointer (4/8字节)
- Mark Word:存储哈希码、GC年龄、锁状态等信息
- 实例数据:字段按宽窄排序,减少对齐填充
- 对齐填充:确保对象大小是 8 的倍数
- 开启压缩指针(默认)可将引用压缩到 4 字节