
1. 先搞清楚一件事对象头到底管什么写了好几年Java很多人对“Java对象在内存里到底长什么样”压根没细想过。new一个Object()看起来就一行代码但JVM在堆上给它分配的内存并非只有一个“数据区”这么简单。每一块对象内存都被拆成了不同用途的区域其中最容易被忽略、却几乎决定锁、GC、hashCode等关键机制的就是对象头。这里得先把标题里的“对象存储”说清楚。它和你在后端开发里用到的MinIO、OSS、Ceph这类分布式对象存储服务完全是两码事。本文讲的对象存储指的是JVM运行时一个普通Java对象在堆内存里的物理存储布局。搞清楚这块内容不光是应付面试八股更重要的是排查线上性能问题、优化高并发代码时你能真正看得懂JVM在背后替你做了什么。对象头的核心职责可以归纳为三点存放对象自身的运行时数据哈希码、GC分代年龄、锁状态标志、持有指向类元数据的指针、以及如果是数组对象还要额外记录数组长度。很多人以为Java对象在内存里就是“字段挨个排开”这是最大的误解。实际上一个对象在堆中的布局大体分为三块对象头Object Header、实例数据Instance Data、对齐填充Padding。对象头在最前面是JVM识别和管理对象的基础。它解决的痛点也很直接JVM在垃圾回收时要快速判断对象年龄、在并发场景要判断锁的当前状态、在调用hashCode时要立刻返回哈希值——这些信息如果不放在对象自己身上每次都要去别的地方查性能代价完全不可接受。所以HotSpot把这类高频运行时元数据直接内联到对象内存布局里这就是对象头存在的根本原因。接下来我会从对象头的完整布局、Mark Word底层位结构、锁升级机制、以及用工具实测验证几个维度展开最后给出我在实际项目中踩过的坑和排查思路。内容尽量讲得透彻一些能落地能解决问题。2. 对象头的整体布局与内存占用拆解2.1 普通对象和数组对象的头部差异HotSpot虚拟机中对象头又细分为两部分Mark Word和Klass Pointer。如果是数组对象还有第三部分专门记录数组长度Array Length。Mark Word字面意思就是“标记字”这片区域是对象头的灵魂所在。它默认存储对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等数据。为什么叫“标记字”因为这块内存不是固定存一种信息的它会根据对象当前的状态动态复用。比如对象处于无锁状态时它存的是hashCode和分代年龄一旦这个对象被某个线程加了偏向锁它又变成存储偏向线程ID和偏向时间戳。这个动态复用机制是理解对象头最核心的一个点。Klass Pointer指向该对象的类元数据。JVM在运行时要判断“这个对象到底是哪个类的实例”就靠它找到方法区或元空间里的Klass对象。不过在绝大多数场景下JVM会开启指针压缩-XX:UseCompressedOopsKlass Pointer就不再是8字节的原始机器指针而是被压缩成4字节。指针压缩的触发条件是堆内存小于32GB这也是为什么很多教程会说“堆别超过32G否则对象头会变大”。数组对象的头部则比普通对象多出一个长度字段。由于数组的容量在创建后是固定的但JVM在GC移动数组、序列化数组时又必须知道它有多少个元素所以这个长度直接存放在对象头里避免每次去类元数据里查。下表是64位JVM开启和关闭指针压缩情况下的对象头内存占用对比对象类型压缩指针默认开启堆32G关闭压缩指针普通对象Mark Word 8字节 Klass Pointer 4字节 12字节Mark Word 8字节 Klass Pointer 8字节 16字节数组对象Mark Word 8字节 Klass Pointer 4字节 数组长度4字节 16字节Mark Word 8字节 Klass Pointer 8字节 数组长度4字节 24字节所以new Object()为什么很多人说是16字节因为对象头12字节加上对齐填充4字节凑成8的倍数正好16字节。如果里面的字段再占4字节还是16字节占8字节就变成24字节。理解这个对齐规则对估算缓存命中率、估算大对象池内存上限非常有帮助。2.2 为什么对象头这么设计对象头之所以设计成可变的根本原因是空间换时间、时间换灵活性的权衡。JVM的发明者们面临一个最现实的问题内存有限能省一点是一点运行时信息又必须放在显眼的位置。拿hashCode举例。Object.hashCode()返回的是一个int如果每次调用都去临时计算性能很差如果单独为每个对象预留4字节存储hashCode那JVM在创建每个对象时都得先算一遍而且很多对象可能一辈子也不会调用hashCode预留给它就浪费了。Mark Word的做法是“懒存储”——只有当你第一次调用identityHashCode时它才把值写进Mark Word的对应位这在空间和时间上都是最优解。分代年龄也是类似逻辑。对象头里用4个bit存放GC年龄所以最大只能是15。为什么是4个bit而不是8个十六个因为绝大多数对象在几次Minor GC之后就会被回收活过15次GC的对象已经极其稀少4个bit足够用。省下来的bit拿去做锁状态标志收益更高。锁状态标志的设计就更精妙了。一个对象从创建到被回收可能经历无锁、偏向锁、轻量级锁、重量级锁等状态。如果为每种状态都预留独立内存对象头会膨胀到不可接受。于是HotSpot让Mark Word变成“多面手”不同状态下同一段位存储不同含义。Java的synchronized锁升级机制本质上就是Mark Word在不同状态之间切换的过程。3. Mark Word位结构深度解析与锁状态流转3.1 64位JVM下Mark Word到底有多少个bit在干活在64位JVM中Mark Word一共有64个bit。这64个bit的划分方式取决于锁标志位lock。无锁状态lock01前25个bit未使用31个bit存identityHashCode1个bit存是否是偏向锁biased_lock04个bit存分代年龄1个bit未使用最后2个bit的lock值为01。注意这里的hashCode不是System.identityHashCode存进去的完整值而是经过31位截断后的结果。偏向锁状态lock01但biased_lock154个bit存偏向线程ID2个bit存偏向时间戳epoch1个bit unused4个bit分代年龄biased_lock1lock01。轻量级锁状态lock0062个bit存指向栈中Lock Record的指针。此时Mark Word原本的hashCode、年龄等信息全被“挤出去”——等锁释放时会从Lock Record中恢复回来。重量级锁状态lock1062个bit存指向MonitorObjectMonitor的指针。Monitor才是真正负责阻塞、唤醒线程的组件。GC标记状态lock11此时Mark Word一般只用来配合GC对象转移大部分bit为空或存转发指针。这整个设计最关键的理念就是同一块64位内存在不同阶段扮演不同角色。你不需要记下每一位的排列但你一定要能从锁标志位判断当前对象处于什么状态。3.2 synchronized锁升级的底层本质我曾经拿synchronized面试题考过不少人很多人能背出“无锁→偏向锁→轻量级锁→重量级锁”但问一句“JVM怎么知道这个对象该升级到什么锁”就答不上来了。其实整个过程就是Mark Word里锁标志位的变更竞争。一个对象刚new出来处于无锁可偏向状态。线程A第一次执行到synchronized代码块时JVM发现没人抢这把锁就用CAS把Mark Word改成“偏向线程A”的状态之后线程A每次进入同步块只需要检查一下偏向线程ID是不是自己连CAS都省了。这个设计是JDK 1.6引入的目的非常明确大多数锁在绝大多数时间只被同一个线程持有偏向锁能把加锁成本降到几乎为零。但如果有线程B来竞争就麻烦了。偏向锁的撤销需要等待全局安全点SafePoint暂停所有业务线程然后判断原持有者是否存活如果存活则让原线程在最近的安全点挂起接着再决定是轻量级锁接管还是直接升级。这个撤销过程非常昂贵所以JDK 15之后默认废弃了偏向锁JDK 20更是默认关闭了这个参数。这也是为什么现在的面试题越来越多开始问“偏向锁还需要研究吗”——实际上新版JDK已经把默认值改了。接下来是轻量级锁。当线程B发现对象已经处于偏向锁状态且偏向的不是自己偏向锁失败后JVM会让线程A和线程B都去自旋竞争在各自线程栈中创建Lock Record用CAS去抢Mark Word的所有权。抢到的那个线程Mark Word里存的就是指向自己Lock Record的指针。轻量级锁的设计前提是“临界区执行速度极快”多线程通过自旋等待避免切入内核态。如果自旋到一定次数或使用自适应自旋仍然没人抢到锁说明竞争确实激烈继续空转只会白白消耗CPU。此时锁膨胀为重量级锁Mark Word指向ObjectMonitor后续等待的线程全部进入操作系统层面的阻塞队列。代价是线程从用户态切到内核态上下文切换开销远大于自旋但这是高竞争场景下的唯一正确选择——宁可让线程睡着也不要让CPU空转。下面的状态流转表能帮你快速梳理锁状态Mark Word关键位适用场景性能特征无锁lock01biased_lock0对象刚创建无竞争零开销偏向锁lock01biased_lock1存线程ID同一线程反复进入同步块近乎零开销轻量级锁lock00存Lock Record指针少量线程短时间竞争自旋不切内核态重量级锁lock10存Monitor指针大量线程长时间竞争阻塞唤醒切内核态3.3 为什么偏向锁和hashCode不能共存这是面试中很经典的一个“隐藏陷阱”。很多人背了锁升级流程但问“对一个已经重写过hashCode方法的对象加偏向锁会发生什么”就懵了。原因是偏向锁状态的Mark Word里没有空间存hashCode。当一个对象的hashCode被实际调用过即identityHashCode已经被计算并写入Mark Word的无锁状态那么Mark Word的偏向锁标志位和线程ID位已经无法同时容纳这个值。此时如果代码对这个对象加synchronized锁JVM会直接跳过偏向锁状态进入轻量级锁或重量级锁状态。换句话说偏向锁存在的前提就是“这个对象没有被真正hash过”。这也是为什么某些极端的性能调优场景中有人会提前调用System.identityHashCode(obj)来“杀死”对象的偏向锁能力从而强制它走轻量级锁路径避免出现多个线程竞争偏向锁导致的撤销开销。这种方法在一些特定业务中挺有效的但不能乱用否则会适得其反。4. 用JOL实测Java对象内存布局4.1 JOL工具配置与基本操作纸上谈兵说再多不如自己动手看一次。OpenJDK官方提供了一个叫做JOLJava Object Layout的工具专门用来打印对象内存布局非常适合验证上面讲的这些理论。先在pom.xml里引入依赖用maven的孩子直接复制dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency然后在main方法里写这么一段基础测试代码import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class ObjectHeaderDemo { public static void main(String[] args) { System.out.println(VM.current().details()); Object obj new Object(); System.out.println( 普通 Object 对象布局 ); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }运行时会输出类似下面的信息# WARNING: Unable to attach Serviceability Agent. You can try again with suppressed -XX:UsePerfData # Running 64-bit HotSpot VM. # Using compressed oop with 3-bit shift. # Using compressed klass with 3-bit shift. # Objects are 8 bytes aligned. # Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] # Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] 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 (type pointer) 12 4 (object alignment gap) Instance size: 16 bytes注意看第二行的mark字段值是0x0000000000000001。这个1就是二进制中最右边的两个bit的lock01而biased_lock位为0所以这里显示non-biasable。实例大小16字节正好和前面计算的一样。把main方法加一行改成数组int[] arr new int[10]; System.out.println(ClassLayout.parseInstance(arr).toPrintable());运行后你会看到多出来一个length字段OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 8 4 (object header: class) 0x00000000 (type pointer) 12 4 (object header: array length) 0x0000000a 16 40 int[] elements N/A Instance size: 56 bytes数组长度为10int是4字节40字节的数据区加上16字节的头部正好是56字节对齐后也还是56。这里有个值得注意的细节Mark Word输出中的0x0000000000000001说明这个对象已经不可偏向non-biasable。如果你在JDK 8下跑同样的代码很多对象默认是可偏向的biased_lock1mark值会是0x0000000000000005。但JDK 15之后偏向锁默认关闭所以新版本验证时大概率看到的是不可偏向状态。4.2 利用JOL观察锁升级状态JOL不仅能看静态布局还能配合synchronized实时观察Mark Word的变化。我写过一个比较直观的小测试public class LockUpgradeDemo { private static final Object LOCK new Object(); public static void main(String[] args) throws Exception { // 无锁状态 System.out.println( 无锁 ); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); // 加偏向锁JDK 8 synchronized (LOCK) { System.out.println( 偏向锁 ); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); } // hashCode调用后偏向锁不可用 LOCK.hashCode(); synchronized (LOCK) { System.out.println( hashCode后再加锁 ); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); } } }在JDK 8下运行第一个输出mark可能是0x0000000000000005说明已经偏向当前线程第二个输出同样是偏向锁第三个输出因为调用过hashCodemark会变成0x0000000000000009之类带锁标志位00或10的值代表轻量级或重量级锁。这是因为偏向锁和hashCode互斥这一点现场打印一遍比背十遍都管用。如果你在JDK 11及以上版本运行可以加上JVM参数-XX:UseBiasedLockingJDK 15以下有效强制开启偏向锁上面这个实验才有意义。JDK 20以后基本只能验证轻量级/重量级锁路径了。这一个实验带来的认知升级是锁优化不是一个抽象概念而是实实在在发生在每个对象内存位上的CAS决策。5. 常见问题排查与经验总结5.1 高频问题速查表我把这些年面试与被面试、排查线上问题过程中经常碰到的对象头相关问题整理成一个速查表方便各位直接查阅问题答案要点踩坑级别new Object()占多少内存64位JVM默认压缩指针下是16字节对象头12对齐4低int[]占多少内存16字节头部 4*length字节数据再加上对齐中为什么GC分代年龄最大是15Mark Word用4bit存年龄中为什么偏向锁和hashCode冲突偏向锁占用Mark Word大部分位没地方放hashCode高轻量级锁为什么要自旋减少线程阻塞唤醒时的用户态/内核态切换开销中什么是重量级锁锁升级到ObjectMonitor线程进入OS阻塞队列低如何查看对象头用JOL工具或HSDB、JFR等低JDK 15之后偏向锁默认关了因为撤销偏向锁的成本大于收益高5.2 实际项目中容易踩的坑第一个坑是关于“偏向锁已废弃”的认知误区。到现在还有人拿着早期八股文的结论说“synchronized加锁先走偏向锁”。但在JDK 15默认环境中对象初始化后就是non-biasable状态偏向锁这条路直接不走。如果在高版本JDK上做锁升级相关测试必须意识到这一行为差异。第二个坑是对象头内的数组长度和普通字段长度的混淆。比如你在做某种“紧凑数组封装”的工具时如果一个Object里有多个int[]字段那么每个数组实例都有额外的16字节头部开销。如果数组很短比如只有1个元素那数组头部甚至比数据本身还大这种情况下用单个大数组批量存储三个小数组的数据反而更省内存。我有一个项目就是把三个double[]合成一个double[3*n]省了很多对象头开销GC压力明显下降。第三个坑是关于自旋锁的理解偏差。轻量级锁的自旋是基于CAS忙等的它只适合临界区极短的场景。如果你的同步块里做了IO、网络请求、数据库调用那轻量级锁自旋只会白白浪费CPUJVM很可能很快就把锁膨胀为重量级锁。很多线上CPU飙升问题的根源就在这里。第四个坑是identityHashCode的性能归属。如果你给equals/hashCode重写不当导致大量对象在无锁状态下频繁计算hashCode并写入Mark Word这本来没什么问题但如果这个对象又被某个框架比如某些缓存组件做了synchronized处理就会导致锁升级路径改变进而影响性能表现。这是非常隐蔽的性能损耗点用JOL打印一下生产环境的典型对象布局能帮你快速定位。5.3 排查对象头相关问题的基本思路面对线上问题很多人上来就看GC日志、抓线程栈很少人第一时间会想到对象头层面的影响。我建议按下面这个顺序排查第一如果怀疑锁性能问题先通过JFRJava Flight Recorder抓取锁竞争事件看看锁竞争集中在哪个类、哪个方法判断是锁粒度问题还是锁本身实现的问题。如果锁竞争严重再去看这个对象是不是正在承担“多用途承载”——比如既做map的key需要hashCode又做synchronized的锁对象就很可能因为偏向锁/轻量级锁切换而多出大量CAS操作。第二如果怀疑内存占用问题用JOL直接打印业务对象的完整布局。你会很直观地看到对象头占了多大比例。一个只有两个int字段的小对象对象头12字节数据8字节总共24字节有对齐实际有效载荷只有三分之一。如果要存储海量这种小对象缓存到外部存储或者改用紧凑结构体才是正解。第三如果怀疑GC角度分布异常可以观察分代年龄变化。这4bit的被写满15次之后对象就会进入老年代。如果你发现老年代持续快速增长而很多对象理论上早该被回收那就需要检查是否在代码里持有这些对象的“意外引用”而对象头的分代年龄标记只是结果不是原因。6. 最后分享一点经验对象头这块内容真正能玩明白的人并不多。多数人停留在“知道这个名字”的层面可一到线上锁升级导致CPU飙高、小对象太多导致GC压力大、hashCode和锁冲突导致意外卡顿就一头雾水。我个人的体会是学好对象头最有效的方式就是动手打印一次JOL输出然后对照本文讲的状态切换过程亲眼看看Mark Word的值是怎么变的。当你养成这种“所有Java对象都是内存中的字节序列”的视角之后很多性能问题都会变得透明。比如你能立刻估算出一个线程池队列里积压10万条任务对象占多少内存、一次批量缓存失效时大量锁膨胀会不会拖垮CPU、一个对象要不要预先排除偏向锁等等这些判断力不是背面试题能得来的而是建立在底层机制之上的。如果你看完这篇文章准备自己动手测试建议先在JDK 8的环境下完整跑一遍偏向锁实验再去JDK 17或21上跑一遍对比一下版本差异。先把无锁、轻量级锁、重量级锁这三种状态产生的Mark Word变化输出记下来以后面试或者排查问题时你会发现这段经验比任何理论都扎实。