开头先抛个实际问题吧。很多Java开发者干了两三年,JVM调优也聊得头头是道,但是被问一句“new Object()到底占多少内存”,或者“synchronized加锁的时候对象里改的是哪几个字节”,往往会愣住。这两个问题背后对应的恰恰就是Java对象头——对象存储在堆内存里的最前置结构,也是理解锁升级、内存占用、压缩指针、OOM排查等一系列进阶话题的地基。今天这篇就围绕对象头的内容,把它的布局、锁状态流转、64位环境下的内存实测,以及和排查、面试相关的点全部展开,整理成一篇可以反复翻看的东西。
这篇的内容适合这么几类人:准备Java面试、正在啃JVM底层、做高并发项目想搞清楚锁机制、以及线上遇到内存问题想从对象结构层面去分析的人。对象头本身概念不多,几个字段而已,但真正把它跟锁升级、内存布局串起来之后,你会发现很多“背八股文”时记不住的东西,其实都是顺理成章的推导结果。
1. 对象头的整体布局:Mark Word、类型指针和数组长度都装在哪个位置
先把最基础的概念建立起来。任何一个Java对象,只要被new出来并放进堆内存,它的内存布局从前到后就三块区域:对象头、实例数据、对齐填充。实例数据就是你在类里定义的字段值,对齐填充是为了让对象整体占用的字节数是8的倍数,方便内存分配和访问。真正让人迷惑的其实是对象头,因为不同状态下它的体积和内容都会变化。
1.1 对象头的基本体积:32位和64位下的差异
对象头在HotSpot里的构成不是固定的。最常见的两种场景说清楚。
32位JVM下,普通对象头是8字节,由Mark Word(4字节)和类型指针(4字节)组成。如果是数组对象,还需要额外4字节记录数组长度,所以数组对象头是12字节。
64位JVM下,默认开启压缩指针(-XX:+UseCompressedOops)时,Mark Word仍然是8字节,类型指针被压缩成4字节,普通对象头总共12字节,数组对象头则是16字节。如果关闭压缩指针(-XX:-UseCompressedOops),类型指针变成8字节,普通对象头16字节,数组对象头20字节,再加上对齐填充,实际分配往往还会往上走一点。
这个大小差异看上去微不足道,但是在百万、千万级别对象量的场景下,差出来的可能就是几百MB甚至上GB的堆内存。我曾经处理过一个缓存服务,里面全是小对象(每个对象就两三个int字段),堆内存一直居高不下,GC也频繁。后来把压缩指针相关参数核对了一遍,又把对象结构重新梳理了一下,才发现大量内存是被对象头和对齐填充白白吃掉的。这个后面用JOL实测的章节再演示。
1.2 Mark Word的位级拆分:状态位如何决定剩余位数的含义
Mark Word这8个字节是整个对象头的灵魂。它不是固定的某个数值含义,而是一个多状态复用的位容器。HotSpot会根据对象当前的状态(是否偏向锁、是否被加锁、是否处于GC标记状态等)来决定这64个bit每一段代表什么含义。
64位JVM下,Mark Word的常见复用状态是这样的(以HotSpot源码markOop.hpp为基础,结合偏向锁开启的情况):
| 锁状态 | 锁标志位 | 偏向锁位 | Mark Word的剩余bit分布含义 |
|---|---|---|---|
| 无锁(可偏向) | 01 | 1 | 前54bit存偏向线程ID等,后面存epoch、分代年龄、偏向标志、锁标志位 |
| 无锁(不可偏向) | 01 | 0 | 前62bit存hashCode,后面存分代年龄、偏向标志、锁标志位 |
| 偏向锁 | 01 | 1 | 前54bit存偏向线程ID,后面存epoch、分代年龄、偏向标志、锁标志位 |
| 轻量级锁 | 00 | 无意义 | 前62bit存指向栈中LockRecord的指针 |
| 重量级锁 | 10 | 无意义 | 前62bit存指向ObjectMonitor的指针 |
| GC标记 | 11 | 无意义 | 前62bit存GC转发指针,用于对象晋升或移动时定位新地址 |
这张表很多人看过,但没注意到两个关键点。第一,分代年龄只有4bit,所以最大只能记到15,这就是为什么-XX:MaxTenuringThreshold上限是15。第二,hashCode在没有调用的时候是不存在的,一旦调用了Object.hashCode(),这个值会被写进Mark Word,而且如果一个对象已经处于偏向锁状态,写hashCode会把偏向锁撤销掉。第二点很反直觉,实际项目里容易踩坑,后面单独说。
1.3 类型指针:压缩指针为什么能省这么多内存
类型指针指向的是对象的类元数据(Klass),JVM从堆里顺着这个指针能找到对象的Class信息,这样执行obj.getClass()、方法派发、类型检查等操作才有依据。
64位JVM如果不用压缩指针,一个指针8字节,两个指针就是16字节,光对象头就很重。压缩指针的原理其实不神秘:Java堆地址是8字节对齐的,也就是每个对象起始地址的末3位永远是0,那在存储指针的时候可以直接把末3位丢掉,用的时候再左移3位还原。32GB堆以内的地址,35位就能表示,压缩成4字节(32位)就够了。这就是为什么-XX:+UseCompressedOops的默认开启上限是堆小于32GB,超过32GB时默认就关掉了,因为4字节存不下完整地址了。
注意:压缩指针指的是类型指针压缩。还有一个独立的-XX:+UseCompressedClassPointers,专门压缩Metaspace里的类元数据指针,通常和UseCompressedOops联动。排查内存问题时要分清这两个参数,别调错了方向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁状态在Mark Word中的真实流转:偏向锁、轻量级锁、重量级锁切换细节
synchronized在JDK 1.6之后的大升级,核心就是把锁信息放进对象头,然后通过CAS和状态切换实现“低成本加锁”。理解对象头,就绕不开这段流转过程。很多网上的说法是“锁升级”三个阶段,听起来像单向通行,实际操作里细节比这个丰富。
2.1 从无锁到偏向锁:第一次加锁时Mark Word经历了什么
JVM启动后,大部分新创建的普通对象初始是无锁可偏向状态,Mark Word的偏向位是1,但偏向线程ID为0,表示“还没有人偏向”。这时候第一个线程执行到synchronized块时,JVM做的是偏向上锁:把Mark Word里的偏向线程ID改成当前线程ID,靠一次CAS完成,成本极低。
此后这个线程再次进入同一对象的synchronized块,不需要任何CAS操作,直接判断Mark Word里的偏向线程ID是不是自己,是的话就说明“锁还在我手里”,直接进入临界区。这就是偏向锁性能最高的原因——同一个线程反复加锁,几乎零开销。
2.2 轻量级锁的CAS替换与偏向锁撤销
偏向锁不是永远好使。一旦有另一个线程来竞争,偏向锁就得撤销。撤销的触发点是到达安全点(SafePoint),JVM暂停所有业务线程,检查持有偏向锁的线程是否还活着。如果已经不存活,直接把对象头恢复到无锁状态;如果还存活且正在执行临界区,就升级为轻量级锁。
轻量级锁的工作方式是:当前竞争的线程在自己的栈帧中生成一个LockRecord,然后尝试用CAS把Mark Word替换成指向这个LockRecord的指针。成功了,说明抢到锁;失败了,说明锁被别人持有,轻量级锁就膨胀成重量级锁。注意这个阶段没有操作系统级别的互斥,拿不到锁的线程会自旋,默认自旋次数不是固定的,JVM会根据运行状态动态调整。
很多人以为轻量级锁比偏向锁“高级”,其实不是。轻量级锁适合“多线程交替进入临界区,但同一时刻只有一个人持有”这种场景。如果两个线程高频竞争同一个锁,轻量级锁的自旋会白白消耗CPU,那还不如直接重量级锁挂起线程来得划算。
2.3 重量级锁:Mark Word里存的并不是monitor对象
这里有个高频误解,面试里好多人都答错。很多人说“重量级锁会把monitor对象放进Mark Word”,其实Mark Word里只存了一个指向ObjectMonitor的指针。ObjectMonitor本身是C++层面的数据结构,在JVM内部由ObjectSynchronizer维护,不在Java堆里。
Mark Word里的指针是一个压缩或普通宽度的地址,即使指针只有4字节,也足以定位到那个ObjectMonitor。而ObjectMonitor内部才有真正的等待队列、阻塞队列、计数器和持有者线程等信息。这种“间接寻址”的设计,保证了Mark Word无论处于什么锁状态,都只用64bit,不会因为锁升级而扩大对象头体积。
2.4 hashCode与偏向锁的冲突:线上排查时的一个经典坑
前面提到过,调用了Object.hashCode()之后,如果对象处于偏向锁状态,JVM要把它撤销,因为Mark Word里的bit已经被偏向线程ID占用了,没有额外空间存hashCode。这个坑在低并发场景里很隐蔽,但一旦出现,表现就是:
对象明明被同一个线程反复加锁,按理说走了偏向锁快路径,但实际性能下降明显。原因就是代码里在并发块内调用了hashCode(),每次都要撤销偏向锁、重新偏向,开销比预想大了一个量级。我遇到过类似场景,排查时通过火焰图发现synchronized块内的耗时异常,细看才发现是hashCode触发了偏向锁批量撤销。这个行为的本质不是JDK的bug,而是Mark Word位复用的必然代价。
| 操作 | 对偏向锁的影响 | 实际底层原因 |
|---|---|---|
| 调用Object.hashCode() | 撤销偏向锁,对象回到无锁可偏向状态 | Mark Word空间不足,hashCode与偏向线程ID冲突 |
| 调用System.identityHashCode() | 同样要存入Mark Word,触发撤销 | 同上 |
| 等待期间调用wait/notify | 直接膨胀为重量级锁 | wait需要ObjectMonitor支撑,偏向锁无法处理 |
| 两个线程频繁竞争 | 偏向锁撤销并升级轻量级/重量级 | 偏向锁只适合单线程重复获取场景 |
3. 用JOL实测64位JVM下的对象头与内存布局
概念讲得再多,不如打开工具看一眼。OpenJDK提供了一个叫JOL(Java Object Layout)的小工具,专门用来打印JVM里对象的内存布局。下面这些实测数据都是我在64位HotSpot、JDK 8和JDK 17上跑过的结果,可以帮你直观感受对象头是怎么影响内存的。
3.1 用JOL查看Object、数组、普通实例的布局差异
引入依赖:
xml复制<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
然后写段代码,分别打印一个空Object、一个int数组、一个自定义小对象的布局:
java复制import org.openjdk.jol.info.ClassLayout;
public class JolDemo {
static class Point {
int x;
int y;
}
public static void main(String[] args) {
System.out.println("=== Object ===");
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
System.out.println("=== int[3] ===");
System.out.println(ClassLayout.parseInstance(new int[3]).toPrintable());
System.out.println("=== Point ===");
System.out.println(ClassLayout.parseInstance(new Point()).toPrintable());
}
}
JDK 17默认开启压缩指针,输出大致如下:
text复制=== Object ===
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00000000c00003e0
12 4 (object alignment gap)
Instance size: 16 bytes
一个空Object,没有字段,却占了16字节,因为对象头12字节 + 对齐填充4字节。数组如下:
text复制=== int[3] ===
int[3] object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00000000c00003e0
12 4 (array length) 3
16 12 int [I.<elements> N/A
Instance size: 32 bytes
数组头16字节,3个int值12字节,正好32字节,无需填充。再看自定义的Point:
text复制=== Point ===
JolDemo$Point object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00000000c00003e0
12 4 int Point.x 0
16 4 int Point.y 0
20 4 (object alignment gap)
Instance size: 24 bytes
x和y各4字节,对象头12字节后直接连续排列,因为正好可以从12字节偏移开始放两个int,凑到20字节,再对齐到24。这组数据说明一个很现实的问题:如果业务里大量对象是这种“几个基本类型字段”的小对象,对象头占比非常高。Point类实际业务数据只有8字节,但总占用24字节,对象头加上填充占了三分之二。
3.2 压缩指针开关带来的体积差异
用下面的参数跑同一段代码,对比Point对象的占用:
bash复制# 开启压缩指针(默认)
java -XX:+UseCompressedOops JolDemo
# 关闭压缩指针
java -XX:-UseCompressedOops JolDemo
开启时Point对象24字节,关闭后变成24字节变成32字节左右(对象头16字节 + 字段8字节 + 填充)。单看一个对象不大,但假设一个系统里有500万个Point对象,关闭压缩指针会多出大约40MB堆内存。这只是一个小小的示例,真实系统中大量对象累积起来的差值非常可观。所以线上环境在堆小于32GB时,压缩指针默认开启的决策是非常合理的。
3.3 字段重排序对实际内存占用的影响
JVM允许在字段布局时重新排列顺序,也就是所谓的字段重排序。long/double占8字节,int/float占4字节,short/char占2字节,byte/boolean占1字节,对应类型的字段会尽量按大小分组排列,避免大量填充碎片。
举个例子:
java复制class Misaligned {
long a; // 8字节
int b; // 4字节
byte c; // 1字节
long d; // 8字节
boolean e; // 1字节
}
class Aligned {
long a;
long d;
int b;
byte c;
boolean e;
}
Misaligned类按照我们定义的顺序直接排,可能因为long字段需要对8对齐,中间插入大量padding,总占用明显高于Aligned类。JVM的字段重排序有时候会把这种情况自动优化掉,但字段数量多、继承层次复杂时,手写类的时候按“长字段在前,短字段在后”排列仍然是一个好习惯。
4. 对象头相关的典型问题排查方法与面试高频考点
对象头不只是理论,它跟线上问题诊断、面试题都有强关联。拿工程实际来说,OOM时如果学会分析对象头占比,排查效率会高很多。拿面试来说,对象头的位级结构、锁升级路径、hashCode与偏向锁冲突,都是可以深挖的考点。
4.1 OOM分析时为什么要关注对象头
线上OOM最常见两种情况:一种是对象真的太多,另一种是单个对象占用太大。前者往往和对象头紧密相关。比如一个List里塞了几百万个Integer对象,每个Integer对象本身就有16字节(对象头12字节 + int值4字节,正好16字节),而实际有效数据只有4字节。这种情况下堆内存被大量对象头吞噬,GC再频繁也救不回来,因为有效数据本身占用的比例太低。
处理这种场景,会有几个方向。第一,能用int[]就用int[],把基础类型数组化,数组元素不需要独立对象头,这是最直接省内存的方式。第二,改用基本类型包装类的替代方案,例如用trove4j、agrona等高性能原语集合库。第三,如果必须用对象,精简字段并把long分组排前面,减少对齐填充。这些思路的实施,都建立在你能看懂对象头分布的基础上。
4.2 锁诊断:怎么从对象头看一把锁当前的升级状态
JOL不光能看内存布局,还能看锁状态。对一个对象执行synchronized之后,再打印Mark Word,能明显看到低位标志变化。写段小代码验证一下:
java复制import org.openjdk.jol.info.ClassLayout;
public class LockStateDemo {
static final Object obj = new Object();
public static void main(String[] args) throws Exception {
System.out.println("=== 初始无锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 模拟偏向锁:这里让当前线程直接进入同步块
synchronized (obj) {
System.out.println("=== 偏向锁/轻量级锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
// 再开一个线程去竞争,让锁膨胀
Thread t2 = new Thread(() -> {
synchronized (obj) {
System.out.println("=== 另一个线程持锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
});
t2.start();
t2.join();
}
}
运行之后观察Mark Word那一行的十六进制值变化,高位段指向的地址会明显从偏向线程ID变成LockRecord指针或ObjectMonitor指针。实际项目里不会手动这么排查,但理解这个原理后,看到锁竞争相关的性能问题,你会更清楚去查偏向锁是否被关闭、是否在wait/notify调用后发生锁膨胀、是否存在大量并发线程冲击同一个锁。
提示:偏向锁从JDK 15开始被标记为废弃,JDK 18之后默认不启用偏向锁。新项目里讨论偏向锁更多是历史包袱问题,但查看Mark Word状态的方法和锁膨胀机制仍然适用。
4.3 面试常考的几个对象头“坑”
对象头这个话题在面试中特别容易被追问,我整理了出现频率最高、最容易答错的几个点:
- 一个空Object在64位JVM上占多少字节?答:默认压缩指针开启时,对象头12字节,对齐后16字节。
- Mark Word里存的是hashCode还是identityHashCode?答:存的是identityHashCode,也就是Object.hashCode()的默认实现算出来的值。重写hashCode()后,Mark Word里不会存重写后的值。
- 调用hashCode会触发偏向锁撤销吗?答:会,因为Mark Word空间不够,必须让出偏向线程ID的位置。
- 为什么分代年龄最大只有15?答:Mark Word里分配了4bit存分代年龄,最大表示15。就算你调大-XX:MaxTenuringThreshold,也不能超过15。
- 数组对象和普通对象的对象头区别在哪里?答:数组多4字节存length,所以普通对象头12字节、数组对象头16字节。
5. 踩坑记录:一次因关闭压缩指针引起的堆内存激增
理论说完了,讲一个真实案例。有一个内部工具服务,部署时JVM启动参数是运维同学从网上找的“性能优化模板”,里面加了-XX:-UseCompressedOops。原因是模板认为关闭压缩指针可以提升CPU访问性能,减少解压指针的CPU开销。结果这个服务内存占用比预期高了近30%,GC频次也明显上升。
当时排查的过程是这样的:先用jmap看heap dump,发现大量简单数据类型对象、字符串对象,总数非常多。每一类对象的instance size都比“应该的”大一圈。通过JOL打印布局,确认是对象头从12字节变成了16字节,对齐填充也随之增加。用MAT统计各类对象的shallow heap和retained heap,把关闭压缩指针的效果量化出来,服务在堆里大约有上千万个对象,每多4-8字节,累计多出几十甚至上百MB。最后把参数改回默认开启压缩指针,内存占用立刻下来了。
这个案例想说明的不是“关闭压缩指针一定不好”,而是任何JVM参数都要结合应用的对象数量、对象大小和访问模式来权衡。对于大量小对象的场景,压缩指针带来的内存节省远大于那点解压开销;对于极少数超大数组、对象数量很少的场景,关闭压缩指针也许可以换来微弱的CPU优势。绝大多数业务系统属于前者。
6. 对象头相关的一些实用观察方式,以及延伸思考方向
聊到最后,分享两个实际项目中常用的观察方式,顺带说说对象头知识还能往哪些方向延伸。
观察方式一是用JOL在本地或者压测环境打印目标对象的布局,观察对象头大小、字段排列、填充情况,这在你准备内存优化时非常有用。观察方式二是用jol的GraphLayout类统计一批对象的总占用,比如统计一个List里的所有对象实际占了多少内存,可用来精确评估集合的内存成本。
java复制import org.openjdk.jol.info.GraphLayout;
public class GraphLayoutDemo {
public static void main(String[] args) {
java.util.List<Integer> list = new java.util.ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(i);
}
long total = GraphLayout.parseInstance(list).totalSize();
System.out.println("totalSize = " + total);
}
}
延伸方向上,对象头是理解对象存储的起点,再往深处走还有几条线:一是对象生命周期与GC的关系,比如对象如何从Eden晋升到Old、分代年龄怎么更新;二是内存逃逸分析和栈上分配,小对象如果在方法内不逃逸,JIT可能把它分配在栈上或优化成寄存器,完全绕开堆内存和对象头;三是JVM的字段压缩和值类型(Project Valhalla)未来可能带来的内存布局变化。每一条都会不断刷新你对“对象存储”的认知。
回到最初的问题:new Object()到底占多少内存?现在答案很清晰了,64位JDK默认参数下,16字节。而这个答案背面的宽度,足够你展开讲半小时,从对象头的位级复用讲到锁升级、内存压缩、GC分代、逃逸分析。能把这条线讲透的人,写代码和排查问题的水平都不会差。
