Java对象头深度解析:Mark Word、压缩指针与JOL实战
2026/9/17 4:24:42 网站建设 项目流程

先问个问题:你在代码里随手new了一个Object,它到底占多少内存?很多人第一反应是“一个引用呗,8字节”,但真让你把这个对象在堆里的布局画出来,能画对的人立马少一大半。Java 对象在堆内存里的“对象存储”,核心就在对象头。对象头里的 Mark Word 存着锁状态、identity hash code、GC 分代年龄,Klass Pointer 指向类元数据,数组对象还要多一个 length 字段。听起来像面试八股,但你做内存调优、排查 OOM、写并发组件时就会发现,这些 bit 位和字节对齐才是决定性能和内存占用的底层逻辑。

我刚开始工作时也是死记硬背“对象头 12 字节”,直到有次线上内存增长异常,把 dump 拉下来一算,怎么算都对不上,才发现是没有吃透 Mark Word 和压缩指针。这篇文章我系统聊一下 Java 对象头:从对象整体布局、Mark Word 的五种状态,到 Klass Pointer 和压缩指针的 32GB 临界点,再用 JOL 把你亲手写的对象头“打出来”看一遍。顺便把面试里那些高频问题和实际排查中的坑一起整理了。这里说的“对象存储”,指的是 JVM 堆内对象本身的存储机制,不是 OSS、Ceph 那类分布式对象存储服务,别搞混。

1. 对象在堆里到底长什么样:一次内存储解剖

1.1 从 new Object() 开始说起

在 64 位 JVM 默认参数下(JDK 8 + 压缩指针开启),一个普通的new Object()占用 16 字节。这 16 字节不是凭空来的,它的布局拆开看是这样:

  • Mark Word:8 字节,存对象自身的运行时数据,锁、hashCode、GC 年龄全部塞在这里。
  • Klass Pointer:4 字节,指向方法区里的类元数据,告诉 JVM“我是哪个类的实例”。
  • 对齐填充:占位 4 字节,凑满 16 字节。

为什么会有对齐填充?因为 HotSpot 的堆内存分配器要求对象起始地址必须是 8 的倍数。这个 8 字节对齐规则带来了一个关键收益:可以让压缩指针用更少的 bit 表示更大的堆空间,具体后面第三章详细说。你只需要先记住结论:new Object()默认 16 字节,而不是 12。

有个小细节值得注意:如果把压缩指针关掉(-XX:-UseCompressedOops),Klass Pointer 会变成 8 字节,Mark Word 还是 8 字节,对象头合计 16 字节,new Object()就占 24 字节。同样一个空对象,内存占用直接多了 50%。所以线上 JVM 默认开启压缩指针是有道理的,后面我们单独拿一节讲。

1.2 三部分各自的职责

一个 Java 对象在堆里严格由三部分组成:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。我先用一张表把内容铺开,后面再逐段拆:

组成部分默认大小(压缩指针开启)存了什么
Mark Word8 字节锁状态、identity hash code、GC 分代年龄、偏向锁线程 ID 等
Klass Pointer4 字节指向方法区(元空间)中的类元数据
数组长度4 字节(仅数组对象有)数组的元素个数
实例数据不定对象声明的字段值,包括父类字段
对齐填充0~7 字节让对象大小对齐到 8 字节的倍数

对象头是整块内存布局里最“善变”的部分,因为 HotSpot 为了极致的空间利用,让同一块内存根据对象状态反复变化含义。这也是对象头最难理解的原因:它不是恒定不变的一串数据,而是一个“分身术大师”。

实例数据则要遵循字段排列规则:long/doubleint/floatshort/charbyte/booleanreference按顺序排列,父类字段会排在子类前面,字段之间还可能有空隙。JVM 这么排是为了尽量紧凑,减少内存浪费。写代码时如果你希望字段紧凑,列的顺序也会影响实际内存占用,虽然现代 JVM 会自动调整字段顺序,但了解这个对估算大对象大小很有用。

