1. 先从 synchronized 的对象头说起:锁状态其实是“身份标签”
聊 Java 并发,偏向锁、轻量级锁、重量级锁这三个词几乎一定绕不开。很多人把“锁升级”背成了一张流程图:先偏向,再轻量,最后重量。但真正到了线上,你会发现这套流程并不是每次同步代码都会完整走一遍,甚至有些场景根本没走偏向锁就直接膨胀了。想搞清楚这个问题,第一步得先认识 Java 对象在 JVM 里用来存锁状态的那块地方:Mark Word。
HotSpot 里的每个 Java 对象都有对象头,Mark Word 就是对象头里最核心的一小段空间。它平时既存对象的无锁状态,也存分代年龄、哈希值,还存各种锁状态下的指针。无锁、偏向锁、轻量级锁、重量级锁这四种状态,本质上就是在改写 Mark Word 里的标志位和指针。你可以把 Mark Word 理解成门牌上的一个可改写标签:同一扇门,标签不同,代表当前用了哪种锁方案。
下面这张表可以帮助先建立整体印象:
| 锁状态 | Mark Word 里主要信息 | 用大白话记忆 |
|---|---|---|
| 无锁 | 对象哈希、分代年龄,偏向标志为 0 | 还没人给它贴“专属名字” |
| 偏向锁 | 偏向线程 ID、epoch | 标签上写了某个线程的名字 |
| 轻量级锁 | 指向线程栈中 Lock Record 的指针 | 锁被当前线程夹在自己的栈里 |
| 重量级锁 | 指向 ObjectMonitor 的指针 | 锁已经移交给 JVM 的监视器对象 |
这套升级路线是 HotSpot 为了照顾不同竞争程度的临界区而设计的:如果只有一个线程反复进出,就尽量省掉没必要的原子操作;如果真的有多个线程抢,再一步一步加重手段。但这不代表每次加锁都会严格“从偏向走到重量”。竞争很激烈时,JVM 在合适的时机可以直接膨胀,没必要非挤进轻量级锁那一层。
理解锁升级,关键不是死记状态名,而是搞清每个状态省了什么、贵在哪、什么时候值得用。
2. 偏向锁:单线程重复进入的“专属通道”
2.1 设计初衷与获取过程
偏向锁对应的是“其实只有一个线程在反复加锁”的场景。这个问题在早期 Java 里非常现实:synchronized 如果每次都走操作系统级别的互斥量,哪怕临界区短到只有几条指令,线程还是得付出内核态切换的成本。JDK 6 之后,HotSpot 引入了偏向锁,思路很简单:既然只有你一个人来,那就在对象头里记录下“你是主人”,以后你再来时直接放行。
具体来说,线程第一次进入 synchronized 块时,如果对象处于可偏向状态,JVM 会通过 CAS 把当前线程的 Thread ID 写进 Mark Word。之后同一线程再次进入,先检查对象头里的 Thread ID 是不是自己。如果是,连 CAS 都不用做,直接进入临界区。这是偏向锁最大的优势:把原本每次加锁都需要的原子操作,变成一次普通的内存比较。
偏向锁在 JDK 6 时代默认就是开启的,而且为了避开 JVM 启动阶段复杂的类加载、堆初始化,早期版本还留了一个 4 秒的启动延迟。想要在 JDK 8 这类老版本上立即看到偏向锁效果,可以这样设置:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0如果完全不想用偏向锁,也可以关掉:
-XX:-UseBiasedLocking注意,这条参数在 JDK 15 之后基本没意义了,因为偏向锁从 JDK 15 开始默认禁用并被标为废弃,后续版本逐步退出了默认行为。很多公司在面试题里还保留着偏向锁八股文,但在新 JDK 上做压力测试时,你实际观察到的默认行为可能已经不是这套了。
2.2 偏向锁撤销为什么这么贵
偏向锁最大的风险,不是获取,而是撤销。一旦第二个线程来抢这把锁,JVM 不能简单地把 Mark Word 里的 Thread ID 改掉,因为线程 A 可能正停留在临界区里,改掉之后它对锁的“所属权”就解释不清了。所以偏向锁的撤销必须在安全点(Safepoint)完成:先把所有 Java 线程暂停,检查原偏向线程是否还活着、是否还在临界区,然后决定是把对象锁状态改回无锁,还是升级成轻量级锁甚至重量级锁。
这就是为什么偏向锁在真实竞争下可能反而变成负担。如果一个对象被多个线程短时间、交替、激烈地访问,偏向锁会不断触发撤销,而每次撤销都伴随一次全 JVM 停顿,哪怕只是很短的时间,在延迟敏感的系统里也足以造成尖刺延迟。
另外还有一个容易忽略的坑:只要调用过System.identityHashCode(),或者对象本身的 hasCode 被计算过,偏向锁就没法用了。因为 Mark Word 里需要空间存哈希值,而偏向线程 ID 也会占同一块地方,二者冲突。所以你在测试偏向锁时,千万别随手打印对象的 hashCode,否则你以为是偏向锁,实际上已经悄悄变成普通无锁了。
3. 轻量级锁:没有实际竞争时的“自旋替代方案”
3.1 Lock Record 与 CAS 的配合
当偏向锁被撤销,或者 JVM 本身关闭了偏向锁,第一次加锁通常会走轻量级锁。轻量级锁针对的场景是“多个线程轮流进锁,但真正并发产生冲突的概率很低”。这时候 JVM 不想让线程去调用阻塞原语,而是尽量让加锁过程变成一次 CAS,不成功就再试几次。
整个过程是这样的:每个线程在进入 synchronized 之前,会在自己的栈帧里创建一个 Lock Record。加锁时,线程会把对象头 Mark Word 的原始状态拷贝到这个 Lock Record 里,然后尝试用 CAS 把对象头的 Mark Word 改成指向自己栈里那个 Lock Record 的指针。
如果 CAS 成功,说明锁已经拿到,对象头里记录的不再是无锁状态,而是“我把它放在我这个线程的栈上了”。释放时,再通过 CAS 把 Lock Record 里保存的原始 Mark Word 写回对象头,锁就还原了。
如果 CAS 失败,JVM 会先判断一下是不是同一个线程在重入。如果是重入,那当前线程其实已经拥有锁了,不需要真的再抢,只需要往自己的栈里继续压入一个 Lock Record。这样,锁的可重入性不需要通过计数器记录,而是通过栈里多条 Lock Record 来体现,既简单又快速。
3.2 为什么竞争不激烈时比重量级锁快
很多资料把重量级锁说成“有线程竞争就要升级”,实际并不完全准确。准确的说法是:线程发现轻量级锁 CAS 失败了,会先试着自旋,也就是循环等待几次,期待持有锁的线程能很快释放。自旋本质上是在用 CPU 时间换内核切换的时间。如果临界区很短,比如只是累加一个变量,自旋等一两百纳秒就能等到锁,这比让线程睡下去、之后再唤醒省得多。
自旋几轮之后仍然拿不到锁,JVM 才会考虑把轻量级锁膨胀成重量级锁。也就是说,锁定级到重量级的真正触发点是“短时间自旋无法解决竞争”,而不是单纯出现两个线程同时摸锁就算升级。
我建议你在看相关源码或博客时,注意区分“轻量级锁失败”和“轻量级锁膨胀”这两件事。轻量级锁失败只代表 CAS 没成功,不代表一定要马上挂起线程;膨胀策略是 JVM 根据历史自旋成功率动态调整的。老版本的 JVM 还有参数可以手动控制自旋,但现代 HotSpot 默认用自适应自旋,不需要也不建议去调。
4. 真正触发重量级锁的临界点:膨胀与 ObjectMonitor
4.1 膨胀到 ObjectMonitor 后发生了什么
当多个线程真正抢同一把锁,自旋又拿不到,JVM 就会把锁对象膨胀为重量级锁。重量级锁的核心是 ObjectMonitor,这是 JVM 内部的一个监视器对象。线程状态、获得锁的线程、等待队列、可重入计数等,全都在 ObjectMonitor 上管理。
一旦进入重量级锁,阻塞和唤醒就不再是“自己原地转圈”,而是交给 Java 线程调度和操作系统层面的互斥量。竞争失败的线程会被挂起,进入 EntryList 等待队列;持有锁的线程释放后,会从等待队列里唤醒一个或多个线程。这个过程可靠,但有明显的上下文切换开销,也可能有线程饥饿问题,所以整体吞吐量在高竞争下取决于唤醒策略和临界区长度。
重入在重量级锁里也好处理了:ObjectMonitor 内部有一个递归计数,同一个线程再次进入同一个 monitor 时,计数器加一,JVM 记录的是同一个线程,不会误判成别人来抢。和轻量级锁用栈上 Lock Record 表示重入的方式相比,这是另一套更重量但更通用的设计。
4.2 哪些情况会跳过轻量级锁直接膨胀
有几类情况会让锁直接从无锁状态跳到重量级锁,或者说得更准确些,根本不考虑偏向和轻量级方案:
第一,调用wait()或notify()。这套方法依赖 ObjectMonitor 的 WaitSet,轻量级锁状态下没有类似结构。虽然从语法上看,线程持有 synchronized 后调用wait()是合法的,但 JVM 内部为了保证等待通知机制可用,会把对象膨胀到重量级锁。这也是为什么面试喜欢把wait()和锁升级放在一起问:不只是问你 wait 和 sleep 的区别,还问 JVM 底层如何满足 wait 对监视器的要求。
第二,锁竞争从第一个线程进入时就已经很明显。如果一个对象在多线程环境下第一次被 synchronized 访问,而 JVM 通过偏向撤销或锁竞争记录判断出它会成为热点锁,那么它并不会规规矩矩走“无锁 -> 偏向 -> 轻量 -> 重量”的四级路线,而是直接膨胀。所以“逐级升级”只是理想化的简化描述,实际行为要更激进。
第三,如果代码里编译器做了锁消除,那连锁都不存在了。HotSpot 在开启逃逸分析时,如果发现锁对象不会逃逸出当前线程,会把整个 synchronized 直接去掉。你在 JIT 后的机器码里根本看不到锁操作,自然也没有升级过程。
4.3 重量级锁释放后会不会降级
面试里经常有人问“重量级锁释放后能不能降回轻量级锁”。从规范层面说,重量级锁保存了完整的竞争信息,没有必要每次释放都去评估是否该降级。而且反复膨胀、降级本身也是一笔开销,还会让队首线程的唤醒逻辑变得复杂。
所以你可以把升级理解为“从简方案搬到完整方案”的过程,更准确的说法是膨胀。HotSpot 对部分空闲 monitor 也有后台回收/收缩机制,但在稳定的高竞争场景下,锁对象长期处于重量级状态是很正常的。不要指望同一把锁能根据竞争度自动降回轻量级锁。
5. 实操:用 JOL 观察一把锁的 Mark Word,别只停留在“背结论”
说再多理论,不如自己跑一遍。平时做 Java 并发实验,我常用 JOL 打印对象布局,它可以把对象头和 Mark Word 的字节信息显示出来。用 Maven 引入:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>然后写一个简单的类:
import org.openjdk.jol.info.ClassLayout; public class LockStateShow { public static void main(String[] args) throws Exception { Object lock = new Object(); System.out.println("初始状态:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 故意计算一次 IdentityHashCode,观察对偏向锁的影响 // System.out.println(System.identityHashCode(lock)); for (int i = 0; i < 5; i++) { synchronized (lock) { System.out.println("第 " + i + " 次 synchronized 内部:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } } Thread.sleep(6000); System.out.println("等待 6 秒后,偏向锁延迟结束:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } }用 JDK 8 跑时,建议加上-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0。你会看到对象头的 Mark Word 在无锁、偏向锁、轻量级锁状态之间变化。需要提醒的是,JOL 打印出来的是小端序的字节,直接看十六进制很容易看晕,所以重点不是背某个具体十六进制值,而是对比同一行字节在进入 synchronized 前后发生了什么变化。
手动观察时一定要记住两个坑:
- 不要随便调用
hashCode(),因为计算过 identity hashCode 的对象,会影响偏向锁的启用,Mark Word 里哈希值占用位和偏向线程 ID 冲突。 - 如果加了大量同步日志,锁粗化甚至锁消除可能会改变真实行为。你看到的优化结果是 JIT 之后的,不是源码里表面那个 synchronized 的直接体现。
正确打开方式是把上面的类变成两个版本,一个开启偏向锁,一个关闭偏向锁,各自跑一大轮同步操作,记录耗时。对单线程反复加锁的场景,偏向锁往往有明显优势;而一旦换成多线程频繁争抢,关闭偏向锁后反而更稳定,因为撤销偏向锁会频繁触发 Safepoint,带来不可忽略的抖动。
6. 面试和实战中最容易翻车的四个为什么
6.1 为什么偏向锁撤销要在 Safepoint 做
偏向锁最大的隐患是“一个线程可能在临界区内,而另一个线程想抢锁”。如果只通过 CAS 换掉 Mark Word,没法保证旧线程真的不会继续访问临界区。所以必须先暂停所有线程,确保临界区里没有执行中的 Java 代码,才能安全修改对象头。这也是为什么一旦锁竞争频繁,偏向锁不但没有帮你省钱,反而可能因为停顿制造新麻烦。线上系统最怕的就是这种微妙的全 JVM 暂停点。
6.2 为什么轻量级锁“失败”不直接挂起
因为挂起线程需要进入操作系统内核态,上下文切换的成本可能是几十微秒到几百微秒级别。如果临界区只有十几条指令,等锁的线程自旋几十次就能拿到锁,那自旋消耗的 CPU 总量也远小于一次阻塞和唤醒。现代 HotSpot 还会做自适应自旋:如果最近几次自旋后成功拿到锁,就多旋几次;如果连续失败,就适当减少自旋,避免 CPU 空转。
6.3 为什么高竞争下重量级锁反而“更正确”
很多人以为重量级锁一定最慢,其实要看场景。低竞争时,轻量级锁靠 CAS 就能拿锁,重量级锁没必要;高竞争时,一堆线程都在自旋,CPU 被打满,上下文切换也没有彻底避免,系统整体吞吐会明显下降。此时让竞争线程去排队,用重量级锁的等待唤醒机制把并发排队化,反而能降低 CPU 压力。所以“升级成重量级锁”不是失败,而是 JVM 在帮你从“快速失败”切到“排队处理”的兜底方案。
6.4 synchronized 和 ReentrantLock 的锁升级有什么联系
严格来说,自旋、CAS、排队这些思想在 synchronized 升级流程和 ReentrantLock 里都有对应。ReentrantLock 默认是非公平锁,加锁时先做一次 CAS,如果失败,再执行compareAndSetState等流程,最终没抢到会进入 AQS 队列。它没有偏向锁这一层,但同样有“先自旋/CAS,再入队阻塞”的分级思想。面试里如果被问到“为什么 ReentrantLock 没有偏向锁”,可以从两个角度答:偏向锁需要 JVM 在对象头里维护线程 ID,而 ReentrantLock 状态在 Java 层和 AQS 里,设计目标不同;再者,偏向锁在现代 JDK 里已经被默认淘汰,纯粹基于 Java 层的 AQS 方案更可控、也更容易维护。
我在实际调优里最深的体会是,锁升级不能只看“升级到哪一层”,而是要先搞清临界区到底有多短、竞争的线程有多少、单位时间进入多少次。只有当你真正遇到同步性能瓶颈时,再把 Mark Word、偏向锁撤销、Safepoint 这些底层机制摆到台面上分析,否则一上来就抠锁状态细节,往往是在解决一个根本不存在的性能问题。