先问个问题:你在代码里随手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 Word | 8 字节 | 锁状态、identity hash code、GC 分代年龄、偏向锁线程 ID 等 |
| Klass Pointer | 4 字节 | 指向方法区(元空间)中的类元数据 |
| 数组长度 | 4 字节(仅数组对象有) | 数组的元素个数 |
| 实例数据 | 不定 | 对象声明的字段值,包括父类字段 |
| 对齐填充 | 0~7 字节 | 让对象大小对齐到 8 字节的倍数 |
对象头是整块内存布局里最“善变”的部分,因为 HotSpot 为了极致的空间利用,让同一块内存根据对象状态反复变化含义。这也是对象头最难理解的原因:它不是恒定不变的一串数据,而是一个“分身术大师”。
实例数据则要遵循字段排列规则:long/double、int/float、short/char、byte/boolean、reference按顺序排列,父类字段会排在子类前面,字段之间还可能有空隙。JVM 这么排是为了尽量紧凑,减少内存浪费。写代码时如果你希望字段紧凑,列的顺序也会影响实际内存占用,虽然现代 JVM 会自动调整字段顺序,但了解这个对估算大对象大小很有用。
2. Mark Word:一台状态机,两个 Bits 的魔术
2.1 Mark Word 的位分布:每一 bit 都不是白给的
Mark Word 在 64 位 JVM 里固定 8 字节(64 bit)。HotSpot 用一个 union 结构来复用这块内存,根据锁状态不同,这 64 bit 的含义完全不一样。先看一张最常用的布局:
| 状态 | biased_lock | lock | Mark Word 中存储的内容 |
|---|---|---|---|
| 无锁 | 0 | 01 | 25 bit unused + 31 bit identity hash code + 1 bit unused + 4 bit 分代年龄 |
| 偏向锁 | 1 | 01 | 54 bit 线程 ID + 2 bit epoch + 1 bit unused + 4 bit 分代年龄 |
| 轻量级锁 | 无用 | 00 | 62 bit 指向栈中 Lock Record 的指针 |
| 重量级锁 | 无用 | 10 | 62 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 算对象大小,再做参数决策,这个习惯会让你少走很多弯路。