2. Mark Word:一台状态机,两个 Bits 的魔术

2.1 Mark Word 的位分布:每一 bit 都不是白给的

Mark Word 在 64 位 JVM 里固定 8 字节(64 bit)。HotSpot 用一个 union 结构来复用这块内存,根据锁状态不同,这 64 bit 的含义完全不一样。先看一张最常用的布局:

状态biased_locklockMark Word 中存储的内容
无锁00125 bit unused + 31 bit identity hash code + 1 bit unused + 4 bit 分代年龄
偏向锁10154 bit 线程 ID + 2 bit epoch + 1 bit unused + 4 bit 分代年龄
轻量级锁无用0062 bit 指向栈中 Lock Record 的指针
重量级锁无用1062 bit 指向 ObjectMonitor 的指针
GC 标记无用11空(GC 使用)

先解释两个经常被问的细节。第一,identity hash code 是延迟生成的,只有调用System.identityHashCode(obj)或者Object.hashCode()(未重写时)才会计算并写入 Mark Word。为什么延迟?因为不是所有对象都需要 hashCode,能省则省。第二,分代年龄只有 4 bit,最大表示 15,所以 GC 年龄计数到 15 就进入老年代(CMS 或其他收集器如果不是从 15 降,可能是 6,但顺着 4 bit 的上限逻辑去记是最好的)。

这里有个很重要的连锁反应:偏向锁的 54 bit 存的是线程 ID,而无锁状态的 31 bit 存的是 hashCode,两个状态是互斥的。一旦对象调用了 identity hashCode,它就无法再进入偏向锁状态,因为 Mark Word 已经没有足够的空间同时装下线程 ID 和 hashCode。这一点在并发编程里影响不小,后面实操部分我会演示。

2.2 锁升级路径:偏向锁到重量级锁的完整故事

synchronized锁并不是一开始就上重量级锁,而是有一整套“升级”路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这样设计的根本原因是:Java 线程是映射到操作系统原生线程的,一旦锁竞争落到 OS 互斥量(Mutex)上,线程挂起和唤醒的成本非常高。为了尽量减少这种昂贵的操作,JVM 做了层层优化。

  • 偏向锁:假设同一个线程反复获取同一把锁,那么只需要在 Mark Word 里记录线程 ID 即可,后续这个线程再次进来,不需要任何 CAS 操作,直接判断线程 ID 相同就放行。这是成本最低的锁。
  • 轻量级锁:一旦出现第二个线程竞争,偏向锁就会撤销。新来的线程会在自己的栈帧中创建一个 Lock Record,并尝试用 CAS 把 Mark Word 替换为指向 Lock Record 的指针。CAS 成功就拿到锁,失败说明竞争加剧,进入自旋等待,反复尝试获取。
  • 重量级锁:自旋是有代价的,如果竞争线程多或自旋时间太长,JVM 会把锁升级为重量级锁,Mark Word 指向一个ObjectMonitor对象,后续等待线程都会进入EntryList阻塞队列,由操作系统负责调度唤醒。

锁升级有一个容易被忽略的特征:锁只能升级,不能降级。也就是说,一旦从轻量级锁变成重量级锁,在持有期间不会回到轻量级锁。偏向锁的撤销成本很高,需要等待一个安全点(SafePoint),这也是为什么偏向锁在 JDK 15 之后被默认关闭甚至移除:在现代多线程应用里,偏向锁带来的收益越来越小,而撤销的代价却一直存在。老项目如果还在 JDK 8 上跑,偏向锁默认开启,可以手动加-XX:-UseBiasedLocking关闭,具体视业务场景决定。

你还会经常听到“批量重偏向”和“批量撤销”。当一个类的对象频繁发生偏向锁撤销时,JVM 会触发批量重偏向,把 epoch 加 1,后续新对象可以重新偏向;如果偏向冲突持续加剧,超过阈值就会批量撤销,让这个类的对象不再支持偏向。这些机制的核心目的只有一个:让锁在最合适的成本层级上工作。

