1. 一次模拟面试的反问:你说"锁升级",可锁到底存在哪个比特里
最近帮几个朋友做模拟面试,发现一个特别有意思的现象:只要问到 synchronized 底层原理,十个候选人里至少有七个能说出"JDK 6 之后有锁升级""偏向锁、轻量级锁、重量级锁"这条主线。但当我继续追问一句"那你对象头里的 Mark Word 一共几个比特?偏向锁和无锁状态分别长什么样?"——当场卡壳的占一半以上。
这个细节恰恰是 synchronized 底层原理真正的分水岭。背过整体流程的人很多,能把每个状态在内存里的二进制布局讲清楚的人很少。而面试官问 synchronized,通常并不指望你把 HotSpot 源码逐行背出来,他真正想确认的是:你对"锁"这个抽象概念有没有落到底层的能力——它到底锁住了什么,用什么东西记录了持有者,竞争发生时 JVM 在用户态和内核态之间做了哪些事情。
先说三句话结论,方便你后面带着主线往下看:
synchronized的一切行为围绕对象头里的 Mark Word 展开,Mark Word 会随着锁状态变化而重用存储语义。- 锁升级本质是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,从左到右成本递增,触发条件完全由竞争程度决定。
- 重量级锁最终依赖操作系统底层的互斥量来阻塞线程,这也是"重量"二字的来源。
还要补充一个很多人忽略的前提:JDK 8、JDK 11 和 JDK 17 的锁行为并不一样。JDK 15 之后偏向锁被默认禁用,JDK 18 之后被标记废弃,这对你回答"偏向锁怎么撤销"这类问题影响很大。所以我建议先确认你面试用的 JDK 版本,再决定话术怎么组织。下文我会把 JDK 8 的经典路径和新版本 JDK 的简化路径都讲清楚,这样不管面试官用的是哪条线,你都能接住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从字节码入手:synchronized 编译后到底"翻译"成了什么
要理解 synchronized 的底层原理,绕不开的一个动作是反编译。很多人在源码层面看 synchronized 觉得没什么可研究的,就是个关键字,但编译成字节码之后,它的实现痕迹暴露得很明显。
2.1 同步代码块:monitorenter 与 monitorexit 的成对出现
先看一段最普通的代码:
java复制public class SyncDemo {
private final Object lock = new Object();
public void hello() {
synchronized (lock) {
System.out.println("hello synchronized");
}
}
}
编译后用 javap -c -v SyncDemo.class 反编译,关键的指令序列是这样的:
java复制public void hello();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #7 // Field lock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #19 // String hello synchronized
12: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_1
16: monitorexit
17: goto 25
20: astore_2
21: aload_1
22: monitorexit
23: aload_2
24: athrow
25: return
Exception table:
from to target type
7 15 20 any
进入同步块时执行 monitorenter,正常退出同步块时执行 monitorexit。注意,为了应对同步块里抛异常导致锁释放不了的情况,编译器在字节码层面生成了两条 monitorexit 路径:一条是正常路径(偏移量 16),一条是异常路径(偏移量 22)。异常表里记录了 7 ~ 15 这段范围如果抛出任何异常,就跳到偏移量 20 的处理逻辑,先执行 monitorexit 释放锁,再把异常重新抛出。
这个设计很多人第一次看会忽略,但它是"synchronized 在异常情况下也能保证锁一定释放"的底层证据。JVM 不是靠 try/finally 帮你兜底,而是编译器直接生成异常处理指令来保证的。
2.2 同步方法:没有 monitorenter,靠的是 ACC_SYNCHRONIZED 标志
再看同步方法的情况:
java复制public synchronized void sync() {
System.out.println("sync method");
}
反编译后你会发现,方法体里根本没有 monitorenter / monitorexit 指令,差别在方法的访问标志上:
java复制public synchronized void sync();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
...
JVM 在调用一个方法时,会先检查 ACC_SYNCHRONIZED 标志。如果存在,当前线程就需要先成功持有该方法所属对象的 monitor(管程),然后才能进入方法体;方法正常结束或抛异常结束,JVM 自动释放 monitor。
也就是说,同步代码块和同步方法走的是同一条底层链路——最终都要获取对象的 monitor,只是字节码层面的"入口检查"方式不一样。一个是显式指令,一个是方法标志。知道这个区别,面试中如果拿到反编译题,你就不会慌。
2.3 可重入性在字节码层面是如何体现的
说到可重入,我顺便把这个问题也拆了。synchronized 允许同一个线程反复进入同一把锁,比如:
java复制public void outer() {
synchronized (lock) {
synchronized (lock) {
System.out.println("inner");
}
}
}
外层进入时执行一次 monitorenter,内层再进入时再次执行 monitorenter,但这次不会真的去竞争锁。真正实现可重入的是字节码往下 monitor 的内部计数器——我在第 4 章讲 ObjectMonitor 结构时会详细展开。简单说,重复进入就是同一个 monitor 持有者的计数器累加,每次 monitorexit 递减,直到归零才真正释放锁。
3. Mark Word:打开 synchronized 底层的唯一钥匙
聊完了字节码,接下来就要进入真正的底层了。JVM 里的锁不是凭空存在的,它挂在每个 Java 对象的对象头里。这里最核心的东西,就是 Mark Word。
3.1 64 位 JVM 下对象头的比特分配
Java 对象在内存中的布局分三块:对象头、实例数据、对齐填充。对象头又包含两部分:
- Mark Word:固定 8 字节(64 位),记录对象运行时的数据,包括哈希码、GC 分代年龄、锁状态标志、持有锁的线程信息等。
- Klass Pointer:指向该对象的类元数据。默认开启压缩指针时占用 4 字节;关闭压缩指针时占用 8 字节。如果开启了
UseCompressedOops,数组对象的对象头里还会多一个长度字段。
面试中的 synchronized 底层原理,主要就看 Mark Word 这 8 个字节怎么变。注意:以下所说的都是64 位 JVM 的情况,32 位 JVM 的位数分配不同,但逻辑一致。
Mark Word 的 64 个比特,在不同锁状态下有完全不同的含义:
| 锁状态 | 56 或 62 bit 有效字段区 | 偏向锁位(1 bit) | 锁标志位(2 bit) |
|---|---|---|---|
| 无锁 | unused:25 / identity_hashcode:31 / unused:1 / age:4 |
0 | 01 |
| 偏向锁 | thread:54 / epoch:2 / unused:1 / age:4 |
1 | 01 |
| 轻量级锁 | 指向栈中 Lock Record 的指针(62 bit) | - | 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针(62 bit) | - | 10 |
| GC 标记 | - | - | 11 |
这里有一个非常容易混淆的细节:无锁状态和偏向锁状态的锁标志位都是 01,区分靠的是偏向锁位:无锁时偏向锁位是 0,偏向锁时是 1。所以判断一个对象当前是不是偏向锁,不能只盯后两位,还得看第 3 位。
3.2 为什么锁状态能"复用"同一块存储
Mark Word 的设计巧妙之处在于:同一块内存,在不同语义下被反复解读。无锁时,高 31 位存 identity hashcode;偏向锁时,高 54 位换成线程 ID;轻量级锁时,直接整体变成一个 62 位的指针。
这种"歧义复用"和 CPU 里寄存器的一词多义很像——同一个寄存器,在不同指令下可能被解释成操作数、地址或者立即数。JVM 之所以敢这样设计,是因为一种锁状态下,原本字段的值恰好不再需要。
举一个经典的例子:你调用了 Object.hashCode() 之后,对象头的无锁状态里会缓存这个哈希码。一旦对象进入偏向锁状态,hashCode 的 31 个比特位就被线程 ID 覆盖了,所以 一个对象调用了 identity hashCode 之后,就再也没法进入偏向锁状态。这是面试里特别喜欢挖的细节:为什么 System.identityHashCode(obj) 会影响偏向锁?因为 hashcode 占的比特位和偏向锁的 thread 字段占的是同一块空间,二者不可兼得。
3.3 两个 bit 的锁标志位能表达多少状态
锁标志位只有 2 bit,二进制组合有 00、01、10、11 四种,而 JVM 要表达的锁状态足足有五种:无锁、偏向锁、轻量级锁、重量级锁、GC 标记。所以无锁和偏向锁必须共用 01 这个编码,通过偏向锁位再做一次细分。
这种压缩设计也解释了为什么偏向锁撤销后,Mark Word 要恢复到无锁布局而不是别的布局——因为偏向锁和无锁本身就在同一条编码分支上,撤销偏向锁只需要把偏向锁位清零、把 thread 字段还原即可。这个操作实际上比你想的要复杂,牵扯到 epoch 和批量重偏向,我放到下一章详细讲。
4. 偏向锁、轻量级锁、重量级锁:一整条升级路径的完整拆解
这一章是全篇的核心。我用一个完整的多线程竞争场景,把锁升级的每一段讲透。你跟着这个场景走,面试中的后续追问基本都能兜住。
4.1 偏向锁:在 Mark Word 里写入线程 ID,就是"偏向"的真相
偏向锁的设计动机非常简单:大量实践的锁从未发生竞争。同一个线程反复进入同一个同步块,如果每次都要做一次 CAS 重排指针,那纯粹是浪费。所以偏向锁的思路是:第一次拿到锁时,把线程 ID 通过 CAS 写进 Mark Word,之后这个线程再进来,只要看一眼 Mark Word 里的线程 ID 是不是自己,是就直接进入。
这个流程可以拆成三步:
- 线程 A 第一次进入同步块,发现 Mark Word 的锁标志位是
01、偏向锁位是 0(无锁状态)。 - JVM 用 CAS 操作把 Mark Word 的偏向锁位置 1,同时把 A 的线程 ID 写入 thread 字段。
- 线程 A 之后重复进入,直接比较 thread 字段,匹配就不做任何同步操作,直接执行临界区代码。
需要特别注意的是,在 JDK 8 中偏向锁默认是延迟启动的,HotSpot 在 JVM 启动后的 4 秒内会强制关闭偏向锁,等待一段时间后再打开。原因是 JVM 启动初期有大量内部锁操作,这些对象的线程模型不稳定,做偏向优化反而要付出撤销的代价。所以在 JDK 8 里跑 JOL 实验时,如果想立刻看到偏向锁,必须加 JVM 参数 -XX:BiasedLockingStartupDelay=0 才能把延迟关掉。
那么偏向锁什么时候撤除呢?核心触发条件是:另外一个线程 B 尝试获取这个锁。B 进来后发现 Mark Word 已经是偏向状态,而且偏向的不是自己,说明出现了竞争。这时 B 不会立刻把锁抢过来,而是先尝试 CAS 把线程 ID 改成自己。如果这期间 A 已经退出了临界区,意味着锁处于"闲置偏向"状态,B 一次 CAS 就能"偷"走偏向权;如果 A 还在临界区里,说明锁正被持有,JVM 需要撤销偏向锁,把锁升到轻量级或更高级别。
这里有个面试高频反问:偏向锁的撤销算不算一次锁竞争? 答案是:不算锁竞争,但算一次性能损失。偏向锁撤销要走到全局安全点(SafePoint),暂停所有用户线程,再判断持有者是否存活、是否退出临界区,然后决定是偏向给新线程还是升级。这个成本相当高,所以 JVM 引入了"批量重偏向"和"批量撤销"两种机制,避免大量对象反复做无意义的偏向撤销。简单理解:如果同一个类的大量对象都在不停撤销偏向锁,说明这个类的对象锁竞争非常激烈,JVM 会直接把整个类的偏向功能关闭。这就是为什么偏向锁的收益在现代应用上越来越不明显。
4.2 轻量级锁:把 Mark Word 换成 Lock Record 指针,加上自旋兜底
退出偏向后,如果竞争并不激烈,JVM 不会直接扔给操作系统,而是先升级到轻量级锁。轻量级锁的核心思想是:用 CAS 代替互斥量,在用户态解决锁竞争。因为很多临界区的执行时间短到几十条指令,为了这么短的时间把线程挂起、再唤醒,来回切换内核态的成本远大于自旋等待。
轻量级锁的获取流程:
- 线程 B 在栈帧里建立一个
Lock Record(锁记录)空间,里面准备存放 Mark Word 的副本。 - 复制当前的 Mark Word 到这个 Lock Record(这个副本叫
displaced mark word)。 - 通过 CAS 尝试把对象头的 Mark Word 替换成指向 Lock Record 的指针。
- CAS 成功:B 获得轻量级锁。
- CAS 失败:说明 Mark Word 已经被别的线程改成指针了,锁已有持有者,B 进入自旋,不停重试 CAS。
轻量级锁的性能关键就在第 5 步的自旋。早期 JDK 的自旋次数固定,容易误伤——明明临界区要执行挺久,短的线程还傻转半天。JDK 6 引入了自适应自旋:JVM 会根据上一次在同一把锁上自旋等待后成功获取锁的概率,动态调整本次自旋次数。如果等待概率高,就多转一圈;如果等了很久都没拿到,就直接放弃自旋,准备膨胀。
竞态加剧之后,膨胀发生在以下情况:自旋尝试达到阈值、或者 CPU 核心已经无力承担更多自旋线程。JVM 会把 Mark Word 再次替换,这次指向的是一个 ObjectMonitor 对象。
4.3 重量级锁:ObjectMonitor 和内核级阻塞的代价
偏向锁、轻量级锁都只处理了"竞争不激烈"的场景。一旦多个线程长时间纠缠,JVM 只能祭出重量级锁:把锁对象关联到一个 ObjectMonitor,即"对象监视器"。这是 synchronized 的最底层实现。
面试中问到 ObjectMonitor,你至少要把下面几个关键字段说出来:
| 字段 | 作用 |
|---|---|
_owner |
当前持有锁的线程 |
_recursions |
重入计数;同一个线程重复加锁时累加 |
_EntryList |
等待获取锁的线程队列 |
_WaitSet |
调用了 wait() 后处于等待状态的线程队列 |
_cxq |
竞争队列,新来的竞争线程先进这里 |
重量级锁的"重"体现在:线程获取锁失败后,会被挂起进入内核态,通过操作系统底层的互斥量(如 pthread mutex)来阻塞和唤醒。这个过程涉及用户态和内核态的切换,每一次切换都是开销。如果临界区很短,这个开销可能比业务代码本身还要大一个数量级。这也是为什么 Java 官方后来引入 ReentrantLock 以及各种 JUC 工具,核心目的之一就是弥补 synchronized 在可控性和灵活度上的不足。
说到可重入,这里终于能给出完整答案了。ObjectMonitor 里有一个 _recursions 字段,专门记录同一个线程重复进入锁的次数。线程 A 第一次获取重量级锁,_owner 设为 A,_recursions 设为 0 或 1(不同版本起点有差异);A 再次进入同一把锁,_recursions 加 1;每次 monitorexit,_recursions 减 1;只有当 _recursions 归零时,锁才算真正释放,_owner 才允许被设置为 null。
4.4 锁升级不是"一有竞争就一路狂奔"
最后必须强调一个常见误区:偏向锁撤销后不一定会立刻升到重量级锁。锁状态是"哪里热闹,就往上升一级",并且升级路径可以跳级:
- 只有一个线程访问:偏向锁。
- 出现竞争但很快结束:轻量级锁 + 自旋。
- 自旋很久仍然失败:膨胀为重量级锁。
而且,一旦重量级锁建立,后续竞争都会直接走重量级路径,不会退回轻量级。偏向锁一旦被批量撤销,也可能直接跳过轻量级阶段。锁升级是单向且不可逆的,这是面试里常被追问的边界条件。
5. JDK 15 之后:偏向锁被默认禁用,锁路径彻底简化了
如果你用 JDK 17 或者 JDK 21 做实验,会发现在没有配置任何参数的情况下,对象头里的偏向锁位永远是 0。这不是你没触发成功,而是 JDK 15 之后偏向锁被默认关掉了。
5.1 JEP 374 和 JEP 421:官方为什么要放弃偏向锁
JDK 15 引入 JEP 374("Deprecate and Disable Biased Locking"),默认禁用偏向锁;JDK 18 的 JEP 421 进一步将其标记为废弃。官方给出的理由包括维护成本高、偏向锁撤销需要全局安全点,而现代 Java 应用大量使用了线程池和不可变对象,许多场景下偏向锁收益有限,反而徒增复杂性和不可预期停顿。
我个人的观察是:自从偏向锁默认关闭,很多面试教程还在讲"加锁后 Mark Word 变成偏向锁 101",这部分内容在 JDK 17 的默认环境里已经不会发生了。如果你面试时用的是 JDK 17 或以上版本,再把偏向锁当成"当前默认行为"去讲,面试官反而会觉得你的知识体系停在五年前。
5.2 新版本的锁路径:无锁、轻量级锁、重量级锁
新版 JDK 默认禁用偏向锁之后,锁路径变成了一条更干净的直线:
- 无锁状态(Mark Word 保存 hashCode、age 等)。
- 第一个线程进入同步块,直接用轻量级锁的 CAS 方式获取锁。
- 竞争加剧,自旋失败后膨胀为重量级锁。
也就是说,偏向锁这个状态从"默认路径"变成了"历史选项"。如果你想在新版本里观察偏向锁行为,可以尝试显式加参数 -XX:+UseBiasedLocking,但官方会打印弃用警告,而且不保证后续版本还支持。
这个变化也影响了实际业务调优:如果你用 JDK 8 且跑了大量线程池任务,可以评估关闭偏向锁是否减少安全点停顿;如果你用 JDK 17+,那就不存在这个开关了,应该把优化重心放在减少临界区持有时间、降低锁粒度上。
5.3 面试中怎么答才不容易踩坑
建议你在回答锁升级的时候,先加一句前提:
"如果是在 JDK 8 的默认配置下,锁升级路径是无锁到偏向锁,再到轻量级锁,最后到重量级锁;但如果面试环境是 JDK 15 之后,偏向锁已经被默认禁用,路径会简化为无锁到轻量级锁再到重量级锁。"
这句话一出来,面试官就知道你对版本差异有概念。然后再展开讲偏向锁在 JDK 8 下的行为逻辑,和 ObjectMonitor 的结构。这样既展示了知识广度,又避免了被抓版本漏洞。
6. 实战排查:如何确认你的锁到底处于什么状态
光懂理论还不行,真在线上排查问题的时候,需要能够观察到锁的状态。我分享几个亲身用过的工具和实验方法。
6.1 用 JOL 直接打印对象头
JOL(Java Object Layout)是 OpenJDK 提供的一个工具,可以直接打印对象的内存布局。在项目里加依赖:
xml复制<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
然后写一段实验代码:
java复制import org.openjdk.jol.info.ClassLayout;
public class LockStateDemo {
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());
}
}
}
注意:如果跑在 JDK 8 上,需要在启动参数里加 -XX:BiasedLockingStartupDelay=0,否则前 4 秒看不到偏向锁。打印结果里你会看到 Mark Word 的十六进制,比如带 0x05 结尾(二进制 ...101,偏向锁位为 1、锁标志为 01)就是偏向锁;指向某地址的就是轻量级锁。
6.2 用 jstack 定位重量级锁下的阻塞线程
当锁膨胀到重量级锁以后,竞争线程会被挂起。此时用 jstack <pid> 抓线程栈,通常能看到类似下面这种关键行:
code复制"pool-1-thread-2" #13 prio=5 os_prio=0 cpu=1.32ms elapsed=22.41s tid=0x...
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.demo.LockDemo.business(LockDemo.java:15)
- waiting to lock <0x00000007108e4468> (a java.lang.Object)
at com.example.demo.LockDemo.lambda$main$0(LockDemo.java:25)
看到 BLOCKED (on object monitor) 和 waiting to lock,基本可以断定锁已经膨胀成重量级锁。再配合 -XX:+PrintFlagsFinal 查看自旋相关参数,能更准确判断是从轻量级升上来的,还是直接走重量级。
6.3 用 JFR 定位锁竞争热点,而不是瞎猜
如果线上业务出现锁竞争,最简单的排查手段是用 JFR(JDK Flight Recorder)录制一段事件,然后分析 "Java Monitor Blocked" 和 "Java Monitor Enter" 事件:
bash复制jcmd <pid> JFR.start name=lock_check duration=60s settings=profile
jcmd <pid> JFR.dump name=lock_check filename=lock_check.jfr
打开 JFR 文件后,重点看哪个锁对象的阻塞持续时间最长、哪些线程长时间处于 Blocked。很多时候你以为瓶颈是锁竞争,实际是某个线程把锁拿住后在做网络 IO,另一个线程排队排到怀疑人生。这种场景下锁本身没错,错的是临界区范围太大——优化方向根本不是换锁,而是把耗时操作移出同步块。
6.4 实践中我会优先考虑的优化顺序
结合我的一些经验,遇到锁竞争明显的问题,我会按下面的顺序排查和优化:
- 先确认临界区代码量。如果临界区里有 IO 操作、远程调用、日志打印,先把它移出去,往往立竿见影。
- 检查锁粒度。多个无关业务共用一把锁,考虑用分段锁、读写锁或
ConcurrentHashMap等并发容器替代。 - 评估能否用无锁方案。比如
AtomicInteger、ThreadLocal、CopyOnWriteArrayList,很多场景用不上synchronized。 - 最后才考虑换
ReentrantLock,利用它的超时、中断、多个条件队列等能力来精细化控制。
排查锁问题和调锁,最忌讳的就是不看监控凭感觉改。JFR 和 jstack 是定位锁竞争的标配工具,平时多跑几次线上环境的录制,比临时抱佛脚看 JVM 参数靠谱得多。
写到这里,我再分享一个面试中的个人体会:很多候选人把 synchronized 相关八股背得滚瓜烂熟,但一到"JDK 版本差异"或者"对象头比特分配"这类细节就露馅。我的建议是,在理解锁升级主线的基础上,自己动手跑一遍 JOL 实验,把 Mark Word 的状态变化亲手打出来,再把 jstack 里的 waiting to lock 和 on object monitor 看熟悉。有了这层身体记忆,面试时不管从哪个角度被追问,都不会慌。
