如果你准备过Java并发的面试题,synchronized锁升级基本是绕不开的一题。坦白说,很多人在背完“无锁、偏向锁、轻量级锁、重量级锁”四个状态后,仍然说不出它们到底怎么切换、有什么代价、生产中怎么观察。这篇文章以synchronized的锁升级为唯一主题,从对象头Mark Word一路拆到偏向锁撤销、自旋、锁膨胀,再用JOL做一个可复现的实验,最后把面试追问和生产避坑一起讲清楚。无论你是正在准备后端开发面试,还是业务代码里被锁竞争熬得焦头烂额,这篇都能帮你少走弯路。
1. 从对象头开始理解锁升级的完整链路
1.1 Mark Word:JVM把锁状态藏在哪里
要聊锁升级,绕不开对象内存布局。HotSpot中一个普通Java对象在堆里主要由三块组成:对象头、实例数据、对齐填充。对象头又分为Mark Word和Klass Pointer,64位JVM下Mark Word固定占8字节,Klass Pointer在开启压缩指针时占4字节。锁相关的所有信息都写在Mark Word里。
Mark Word本质是一块被反复复用的存储。同一段bit位,在不同状态下翻译出来的含义完全不同:无锁时可能存对象的identity hashcode和GC分代年龄,偏向锁时存线程ID和epoch,轻量级锁时存线程栈中Lock Record的指针,重量级锁时存ObjectMonitor的指针。这就是为什么不同锁状态的mark word长度相同、含义却千差万别。
64位JVM下,Mark Word的低两位用于表示lock状态,高位还有一个biased_lock位配合区分临界状态:
| 锁状态 | biased_lock | lock | Mark Word内容 |
|---|---|---|---|
| 无锁 | 0 | 01 | 身份哈希值、GC分代年龄等 |
| 偏向锁 | 1 | 01 | 持有线程ID、epoch、GC分代年龄 |
| 轻量级锁 | 不适用 | 00 | 指向线程栈中Lock Record的指针 |
| 重量级锁 | 不适用 | 10 | 指向ObjectMonitor的指针 |
| GC标记 | 不适用 | 11 | 标记阶段使用 |
注意无锁和偏向锁的lock位都是01,区别在biased_lock位。所以面试里常说的“无锁最后三位是001、偏向锁最后三位是101”,指的就是这个结构。搞懂这张表,锁升级的底层逻辑就建立起来了。
1.2 第一次加锁:从无锁到偏向锁
偏向锁解决的是“同一个线程反复拿同一把锁”的场景。很多业务代码中的synchronized块,实际只会被一个线程访问,很少存在真正的多线程竞争。如果每次加锁都走CAS甚至操作系统互斥量,成本太高。偏向锁的做法很简单粗爆:在mark word里记下持有线程的ID,后续这个线程再进入,直接比对ID一致就认为锁归自己,连CAS都不做。
第一次加锁流程大致是这样:检查mark word当前是否可偏向,如果可偏向,就通过CAS把当前线程ID写进mark word。CAS成功说明拿到了偏向锁,失败说明已有别的线程持有了偏向锁,此时需要做偏向锁撤销。偏向锁是可重入的,同一个线程再次进入临界区时,检查到线程ID一致就放行,不会修改mark word,也不会额外做CAS。
这里有个很出名的细节:HotSpot默认在JVM启动后4秒才开始启用偏向锁,通过BiasedLockingStartupDelay参数控制。原因是JVM启动初期类加载、JIT编译、对象分配非常频繁,各类竞争路径上偏向锁的撤销反而会成为负担。所以你在测试时要么sleep 4秒以上,要么直接加上-XX:BiasedLockingStartupDelay=0。
1.3 轻量级锁:CAS登场
当偏向锁被撤销,或者对象因为先调用了hashCode而无法进入偏向锁,加锁路径就会走到轻量级锁。轻量级锁针对的典型场景是“多个线程错峰访问同一个锁,同一时刻基本只有一个线程持有”。它不再赌只有一个线程,而是赌“排队的人不多,且持锁时间很短”。
获取轻量级锁的关键动作有两个。第一,线程在自己的栈帧中分配一个Lock Record,并把当前对象头mark word的副本保存进去,这个副本叫做Displaced Mark Word。第二,通过CAS把对象头的mark word替换成指向这个Lock Record的指针。CAS成功,说明锁到手了;CAS失败,说明有其他线程已经把mark word改掉了,于是开始自旋,或者直接膨胀为重量级锁。
轻量级锁用CAS替代操作系统级的线程阻塞,在临界区短、竞争概率低时非常高效。但它的前提是“竞争不激烈”,一旦两个线程同时真实地抢锁,并且自旋也没能拿到锁,锁就会膨胀。后面这段才进入真正的重量级玩法。
1.4 重量级锁:synchronized的底牌
重量级锁依赖HotSpot内部的ObjectMonitor。ObjectMonitor结构里有一组关键字段:_owner记录当前持有者线程,_recursions记录重入次数,_cxq和_EntryList保存等待队列,_WaitSet保存调用wait后释放锁的线程。
当一个线程获取锁失败,会进入等待队列,通过ObjectMonitor::EnterI执行真正的阻塞逻辑,底层通常依赖操作系统层面的互斥机制或者线程park/unpark。这种做法的优点是有完整的等待队列、支持wait/notify机制,缺点是线程从用户态到内核态的切换、挂起与唤醒,成本非常高。
锁膨胀成重量级锁之后,状态就“粗”了下来。后面即使没有激烈竞争,也不太会降回轻量级锁或偏向锁。释放后对象头会回到无锁状态,但Monitor实例通常会回到系统缓存里复用,下次再遇到竞争时膨胀路径会更快。面试里常说的“锁只能升级不能降级”,重点就是指重量级锁不会自动降回轻量级或偏向。
2. 为什么要有锁升级:设计者的取舍
2.1 为什么不是一把锁走天下
如果JVM只提供一种重量级锁,代码倒是简单了,但几乎所有synchronized都要付出操作系统级的互斥代价。实际业务里,大量锁对象要么只被单一线程访问,要么竞争频率极低,极端场景是“一个线程反复进入同一个synchronized块”。针对不同竞争烈度使用不同策略,才是锁升级的核心思路:默认赌不竞争,发现竞争后逐步加戏,直到竞争真的激烈了才上重量级锁。
这种渐进式设计本质上是一套自适应策略。用偏向锁覆盖“无竞争+单线程反复加锁”,成本几乎为零;用轻量级锁覆盖“轻微竞争+临界区短”,成本是一次CAS;用重量级锁兜底“真竞争+长临界区”,牺牲线程切换换来公平排队。每一步的收益都是针对前面场景的代价优化,而不是简单的功能叠加。
2.2 自旋与自适应自旋:拿CPU换唤醒
阻塞和唤醒涉及操作系统调度,开销很大。如果临界区只需要几微秒,那么把一个线程挂起再恢复的时间可能比实际执行临界区的耗时还长。所以在轻量级锁阶段,抢锁失败的线程不会立刻阻塞,而是先空转几圈,反复尝试CAS,这就是自旋。
早期JVM的自旋次数是固定的,比如PreBlockSpin默认10次。后来JVM引入了自适应自旋:根据上一次同一个锁上自旋后成功获得锁的概率,动态调整下一次自旋次数。如果最近通过自旋成功拿到锁的比例高,JVM就多自旋一会儿;如果总是转半天也拿不到,就直接走阻塞,避免CPU空烧。
自旋在单核场景是没有意义的,因为一个线程空转时根本没法腾出CPU让别的线程执行。多核环境下,自旋是典型的“用CPU换延迟”,但如果锁竞争太激烈、临界区太长,就会出现CPU飙高但任务没进展的现象。生产上看到某个线程CPU占用异常高,除了死循环,锁自旋也是一个排查方向。
2.3 锁消除和锁粗化:JIT层面的另两招
锁升级讲的是运行时对象头的状态变化,但HotSpot在JIT编译阶段还有两个容易混淆的优化:锁消除和锁粗化。
锁消除依赖逃逸分析。如果JVM判断synchronized锁住的对象根本没有逃出当前线程,比如在一个方法内new出来的对象,那就直接把这个锁去掉。JDK 8中对应参数是-XX:+EliminateLocks,默认开启。注意这种优化只是编译器的合法猜测,它不会破坏程序语义,因为对象本身没有线程共享的可能。
锁粗化则相反:把多个相邻的加锁区块合并成一个更大的锁临界区,减少重复加锁解锁的次数。典型的场景是循环里多次向同一个容器追加内容,如果编译器认为反复加锁解锁的代价超过持锁时间,就会粗化成整段循环只锁一次。锁消除和锁粗化都是JIT按启发式规则决定的,开发者不能把线程安全寄托在这上面,该同步的地方仍然要同步。
2.4 hashCode为什么会破坏偏向锁
这是一个极容易踩坑的细节:一个对象只要计算过identity hashcode,就再也无法进入偏向锁。原因在于Mark Word空间有限,无锁状态下hashcode占用了很大一部分bit位,而偏向锁需要把线程ID写进相同的区域,两者物理上不能共存。
更准确地说,在无锁可偏向状态下,如果先调用了obj.hashCode(),hash值会被写入mark word的无锁区域,biased_lock位不能再变,后续这个对象加锁时只能走轻量级锁路径。反过来,如果对象已经处于偏向锁状态再计算hashcode,JVM会先撤销偏向锁,把mark word恢复成无锁态或者干脆膨胀到重量级锁来安放hash值。这个细节在JOL实验里会被非常直观地打脸。
2.5 偏向锁撤销与批量重偏向的真相
偏向锁撤销不像看起来那么轻量。如果一个偏向锁正被某个线程持有,另一个线程来抢,JVM需要等待一个安全点,暂停持有者线程,然后判断持有者是否已经退出临界区。如果退出了,直接撤销并可能让新线程重新偏向;如果还没退出,就把锁升级为轻量级锁或重量级锁。安全点期间的停顿会影响应用延迟,这就是偏向锁隐藏的成本。
为了减少频繁撤销,JVM对同一个类做了批量重偏向和批量撤销。当同一个类的对象发生偏向锁撤销次数累计超过20次,会触发批量重偏向:JVM把该类的epoch加1,让旧的线程ID连同旧epoch一起失效,之后新线程可以重新进行偏向。如果撤销次数继续累计超过40次,就会触发批量撤销:JVM认为这个类的锁竞争已经很激烈,不再适合偏向,之后这个类新建的对象直接禁用偏向锁。两个阈值分别是BiasedLockingBulkRebiasThreshold和BiasedLockingBulkRevokeThreshold,生产环境通常保持默认即可。
3. 用JOL亲手复现一遍锁升级
3.1 准备环境:JDK 8 + JOL
实验前先把环境准备好。测试锁升级最好使用JDK 8,因为偏向锁在JDK 8默认开启、默认延迟4秒,正好可以完整观察;JDK 15开始偏向锁被标记为废弃,JDK 18开始默认关闭,并不适合做传统锁升级演示。
需要一个叫JOL(Java Object Layout)的工具包,专门用来打印对象内存布局。引入方式很简单:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>运行时记得带上JVM参数。查看偏向锁状态时用:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0想强制关闭偏向锁观察轻量级锁路径时用:
-XX:-UseBiasedLocking -XX:BiasedLockingStartupDelay=0可以用VM.current().details()在代码里打印当前JVM的详细设置,确认参数生效。JOL的核心API并不复杂,核心就是ClassLayout.parseInstance(obj).toPrintable()。
3.2 先看无锁和偏向锁长什么样
写一个最简单的demo:
import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class LockUpgradeDemo { public static void main(String[] args) throws Exception { System.out.println(VM.current().details()); 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()); } }使用-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0运行时,加锁前的输出末尾是001,这是无锁状态。加锁中的输出末尾变成101,对应偏向锁状态,并且在mark word里能看到明显的线程ID写入。更关键的是,释放锁之后再次打印,仍然显示101。因为偏向锁不会在释放时主动复位,它默认保留偏向状态,方便同一个线程下次再进入。
3.3 关闭偏向锁,观察轻量级锁
把运行参数改成:
-XX:-UseBiasedLocking -XX:BiasedLockingStartupDelay=0还是跑同一段代码。加锁中mark word的末尾会变成00,也就是轻量级锁。单线程加锁时CAS几乎必然成功,所以不会膨胀,稳定落在轻量级锁路径。
区分一下:关闭偏向锁之后,任何synchronized都会走轻量级锁的Lock Record + CAS流程;而开启偏向锁时,如果对象不可偏向,加锁也会走到这里。所以“00状态”不止出现在关闭偏向锁的场景,试过多次实验后会意识到,锁状态的具体取值取决于运行时各种条件叠加。
3.4 制造竞争,观察重量级锁膨胀
重量级锁需要真正的竞争。写两个线程抢同一个锁:
import org.openjdk.jol.info.ClassLayout; import java.util.concurrent.TimeUnit; public class HeavyLockDemo { private static final Object OBJ = new Object(); public static void main(String[] args) throws Exception { Thread a = new Thread(() -> { synchronized (OBJ) { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("A持锁期间,对象头状态:"); System.out.println(ClassLayout.parseInstance(OBJ).toPrintable()); } }); Thread b = new Thread(() -> { synchronized (OBJ) { System.out.println("B获取到锁"); } }); a.start(); Thread.sleep(100); b.start(); a.join(); b.join(); } }A持有锁3秒,B在100毫秒后开始抢。此时A仍然在临界区内,B无法通过轻量级锁自旋获得成功,最终会膨胀成重量级锁。A在锁内打印时,mark word末尾通常能看到10状态。由于线程调度有不确定性,如果第一次没看到10,可以适当拉长A的sleep时间,多跑几次。
这里有个值得强调的现象:AB竞争结束后,你再让主线程回头打印这个对象,它可能已经回到无锁状态,但下次一旦竞争,对象会更快地膨胀。锁升级对同一个对象更像是“路径记忆”,一旦见过了大场面,就很难再回到小清新路径。
3.5 实验中最容易翻车的三个细节
第一,4秒延迟。忘了加-XX:BiasedLockingStartupDelay=0,代码一上来就加锁打印,你永远看不到101。这不是代码错了,是偏向锁还没“醒”。第二,先调hashCode。在加锁前先执行obj.hashCode(),再看加锁状态,会发现稳定显示00而不是101,原因前面已经讲过,hashcode和偏向锁在mark word里互斥。第三,打印行为本身会影响实验结果。JOL打印耗时不少,尤其锁内打印会拉长临界区,改变竞争时序。所以实验结论要多次重复验证,不要一次打印就下死结论。
4. 面试追问与生产实践避坑
4.1 面试怎么答才不像背八股
锁升级相关的面试题很固定,但很多候选人只答了状态名。真正有区分度的是说出来“为什么”和“代价”。几个高频问题可以参考:
| 面试题 | 建议回答方向 |
|---|---|
| synchronized在JDK 1.6之后做了哪些优化 | 引入偏向锁、轻量级锁、自旋/自适应自旋、锁消除、锁粗化 |
| 偏向锁和轻量级锁的区别 | 偏向锁只写线程ID、后续加锁不执行CAS;轻量级锁需要CAS把对象头替换为Lock Record指针 |
| 什么时候发生锁膨胀 | 偏向锁撤销后发生真实竞争,或轻量级锁自旋失败 |
| 锁升级能降级吗 | 重量级锁不会主动降回轻量级或偏向锁;轻量级锁释放后回到无锁状态 |
| 偏向锁撤销为什么会慢 | 需要遇到安全点,暂停持有线程,再执行撤销逻辑 |
| hashCode为什么影响偏向锁 | 无锁态hashcode和偏向锁线程ID占用同一片mark word区域 |
面试时能讲出“偏向锁撤销依赖安全点”“Mark Word是复用存储”“自旋是拿CPU换上下文切换”这三句话,比背三遍状态机更有用。如果你还能顺带提起JDK 15后偏向锁被标记废弃,面试官大概率会认可你有工程意识。
4.2 JDK版本演进:偏向锁为什么被弃用
面试里越来越多会问版本演进。JDK 15通过JEP 374将偏向锁标记为废弃,JDK 18开始HotSpot默认不再启用偏向锁。这不是说synchronized变弱了,反而说明随着JIT和硬件发展,轻量级锁路径已经足够快,偏向锁带来的收益不再划算。
偏向锁的问题集中在撤销成本上。撤销偏向锁需要等待安全点,安全点停顿对延迟敏感的应用很伤。另外现代服务器普遍多核,应用并发度相比JDK 6时代高了很多,“一个锁只由一个线程访问”的场景占比在下降。代码本身的高竞争环境下,偏向锁不只是没有收益,还会因为反复撤销拖后腿。新版JDK默认关掉它,是为了让默认行为更可预测。
4.3 生产环境调参和性能排查怎么做
先说结论:绝大多数应用不要动锁升级相关参数。BiasedLockingStartupDelay只是测试时用;BiasedLockingBulkRebiasThreshold和BiasedLockingBulkRevokeThreshold在默认值下经过大量验证,手工改动未必能带来收益。
线上真遇到锁竞争严重,优先做三件事:先定位锁的粒度能不能拆细,再看临界区能不能缩短,最后评估能否用无锁数据结构替代。比如计数场景用LongAdder、读写分离用ReentrantReadWriteLock或StampedLock、分片思想改造热点对象。调JVM参数是最后手段,而且必须建立在压测数据上。
从JDK 8迁移到JDK 17或21时,还要特别注意:老版本上偏向锁带来的性能红利在新版本默认关闭后会消失,但现代GC和JIT也在持续变强,最终性能差异只能靠真实业务压测判断,不能拍脑袋。
4.4 Synchronized和Lock到底怎么选
很多人觉得Lock一定比synchronized快,其实不一定。synchronized有JIT优化、偏向锁、轻量级锁,低竞争甚至单线程反复加锁时,成本可能比Lock低。Lock的优势不在速度,而在控制能力:公平锁、超时等待、可中断、多个Condition条件队列,这些是synchronized不具备的。
我的选择经验很简单:只做互斥和简单同步、追求代码简洁,用synchronized;需要超时获取锁、可中断等待、多条件队列或者公平调度,用ReentrantLock;读多写少的共享数据,优先考虑ReadWriteLock;热点计数用原子类或LongAdder。如果是面试被问到底层原理,ReentrantLock背后的AQS核心是CLH变体和volatile state,和synchronized的Monitor阻塞模型并不一样,不要混为一谈。
最后分享一点实操体会
我第一次跑JOL实验时,结果完全反直觉:以为会看到偏向锁,却反复出现轻量级锁,后来发现是忘了等4秒延迟;又有一次加了hashCode调用,整个结论直接被推翻。这些坑踩过一遍,比背十遍八股管用得多。锁升级这套机制确实复杂,但核心就一句话:JVM在赌你的锁没人抢,赌输了再加钱上更重的方案。把Mark Word结构搞懂,再结合实际版本跑一次实验,你自然能理解为什么锁升级被称为Java并发中最经典的优化策略之一。