3. 类型指针与压缩指针:64 位 JVM 的空间游戏

3.1 Klass Pointer 到底指向哪里

对象头的第二组成部分是 Klass Pointer,它指向的是方法区(JDK 8 以后叫元空间)中的Klass结构。每个类在 JVM 内部都有一个对应的Klass,保存了类的完整元数据:方法表、字段信息、接口列表、常量池引用等。你执行obj.getClass()instanceof判断、动态方法分派,底层都要靠这个指针找到类信息。

在开启压缩指针时,Klass Pointer 占 4 字节;关闭时为 8 字节。为什么能压缩?因为类元数据通常不会超过 4GB,用 4 字节就足够寻址完整的元空间区域。HotSpot 在启动时会专门划分一块“压缩类空间”,默认大小 1GB,专门放能被压缩指针寻址到的类元数据。

理解了这个指针的作用,你对“对象只知道自己的类型”会有一个具体画面:每个对象头里都藏着一张指向类信息的“门牌号”,JVM 拿到它就能知道你这个对象能调用哪些方法、字段怎么排布、该不该回收。这也是实现多态的基础。

3.2 UseCompressedOops:32GB 堆内存的临界点

压缩指针(-XX:+UseCompressedOops)在 64 位 JVM 里默认开启,它影响的是对象引用(oop,ordinary object pointer)的表示方式:原本 8 字节的引用在压缩后变成 4 字节。这直接让引用类型的字段在实例数据里的占用减半,对整个堆的节省非常可观。

那为什么临界点是 32GB?因为 4 字节最多表示 2^32 = 4G 个“单元”,而 HotSpot 的对象是 8 字节对齐的,所以 4 字节指针可以表示 4G × 8 = 32GB 的堆空间。堆小于等于 32GB 时,压缩指针都能正常工作;一旦堆超过 32GB,UseCompressedOops自动失效,对象引用变回 8 字节,内存占用反而明显上升。

这也是一个非常反直觉的调优结论:如果你的 JVM 把堆设置在 33GB、34GB,内存利用率未必比 30GB 更好,甚至可能更差。因为引用字段突然变大,实例数据膨胀,能放下的对象数量变少,GC 压力也随之上升。很多生产环境宁可把堆控制在 31GB 左右,也不要强行上 40GB,就是这个原因。

我还遇到过团队把-XX:ObjectAlignmentInBytes改成 16 的场景,那是为了在超大堆下继续用压缩指针,但代价是对象填充更多、碎片率更高。普通应用完全不需要动这个参数,知道存在即可。

4. 实操:用 JOL 和 HSDB 把对象头“看”出来

4.1 JOL 快速上手

理论说再多,不如亲眼看一眼。JOL(Java Object Layout)是 OpenJDK 提供的对象布局分析工具,可以直接打印对象头里的每一个字节。在 Maven 里加一个依赖:

<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>

然后写一个最简单的类:

