Java 对象模型详解
全面讲解 HotSpot JVM 中的对象模型:对象内存布局(对象头/实例数据/对齐填充)、Mark Word 与锁标志位、锁升级机制(无锁→偏向锁→轻量级锁→重量级锁)、对象创建全过程、对象访问定位方式,以及 JOL 工具实战。
目录
- 概述
- 对象内存布局
- 对象头详解
- [Mark Word 与锁标志位](#Mark Word 与锁标志位)
- 锁升级机制
- 对象创建过程
- 对象访问定位
- [JOL 工具实战](#JOL 工具实战)
- 实际应用与优化
- 最佳实践与陷阱
- 版本演进
1. 概述
1.1 什么是 Java 对象模型
Java 对象模型描述的是:对象在 JVM 内存中如何表示、如何创建、如何访问、如何同步。它是理解 JVM 内存管理、GC、synchronized 原理、并发性能调优的基础。
本文以 HotSpot JVM(OpenJDK 参考实现,64 位)为准。
1.2 对象模型的三要素
┌─────────────────────────────────────────────┐
│ 对象 = 对象头(Header) + 实例数据(Data) + 对齐填充(Padding) │
├─────────────────────────────────────────────┤
│ 对象头 = Mark Word + Klass Pointer (+ 数组长度) │
├─────────────────────────────────────────────┤
│ 创建 = 类加载检查 → 分配内存 → 零值初始化 → 设置对象头 → <init> │
├─────────────────────────────────────────────┤
│ 访问 = reference → 对象地址(直接指针)→ Klass元数据 │
└─────────────────────────────────────────────┘
2. 对象内存布局
2.1 三大区域
| 区域 | 内容 | 是否必有 |
|---|---|---|
| 对象头(Object Header) | Mark Word + Klass Pointer + 数组长度 | 是 |
| 实例数据(Instance Data) | 父类继承字段 + 本类声明字段 | 可为空 |
| 对齐填充(Padding) | 补齐到 8 字节倍数 | 按需 |
2.2 典型对象大小(64位,开启压缩指针)
| 对象类型 | 大小 | 构成 |
|---|---|---|
空对象 new Object() |
16 字节 | 8(头) + 4(压缩类指针) + 4(填充) |
空数组 new int[0] |
16 字节 | 8(头) + 4(类指针) + 4(数组长度) |
| 含1个int | 16 字节 | 8(头) + 4(类指针) + 4(int) |
| 含1个long | 24 字节 | 8(头) + 4(类指针) + 4(填充) + 8(long) |
| 含1个引用 | 16 字节 | 8(头) + 4(类指针) + 4(压缩引用) |
关闭压缩指针(
-XX:-UseCompressedOops)时空对象为 16 字节(8+8),含1个long为 24 字节(8+8+8)。
2.3 字段存储顺序
HotSpot 默认按以下顺序分配字段空间(相同宽度的字段分配在一起,可减少空洞):
longs/doubles → ints → shorts/chars → bytes/booleans → oops(引用)
java
// 字段顺序不影响实际内存布局(JVM会重排),但了解原理有助于理解JOL输出
public class FieldOrderDemo {
private byte b; // 1字节
private int i; // 4字节
private long l; // 8字节
private Object ref; // 4字节(压缩指针)
public static void main(String[] args) {
// 实际布局:头(12) + long(8) + int(4) + byte(1) + 填充(3) + ref(4) + 填充(4)
// 总大小:约32字节(以JOL输出为准)
System.out.println("运行: 使用JOL查看真实布局");
}
}
3. 对象头详解
3.1 对象头构成
对象头 (Object Header)
├── Mark Word(标记字):8字节(64位)------ 哈希码、GC年龄、锁状态
├── Klass Pointer(类型指针):4字节(压缩开启)/ 8字节 ------ 指向类元数据
└── 数组长度:4字节(仅数组对象有)
3.2 Klass Pointer(类型指针)
- 指向方法区(JDK8+ 为 Metaspace)中该类的 Klass 元数据结构
- 通过它可以获取:Class 对象、虚方法表(vtable)、字段布局、父类信息
-XX:+UseCompressedClassPointers(默认开启)时占 4 字节,否则 8 字节- 对象的方法调用(invokevirtual)依赖 Klass 中的 vtable 完成动态分派
3.3 数组长度
- 普通对象不需要存储长度(字段数编译期已定,写在类元数据中)
- 数组元素个数可变,因此数组对象头额外用 4 字节记录长度
array.length读取的就是这个值,O(1)
4. Mark Word 与锁标志位
4.1 Mark Word 的结构(64位)
Mark Word 是对象头中最复杂的部分,内容随对象状态动态变化(锁升级的本质就是改写 Mark Word):
| 锁状态 | 内容 | 标志位(低2-3位) |
|---|---|---|
| 无锁 | hashCode(31) + 分代年龄(4) + 偏向标志0 + 锁标志01 | 0 01 |
| 偏向锁 | 线程ID(54) + epoch(2) + 分代年龄(4) + 偏向标志1 + 锁标志01 | 1 01 |
| 轻量级锁 | 指向栈中锁记录的指针(62) + 锁标志00 | 00 |
| 重量级锁 | 指向Monitor的指针(62) + 锁标志10 | 10 |
| GC标记 | 空 | 11 |
4.2 hashCode 的存储
java
public class HashCodeDemo {
public static void main(String[] args) {
Object obj = new Object();
// 首次调用hashCode()时才计算并写入Mark Word(惰性计算)
int hash = obj.hashCode();
System.out.println("hashCode: " + hash);
// Mark Word中只有31位存hashCode(无锁状态),最高位符号位不用
// 因此identity hashCode一定是正数
System.out.println("identity hashCode恒为正: " + (System.identityHashCode(new Object()) >= 0));
}
}
要点:
- 无锁状态 才存储 identity hashCode;偏向锁/轻量级锁状态下 Mark Word 空间被占用,此时调用
hashCode()会撤销偏向锁或膨胀为重量级锁(需要空间存哈希码) - 重写
hashCode()方法不影响 Mark Word(那是方法调用,不存对象头)
4.3 分代年龄
- 4 位存储,最大值 15------这就是
-XX:MaxTenuringThreshold最大只能设为 15 的原因 - 对象每熬过一次 Minor GC,年龄 +1,达到阈值晋升老年代
5. 锁升级机制
5.1 背景:为什么要锁升级
JDK 6 之前,synchronized 直接调用操作系统的互斥量(mutex),每次加锁解锁都涉及用户态→内核态切换,性能很差。JDK 6 引入了锁升级:根据竞争激烈程度,逐步升级锁的实现方式,让无竞争/低竞争场景接近零开销。
5.2 升级路径
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
(单向升级,不可降级;偏向锁在JDK15+默认关闭)
5.3 各锁状态详解
无锁状态 :对象刚创建,无线程同步。Mark Word 存 hashCode + 分代年龄,标志位 01。
偏向锁(Biased Lock):
- 思想:如果锁总是被同一个线程获取,那就把锁"偏向"给它,后续进入无需任何同步操作
- 首次获取:CAS 将当前线程 ID 写入 Mark Word
- 再次进入:只比较线程 ID 是否一致(无 CAS,近乎零开销)
- 撤销:出现第二个竞争线程时,在安全点撤销偏向(开销较大)
- 适用:单线程反复进入同步块的场景(如遍历 Vector)
- 现状:JDK 15 起废弃(JEP 374),现代应用中撤销开销往往超过收益
轻量级锁(Lightweight Lock):
- 前提:线程交替执行(竞争但不并发)
- 获取:线程在栈帧中创建 Lock Record,用 CAS 尝试把 Mark Word 复制进去并替换为指向 Lock Record 的指针
- 成功:持有锁,标志位变为
00 - 失败:自旋等待(自适应自旋
-XX:+UseAdaptiveSpinning,JVM 根据历史成功率调整自旋次数) - 自旋超限:膨胀为重量级锁
重量级锁(Heavyweight Lock):
- 关联一个
ObjectMonitor(C++ 对象,底层可用 OS mutex 实现) - Mark Word 指向 Monitor,标志位
10 - 未获取锁的线程进入 EntryList 阻塞(涉及用户态→内核态切换)
wait()的线程进入 WaitSet,notify()唤醒后重新竞争- 适合真正高竞争场景
5.4 完整代码演示
java
public class LockUpgradeDemo {
private final Object lock = new Object();
// 场景1:单线程进入------JDK8默认经历偏向锁(JDK15+直接轻量级或锁消除)
public void singleThread() {
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
// 同一线程反复进入:偏向锁下几乎零开销
}
}
}
// 场景2:两线程交替------轻量级锁(自旋)
public void alternating() throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) { /* 临界区极短 */ }
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) { /* 临界区极短 */ }
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
// 场景3:两线程同时长时间持有------膨胀为重量级锁
public void contention() throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
synchronized (lock) {
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
synchronized (lock) {
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
}
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
public static void main(String[] args) throws Exception {
LockUpgradeDemo demo = new LockUpgradeDemo();
demo.singleThread();
System.out.println("单线程完成");
demo.alternating();
System.out.println("交替完成");
demo.contention();
System.out.println("竞争完成");
}
}
5.5 观察锁状态(结合 JOL)
java
import org.openjdk.jol.info.ClassLayout;
public class LockStateObserve {
public static void main(String[] args) throws Exception {
Object obj = new Object();
// 无锁状态
System.out.println("=== 创建后(无锁)===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
// 单线程持有:偏向锁(JDK15前)或轻量级锁
System.out.println("=== synchronized块内 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
// hashCode调用后(偏向锁被撤销)
obj.hashCode();
System.out.println("=== hashCode调用后 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
5.6 wait/notify 与锁
java
public class WaitNotifyDemo {
private final Object lock = new Object();
private boolean ready = false;
public static void main(String[] args) throws InterruptedException {
WaitNotifyDemo demo = new WaitNotifyDemo();
Thread waiter = new Thread(() -> {
synchronized (demo.lock) { // 必须先持有锁
while (!demo.ready) { // 用while防止虚假唤醒
try {
demo.lock.wait(); // 释放锁并进入WaitSet
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("被唤醒,继续执行");
}
});
waiter.start();
Thread.sleep(100);
synchronized (demo.lock) {
demo.ready = true;
demo.lock.notify(); // 唤醒一个等待线程
}
waiter.join();
}
}
必须在持有锁时调用
wait()/notify(),否则抛IllegalMonitorStateException。wait 会释放锁 (这是与LockSupport.park()的关键区别)。
6. 对象创建过程
new 一条指令背后是五个步骤:
1. 类加载检查 → 2. 分配内存 → 3. 零值初始化 → 4. 设置对象头 → 5. 执行<init>
6.1 第一步:类加载检查
java
public class ObjectCreation {
static class Demo {
static { System.out.println("① 类初始化(首次new触发)"); }
private int value = 42; // ⑤ <init>中执行字段初始化
Demo() { System.out.println("⑤ 构造器执行, value=" + value); }
}
public static void main(String[] args) {
// 1. 检查Demo类是否已加载,未加载则触发:加载→验证→准备→解析→初始化
// 2. 分配内存(大小 = 对象头 + 字段 + 对齐,编译期已确定)
// 3. 内存清零(value先为0)
// 4. 设置对象头(Klass指针、Mark Word初始无锁)
// 5. 执行<init>:字段显式初始化 → 构造器
Demo d = new Demo();
System.out.println("创建完成: " + d.value);
}
}
输出:
① 类初始化(首次new触发)
⑤ 构造器执行, value=42
创建完成: 42
6.2 第二步:分配内存的两种方式
| 方式 | 条件 | 说明 |
|---|---|---|
| 指针碰撞(Bump the Pointer) | 堆内存规整(Serial/ParNew + 压缩整理) | 移动分界指针,快 |
| 空闲列表(Free List) | 堆内存不规整(CMS) | 维护可用块列表 |
并发安全 :多线程同时分配内存时,使用 CAS + 失败重试 或 TLAB (Thread Local Allocation Buffer,每个线程预分配一小块私有堆内存,-XX:+UseTLAB 默认开启)。
6.3 零值初始化的意义
java
public class ZeroInit {
static int staticInt; // 静态字段:准备阶段零值(0)
private int instanceInt; // 实例字段:分配内存时零值(0)
private Object ref; // null
private boolean flag; // false
public static void main(String[] args) {
ZeroInit z = new ZeroInit();
// 即使未显式赋值,字段也有确定的零值(这就是零值初始化的保证)
System.out.println(z.instanceInt + ", " + z.ref + ", " + z.flag);
}
}
6.4 对象创建与并发安全发布
java
public class SafePublication {
static class Holder {
final int value;
Holder(int v) { this.value = v; }
}
static volatile Holder shared; // volatile保证发布的可见性
public static void main(String[] args) throws InterruptedException {
// 错误示范:未构造完成就发布引用(this逃逸)
// 正确做法:构造完成后通过volatile/同步块发布
new Thread(() -> shared = new Holder(42)).start();
Thread.sleep(50);
System.out.println("读到的值: " + shared.value); // 42
// final字段语义(JMM):正常构造的final字段,在构造完成后对所有线程可见
}
}
7. 对象访问定位
Java 程序通过栈上的 reference 访问堆上的对象,有两种定位方式:
7.1 句柄访问(Handle)
reference → 句柄池中的句柄 → { 实例数据指针, 类型数据指针 }
- 优点:对象被 GC 移动时只需修改句柄内容,reference 本身稳定
- 缺点:多一次间接寻址,访问慢
- 代表:部分 JVM 实现(如早期 JRockit)
7.2 直接指针(HotSpot 采用)
reference → 对象地址(直接指向实例数据)
└─ 对象头中的Klass Pointer → 类型元数据
- 优点:少一次间接寻址,访问速度快(Java 对象访问极其频繁,累积收益显著)
- 缺点:GC 移动对象后需要更新所有指向它的引用
7.3 压缩指针(Compressed Oops)
java
// -XX:+UseCompressedOops(默认开启)
// 堆<32GB时,4字节指针通过8字节对齐可寻址 2^32 × 8 = 32GB
// 原理:存储时右移3位,读取时左移3位
public class CompressedOopsNote {
public static void main(String[] args) {
// 查看当前JVM参数
// java -XX:+PrintCompressedOopsMode -version
long maxBytes = Runtime.getRuntime().maxMemory();
System.out.printf("最大堆: %.1f GB(超过约32GB压缩指针自动失效)%n",
maxBytes / 1024.0 / 1024 / 1024);
}
}
压缩指针的收益 :引用从 8 字节降为 4 字节,对象整体更小,缓存利用率更高,通常带来 10%~20% 的性能提升。堆内存建议控制在 31GB 以内以享受压缩指针(32GB 附近是"悬崖":指针膨胀 + 实际可用内存可能反而下降)。
8. JOL 工具实战
JOL(Java Object Layout)是 OpenJDK 官方出品的对象内存布局查看工具。
8.1 引入依赖
xml
<!-- pom.xml -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
8.2 查看对象布局
java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class JolDemo {
// 含各种类型字段的对象
static class Sample {
private boolean flag; // 1字节
private byte b; // 1字节
private char c; // 2字节
private int i; // 4字节
private long l; // 8字节
private String ref; // 4字节(压缩指针)
}
public static void main(String[] args) {
Sample sample = new Sample();
sample.ref = "hello";
// 1. 单个对象的内存布局(对象头+字段+填充)
System.out.println("========== ClassLayout ==========");
System.out.println(ClassLayout.parseInstance(sample).toPrintable());
// 2. 对象图的总占用(含引用对象)
System.out.println("========== GraphLayout Footprint ==========");
System.out.println(GraphLayout.parseInstance(sample).toFootprint());
}
}
典型输出解读(64位,压缩指针开启):
Sample object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header: mark) 0x0000000000000001 (non-biasable)
4 4 (object header: class) 0xf80001e8
8 8 long Sample.l 0
16 4 int Sample.i 0
20 4 String Sample.ref (object)
24 2 char Sample.c
26 1 boolean Sample.flag false
27 1 byte Sample.b 0
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
8.3 观察锁状态变化
java
import org.openjdk.jol.info.ClassLayout;
public class JolLockDemo {
public static void main(String[] args) throws Exception {
Object obj = new Object();
System.out.println("【无锁】");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
System.out.println("【持锁中】");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
System.out.println("【锁释放后】");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
JDK 9+ 注意事项 :JOL 需要模块开放,运行时添加:
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED
8.4 JOL 的典型应用场景
- 验证对象头大小:确认空对象 16 字节、数组额外 4 字节长度
- 观察锁升级:synchronized 前后 Mark Word 的变化
- 对比压缩指针 :
-XX:+UseCompressedOops开关前后的差异 - 排查内存浪费:找出填充占比过高的类,优化字段
9. 实际应用与优化
9.1 内存敏感场景的对象瘦身
java
// 反例:包装类字段浪费内存
class BadPoint {
Integer x; // 16字节的Integer对象 + 4字节引用
Integer y;
}
// 正例:原始类型
class GoodPoint {
int x; // 4字节
int y; // 4字节
}
public class MemoryOptimization {
public static void main(String[] args) {
// 100万个点对象的内存差异(估算)
// BadPoint: 每个对象约 16(对象) + 2×16(Integer) ≈ 48字节 → 约46MB
// GoodPoint: 每个对象约 24字节 → 约23MB
int n = 1_000_000;
System.out.println("包装类方案估算: " + (n * 48L / 1024 / 1024) + " MB");
System.out.println("原始类型方案估算: " + (n * 24L / 1024 / 1024) + " MB");
}
}
9.2 锁选型建议
| 竞争程度 | 推荐方案 |
|---|---|
| 无竞争 | 无需同步;或让 JIT 锁消除 |
| 低竞争(交替执行) | synchronized(轻量级锁+自旋已足够好) |
| 高竞争 | 减小锁粒度 / LongAdder / 无锁结构 |
| 读多写少 | ReadWriteLock / StampedLock |
java
import java.util.concurrent.atomic.LongAdder;
public class LockChoiceDemo {
// 高竞争计数:LongAdder(分段CAS)远优于synchronized
private final LongAdder counter = new LongAdder();
public void increment() {
counter.increment(); // 内部分段,竞争时自动分散热点
}
public long get() {
return counter.sum();
}
public static void main(String[] args) throws InterruptedException {
LockChoiceDemo demo = new LockChoiceDemo();
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) demo.increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("计数结果: " + demo.get()); // 8000000
}
}
9.3 对象创建优化
- TLAB(默认开启):线程本地分配缓冲,减少分配时的 CAS 竞争
- 逃逸分析 + 标量替换 :JIT 可将未逃逸对象拆分到栈上(
-XX:+DoEscapeAnalysis默认开启) - 对象复用:热点路径减少临时对象(注意不要过度池化)
java
public class EscapeAnalysisNote {
// 未逃逸对象:point不逃出方法,JIT可能做标量替换(不在堆上分配)
static int compute(int x, int y) {
int[] point = {x, y}; // 理论上分配在堆
return point[0] + point[1]; // 但JIT可将其替换为两个局部变量
}
public static void main(String[] args) {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += compute(i, i + 1); // 热点方法,触发JIT优化
}
System.out.println("结果: " + sum);
}
}
10. 最佳实践与陷阱
10.1 最佳实践
- 堆内存控制在 31GB 以内:享受压缩指针(除非确实需要更大堆并接受指针膨胀)
- 用 JOL 验证假设:不要凭感觉估算对象大小
- 内存敏感场景用原始类型:避免包装类的对象头开销
- 减少锁粒度:锁升级不可逆,一旦膨胀为重量级锁不会自动降级
- 避免 this 逃逸:构造器中不要启动线程或发布 this
10.2 常见陷阱
| 陷阱 | 说明 |
|---|---|
| 空对象也有16字节 | 对象头是固定开销,大量小对象时用数组/扁平结构 |
| hashCode调用影响锁 | 偏向锁对象调用hashCode()会撤销偏向(JDK15前) |
| 堆32GB悬崖 | 超过32GB压缩指针失效,对象膨胀,可用内存可能不增反降 |
| synchronized不可降级 | 一旦竞争激烈膨胀为重量级锁,即使后来竞争减少也不回退 |
| wait/notify忘加锁 | 未持有锁调用抛IllegalMonitorStateException |
| 数组长度上限 | 数组对象头用4字节存长度,理论上限2^31-1(实际受VM限制,约Integer.MAX_VALUE-8) |
11. 版本演进
| 版本 | 变化 |
|---|---|
| JDK 6(2006) | 引入偏向锁、轻量级锁、自适应自旋、锁消除、锁粗化 |
| JDK 7 | 压缩指针逐步默认开启 |
| JDK 8(2014) | Metaspace 替代永久代,Klass 元数据移至 Metaspace |
| JDK 10 | GC 改进(不影响对象模型) |
| JDK 15(2020) | 偏向锁废弃 (JEP 374),-XX:+UseBiasedLocking 默认 false |
| JDK 17(2021) | 启用偏向锁参数仅打印警告 |
| JDK 18(2022) | 偏向锁相关告警 |
| JDK 21+(2023-) | 虚拟线程时代:虚拟线程阻塞时 synchronized 会膨胀为重量级锁(载体线程不能持有虚拟线程的锁状态,JDK24后支持虚拟线程下的轻量级锁优化) |
总结
Java 对象模型是连接"语言语义"与"机器实现"的桥梁:
- 内存布局(对象头/实例数据/对齐)决定了对象的最小开销与 GC 扫描方式
- Mark Word 用一个字的空间承载了哈希、分代、锁三重职责,是空间换设计的典范
- 锁升级 让
synchronized从"昂贵的重量级操作"变成"自适应的同步原语" - 对象创建五步曲 与安全发布是并发正确性的物理基础
- JOL 让这一切可观察、可验证
掌握对象模型,是理解 JVM 性能调优、并发编程、GC 原理的共同前提。