import org.openjdk.jol.info.ClassLayout; public class ObjectHeaderDemo { public static void main(String[] args) throws Exception { Object obj = new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }

运行后(JDK 8 + 默认压缩指针),你会看到类似这样的输出:

java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x00000000 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

注意看 VALUE 那列:mark 是0x0000000000000001,最低位 1,结合我们第二章的表格,biased_lock=0, lock=01,代表当前是无锁、不可偏向状态。age: 0说明 GC 年龄是 0。紧跟着的 4 字节是 Klass Pointer,然后 4 字节对齐填充,总大小 16 字节。和你之前估算的完全一致。

4.2 上锁前后对比:肉眼观察 Mark Word 变化

现在把对象拿来加锁,看看 Mark Word 怎么变:

public class LockDemo { 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 8 默认偏向锁开启的条件下,如果程序启动后立即运行,可能会先走轻量级锁,因为偏向锁默认有 4 秒启动延迟。你可以先Thread.sleep(5000)再执行加锁逻辑,或者启动时加-XX:BiasedLockingStartupDelay=0。加锁后你会看到 Mark Word 从无锁状态的0x...01变成带有线程 ID 的偏向锁状态,或者变成指向 Lock Record 的轻量级锁状态。

再把obj.hashCode()或者System.identityHashCode(obj)调用一次,加锁前打印,你一般会看到 Mark Word 变成了0x...01前面一堆非零值,也就是 hashCode 已经写进去了。这时候即使再进入同步块,也无法走偏向锁,因为 Mark Word 里没位置存线程 ID。这是个非常经典的“锁竞争”理解入口。

如果想看重量级锁,把synchronized放进多线程竞争的场景,或者在线程池里让多个线程疯狂抢同一把锁。竞争激烈时 Mark Word 会变成指向ObjectMonitor的地址,数值看起来是一个完整的内存地址,不再有“age”这种小字段的痕迹。到这里,你对锁升级已经从字面概念变成了直观记忆。

4.3 数组对象与自定义对象的对象头差异

数组对象在对象头里多了一个 4 字节的 length 字段,用来记录数组长度。因为 JVM 在分配数组时就必须知道确切大小,这个字段也直接决定数组对象的整体占用。演示一下:

int[] arr = new int[10]; System.out.println(ClassLayout.parseInstance(arr).toPrintable());

输出大约是:

[I object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 ... 8 4 (object header: class) 0x00000000 12 4 (array length) 10 16 40 int [I.<elements> N/A Instance size: 56 bytes

可以看到:数组对象头就是 Mark Word 8 字节 + Klass Pointer 4 字节 + 数组长度 4 字节,恰好 16 字节,没有对齐填充。元素数据从偏移 16 开始,10 个 int 占 40 字节,总计 56 字节。如果数组长度是 1,那么 16 + 4 = 20,并不是 8 的倍数,JVM 会再补 4 字节填充,实际占 24 字节。这一点在估算大批量小数组时很有用,千万不能只看元素数量。

自定义对象同理,你可以加几个字段跑一下 JOL,观察不同基础类型字段对对象大小的影响。比如一个只有int a; long b;的对象,算下来通常比两个字段分开写的对象更省空间,因为 JVM 的字段重排把 8 字节的 long 放前面,int 放后面,最终可能只要 24 字节左右;如果字段顺序不合适,就会多出填充。写代码时让大字段优先、小字段靠后,算是一种朴素的“内存优化习惯”。

5. 常见问题与面试坑位速查

5.1 面试高频题快速作答

对象头是 Java 面试的高频考点,尤其是并发、JVM、内存相关岗位。我这里整理了一张速查表,基本覆盖了常见的八股题:

问题回答要点
对象在内存中由哪几部分组成?对象头、实例数据、对齐填充。对象头又分 Mark Word、Klass Pointer,数组还有长度字段
Mark Word 存了什么?锁状态、identity hash code、GC 分代年龄、偏向锁线程 ID、指向锁记录或 Monitor 的指针
synchronized 锁升级过程?无锁 → 偏向锁 → 轻量级锁(CAS + 自旋)→ 重量级锁(ObjectMonitor)
为什么分代年龄最大是 15?Mark Word 里只有 4 bit 存储分代年龄,最大 15
为什么 wait/notify 必须在 synchronized 块里?因为它们依赖 ObjectMonitor,必须是先获取重量级锁(Monitor)才能操作等待集
压缩指针为什么临界 32GB?4 字节指针 × 8 字节对齐 = 32GB 可寻址空间,超过后引用变回 8 字节
偏向锁为什么被废弃?撤销需要安全点,代价高;现代应用并发竞争频繁,收益小,JDK 15 默认关闭

这些题目如果你能结合前面 Mark Word 的位图回答,面试官通常会多给几分,因为说明你看到了底层而不是背了概念。

5.2 实战排查:从内存估算到 OOM

实际工作中,对象头知识最大的用处是内存估算。我在排查一个缓存服务时,发现存储几百万个对象,但内存使用远超预期。用jmap -histo:live一看,发现大量java.lang.Object和自定义 DTO。当时我用 JOL 手动算了几个核心对象的大小,发现每个对象都因为“对象头 + 对齐填充”多占了不少字节,再乘以百万级数量,差距就从几十 MB 变成了几百 MB。

具体操作流程可以参考:先用jps -l找到进程 PID,然后jcmd <pid> GC.class_histogram看各个类的实例数量。对于某个重点类,用jhsdb hsdb --pid <pid>打开 HSDB,通过图形界面查看对象的详细内存布局。注意 HSDB 是老牌调试工具,能直接 inspect 对象头里的 mark 和 klass 指针,比起 JOL 更贴近运行时状态。两者结合使用,线上定位问题非常好用。

如果你在用 MinIO、OSS 这类分布式对象存储的客户端 SDK,它们读文件时会分配比较大的一批字节数组和缓冲对象。字节数组每个都有 16 字节的对象头,再加上堆外内存缓冲区,估算 JVM 内存时千万不要只算数据本身。对象头、对齐、引用字段都必须算进去,否则你的堆内存规划几乎必然出偏差。

5.3 容易被忽略的坑

这里列几个我踩过或见过别人踩的坑:

  • 跑偏向锁 Demo 的时候,程序刚启动就执行同步块,很可能看不到偏向锁,因为 JVM 默认偏向锁有 4 秒启动延迟。加-XX:BiasedLockingStartupDelay=0才能在启动后马上观察。
  • 对象调用过 identity hashCode 之后,无法再进入偏向锁状态。如果你在 Demo 里先打印了obj.hashCode(),后面再观察锁升级就会和预期不符。
  • JDK 15 以后偏向锁默认关闭,JDK 18 之后已移除相关参数。你在新版本上复现偏向锁实验需要切回 JDK 8,不要在 17、21 上死磕,那不是代码写错了。
  • 不要为了“省内存”随意关闭压缩指针,也不要为了“大堆”硬开到 32GB 以上。堆超过 32GB 会让引用翻倍,可能得不偿失。调优前先用 JOL 算一遍关键对象大小。
  • JOL 输出里 mark 的十六进制显示是按小端字节序展开的,读二进制位时别按从左到右的顺序硬套,要看最低位的几位。最低位是 lock 的最低 bit,这决定了当前锁状态。

我第一次看 JOL 输出时,被0x0000000000000001给整懵了,以为是特别大的数值,后来才发现它只是最低位为 1。多跑几个不同状态的例子,很快就能建立直觉。

5.4 从对象头推演到 GC 与并发调优

对象头不仅是一段内存布局,它像一张“运行状态卡”。GC 要更新age,锁机制要把 Mark Word 篡改来篡改去,hashCode 又要往里面挤,三件事都在抢同一块 8 字节空间。理解了这种冲突,你就能解释很多现象:为什么有的对象晋升老年代特别快?为什么 hash 计算频繁的对象锁竞争更剧烈?为什么某些服务从 JDK 8 升级到 JDK 17 后,同步代码块的性能反而没以前“稳”?这些都能从对象头的变化里找到线索。

我建议你把 JOL 当作调优工具箱里的常备工具,哪怕是写一个很小的中间件,也先跑一下看看对象大小是否符合预期。比如你设计了一个百万级并发的请求上下文对象,如果每个请求都 new 一个,对象大小差异会直接放大成内存和 GC 压力。很多框架在做对象池、线程本地缓存时,本质也是在避免反复创建对象带来的对象头分配开销。

我在实际项目中到最后都会发现:真正阻碍你对 JVM 建立完整认知的,从来不是某个高深算法,而是这些最基础的内存结构。对象头串起了并发、GC、内存模型三条线,花一天时间把 JOL 玩明白,比背十篇八股文都值。调优时先用 JOL 算对象大小,再做参数决策,这个习惯会让你少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询