☰
Java synchronized锁升级机制详解:从偏向锁到重量级锁的完整链路
2026/10/14 7:40:43 网站建设 项目流程

1. 锁升级到底是什么

先问一个最实际的问题:你去面试 Java 开发岗,十次里至少有五次会被问到 synchronized 的实现原理,而锁升级基本是这个问题的标准后续。“你说说 synchronized 的锁升级过程”,这一句话背后实际上在考察你对 JVM 并发底层、对象内存布局、自旋与阻塞权衡这三层知识的掌握程度。

先说结论:锁升级是 HotSpot JVM 对 synchronized 做的一套多级优化机制,它让一个锁对象在不同竞争强度下切换为不同的实现形态。按照竞争激烈程度从低到高,依次是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。核心思想非常朴素:大部分代码里锁的竞争根本没有你想象的那么激烈,与其一上来就动用操作系统级别的线程阻塞唤醒,不如先试试一些轻量手段,能省就省。

为什么需要这套机制?根源在于 JDK 1.6 之前的 synchronized 很“笨”。它直接依赖底层操作系统的 Mutex Lock 实现,线程一旦拿不到锁就会进入内核态阻塞,被挂起,等锁释放后再被唤醒。这一挂一醒,涉及到用户态到内核态的切换,开销极大。做过性能测试的兄弟应该懂,如果一段代码里锁竞争激烈,线程上下文切换和系统调用能把吞吐拖垮。所以早期很多人宁可自己写 ConcurrentHashMap 那套分段锁,也不愿意用 synchronized。

JDK 1.6 之后,HotSpot 团队对 synchronized 做了大量优化,引入了偏向锁、轻量级锁、适应性自旋、锁消除、锁粗化等一系列机制。锁升级就是这套优化体系的核心链路。理解它,你才算真正看懂了 Java 并发编程里最常用、也最容易被误解的关键字。

这篇文章我会从对象头内存布局开始讲,逐层拆解每一种锁状态的工作原理、触发条件和升级时机,再把面试官爱追问的细节串起来,最后附上一些实际调试中积累的经验。无论你是准备面试还是想优化线上并发代码,这篇都能给你一个比较完整的脉络。

2. 从对象头说起:一切升级的基础

2.1 Java 对象在内存里长什么样

想理解锁升级,第一步必须看对象头。任何一个 Java 对象,在 HotSpot 虚拟机里都由三部分组成:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

对象头里最核心的部分是Mark Word,它只有 64 位(64 位虚拟机上),但里面存的信息极其丰富:对象的哈希码、GC 分代年龄、锁状态标志位、持有锁的线程 ID、指向锁记录的指针、指向 Monitor 的指针。这就像一个多面手,同一段内存空间,在不同状态下表达完全不同的含义。

很多人初学时会疑惑:一个字段怎么能存这么多东西?答案就是位复用。Mark Word 的结构会根据锁状态标志位的变化而重新解读。最低的 2 位(64 位虚拟机中实际用 2 位锁标志加 1 位偏向标志)用来区分当前处于哪种状态。这有点像你手机上的存储卡,同一个卡槽,插 TF 卡能存数据,插 SIM 卡能打电话,物理空间不变,用途由插入的东西决定。

具体到 64 位 JVM,Mark Word 的布局大致如下:

  • 无锁状态:前 56 位存储对象哈希码(31 位)、分代年龄(4 位)、偏向锁标志(1 位为 0),后 2 位锁标志为 01。
  • 偏向锁状态:前 54 位存储持有偏向锁的线程 ID、epoch 值、分代年龄,偏向锁标志为 1,锁标志为 01。
  • 轻量级锁状态:前 62 位存储指向栈中锁记录的指针,锁标志为 00。
  • 重量级锁状态:前 62 位存储指向 Monitor 对象的指针,锁标志为 10。
  • GC 标记状态:锁标志为 11,此时对象正在被垃圾回收器处理。

这组位布局是理解后面所有内容的地基。面试时如果你能当场画出一个 64 位 Mark Word 的位分布图,基本上就能让面试官觉得你是真看过源码或者深入理解过的,而不是只会背结论。

2.2 为什么锁状态能只靠标志位区分

这里有个关键设计意图:对象头不额外分配内存来记录锁信息,而是复用原有字段。这种设计让无锁对象在内存上不付出任何额外代价,只有在真正需要加锁时,才把 Mark Word 里的内容挪到别处保存,腾出空间给锁相关指针。

举个例子,一个普通的 ArrayList 对象刚开始没有被任何线程同步访问,它的 Mark Word 里存的就是 hash code 和 GC 年龄。一旦某个线程对这个对象执行 synchronized,JVM 就会根据当前竞争程度选择一种锁实现,并且把 Mark Word 重新改写为对应状态的布局。等锁释放后,如果对象没被升级到更高级别,Mark Word 会尝试恢复原样。

这个过程本质上是用空间换灵活性,用位运算换效率。因为 Mark Word 的读写都发生在极端高频的路径上(每次加锁、释放、GC 都要访问它),如果设计得复杂一点,性能损耗就会被放大。所以 HotSpot 的做法是保持字段紧凑,所有状态切换都是简单的位操作和指针替换。

理解了 Mark Word,你再看各种锁升级的讲解就有抓手了,因为每一种锁状态的进入和退出,本质上都是在操作这 64 位的内容。下一步我们按顺序拆每一种锁状态。

3. 偏向锁:默认的“乐观”

3.1 偏向锁要解决什么问题

先看一个统计规律:在大量业务场景中,同一个锁对象往往只被同一个线程反复获取,比如单线程模型下的 StringBuilder、ArrayList,或者每个线程独享的 SimpleDateFormat(虽然这个实际上有坑)。在这些场景下,锁竞争根本不存在,但每次加锁如果都要做 CAS 操作甚至线程挂起,都是白花花的浪费。

偏向锁的思路就是:既然这个锁大概率只会被同一个线程访问,那我干脆把这个锁“偏袒”给第一个获取它的线程。具体做法是在 Mark Word 里记录这个线程的 ID。之后这个线程再次进入同步块时,只需要检查一下线程 ID 是否匹配,匹配就直接通过,连 CAS 都不做。这就是“偏向”的含义:锁偏向于某个线程,仿佛它成了这把锁的“主人”。

如果另一个线程来竞争这个偏向锁,偏向锁就会被撤销并升级为轻量级锁。这里要特别留个心:偏向锁并不是一把独立的“锁实现”,它更像一种激进的优化策略,只在单线程重复获取的场景下生效。一旦遇到竞争,它立刻让位。

3.2 偏向锁的获取与撤销过程

偏向锁的获取流程大致是这样:

  • 第一步,JVM 启动后默认有一个偏向锁延迟期(约 4 秒)。因为 JVM 启动初期大量对象被创建,存在激烈竞争,此时偏向锁的撤销成本很高,所以 HotSpot 故意延迟开启偏向锁,等应用稳定后再启用。这就是你用-XX:BiasedLockingStartupDelay=0可以关闭延迟的原因,很多性能测试工具会建议设置这个参数。
  • 第二步,第一个线程进入同步块,JVM 检查对象的 Mark Word 是否为可偏向状态(偏向标志为 1,锁标志为 01)。如果是,则通过 CAS 尝试将 Mark Word 中的偏向线程 ID 设置为当前线程 ID。
  • 第三步,CAS 成功,线程获取偏向锁。以后每次都只需比对线程 ID。
  • 第四步,CAS 失败,说明有竞争,JVM 进入偏向锁撤销流程。

偏向锁撤销是个相对重量级的操作,要等到安全点(Safe Point)才能执行,因为需要暂停所有线程,检查持有偏向锁的线程是否还活着。如果原线程已退出同步块或已死亡,则把锁对象恢复到无锁状态;如果原线程还在持有,则升级为轻量级锁。这个“撤销要到安全点”的特性,决定了高竞争场景下偏向锁不仅帮不上忙,反而会拖慢节奏,因为撤销本身要停顿。

3.3 批量重偏向和批量撤销

这里有一个很有意思的机制:批量重偏向(Bulk Rebias)和批量撤销(Bulk Revoke)。

场景是这样的:假设一个线程池里的线程轮流处理任务,每个任务都会访问同一个锁对象,这样会导致偏向锁被反复撤销和重偏向,成本非常高。HotSpot 引入了 epoch 这个概念,本质是给偏向锁加了一个“代际”标识。当某个类的偏向锁撤销次数达到阈值(默认 20),JVM 会对这个类执行批量重偏向:将所有未被持有的偏向锁对象的 epoch 加一,然后直接把偏向状态复位,让后续的新线程可以重新偏向。这样做比逐把锁撤销高效得多。

而当撤销次数继续上升,达到另一个阈值(默认 40),JVM 会认为这个类的锁竞争太激烈,直接批量撤销整个类的所有偏向锁,并把类的偏向锁功能关闭(通过修改类元数据中的标志),之后该类的所有对象都走轻量级锁流程。

这个机制经常在面试里被追问,但很少有人能讲清楚。你答出“epoch 分批处理”和“阈值 20/40”,就已经超出大多数候选人了。

3.4 偏向锁必须注意的一个坑:hashCode

很多人踩过这个坑:一个对象如果调用了hashCode()方法(并且没有被重写,用的是 System.identityHashCode),它的 Mark Word 里就需要保存 hash code。但偏向锁的 Mark Word 布局里没有空间存 hash code,因为那些位被线程 ID 占用了。

这时候就会发生一件很微妙的事:一旦一个可偏向的对象被计算了 identity hash code,它的偏向锁就被永久关闭,直接变为无锁状态。这意味着你不能再对他执行偏向锁操作。如果你在自己代码里先new Object()然后调用它的默认 hashCode,再对这个对象加 synchronized,JVM 也只能直接走轻量级锁甚至重量级锁路径。

反向理解更直观:只要对象处于偏向锁状态,调用它的默认 hashCode 就会导致锁撤销。如果你在同步块内部调用了System.identityHashCode(lockObject),就等于主动把偏向锁干掉了,性能会有损耗。这个细节在大流量场景下能被明显感知到,但对大多数业务而言影响不大。面试时能主动提出这个点,会显得你确实研究过对象头布局。

4. 轻量级锁:竞争出现后的第一道防线

4.1 轻量级锁的加锁过程

当偏向锁被撤销(说明有第二个线程来竞争了),锁就升级为轻量级锁。但请注意,轻量级锁这个名字里的“轻量”,是相对于操作系统线程阻塞来说的。它的核心武器是CAS 自旋。

轻量级锁加锁的过程是这样的:

  • 线程在自己的栈帧中分配一块空间,作为锁记录(Lock Record),用来存放对象 Mark Word 的副本。
  • 线程用 CAS 尝试把对象头里的 Mark Word替换为指向自己栈中锁记录的指针。
  • 如果替换成功,说明线程抢到了锁,并把对象头的锁标志位更新为 00(轻量级锁)。
  • 如果替换失败,说明锁已经被别人持有,线程进入自旋,反复尝试 CAS,同时会尝试膨胀为重量级锁。

这个过程本质上是:不借助操作系统,纯用户态解决竞争问题。由于自旋是在用户态完成的,没有线程挂起和唤醒的开销,所以在锁持有时间很短、竞争不激烈的情况下,轻量级锁的效率非常高。

看到这里你可能会问:CAS 本身不就是原子操作吗,怎么会失败?因为对象头里存的指针一旦被第一个线程改掉,第二个线程的 CAS 比较到的当前值和预期值就不相等,自然失败。这个设计和数据库乐观锁的思路一致:先尝试修改,冲突了再处理冲突。

4.2 锁记录到底存了什么东西

锁记录(Lock Record)是理解轻量级锁的关键。它的结构很简单,两个字段:displaced header,就是锁对象原来的 Mark Word 副本;owner,指向锁对象的指针。为什么要把原来的 Mark Word 复制出来?因为加锁过程中要把对象头改写成指向锁记录的指针,如果不备份原始值,释放锁的时候就没法恢复原状了。

这个思想和偏向锁也类似:偏向锁在批量重偏向时要维护一个存储线程 ID 的空间,轻量级锁则用栈中的锁记录来保存原 Mark Word。锁释放时,线程用 CAS 把锁记录里的 displaced header 写回对象头,如果成功,说明没有竞争发生,锁正常释放;如果失败,说明在持有锁期间有其他线程尝试过获取锁,此时需要走重量级锁的释放流程,唤醒正在阻塞的线程。

这个 CAS 失败是重点,它意味着竞争已经升级了。轻量级锁的膨胀时机就是在自旋一定次数后仍然无法获得锁,或者有线程已经在等待队列里,JVM 会把这把锁升级为重量级锁,后续操作交给 Monitor 机制处理。

4.3 自旋与自适应自旋

早期 JVM 的自旋是固定的,比如自旋 10 次,到了次数还失败就阻塞。这种做法的缺点是:如果锁持有时间很短,自旋几次就能拿到锁,很好;如果持锁时间长,自旋纯属浪费 CPU。

JDK 1.6 引入了自适应自旋:JVM 会根据同一个锁对象上一次自旋等待的成功率和当前锁的持有者状态,动态调整本次自旋次数。如果上次自旋成功过,说明竞争短暂,JVM 会适当增加自旋次数;如果上次自旋失败,说明锁持有时间较长,JVM 会减少甚至不自旋,直接阻塞线程。

看起来很美,但实际运行中自适应自旋的表现并不总能让人满意。我自己在线上排查问题时发现,在高竞争场景下,大量线程同时自旋等待一个锁,CPU 占用率会显著上升,因为每个线程都在空转。所以,如果业务中确实存在高竞争,synchronized 升级为重量级锁反而是保护机制,它让等待线程进入阻塞队列,而不是消耗 CPU。这也是“轻量级”和“重量级”名字的迷惑性所在:轻量级只在低竞争下才叫轻量级,高竞争下它是 CPU 黑洞。

5. 重量级锁:最终兜底与线程阻塞

5.1 Monitor 机制是什么

重量级锁本质上依赖操作系统提供的 Mutex Lock 实现,每个 Java 对象都关联一个 Monitor 对象(在 HotSpot 里叫 ObjectMonitor)。这个 Monitor 内部维护了一个所有者线程、一个入口等待队列和一个条件变量等待队列。

当线程执行 synchronized 时,它实际上是在竞争这个 Monitor。如果竞争失败,线程会被封装成 ObjectWaiter 节点,加入同步队列,然后调用系统 API 阻塞自己。等持有锁的线程释放锁时,Monitor 会从队列中唤醒一个或多个等待线程。唤醒操作也需要通过操作系统调度完成,涉及内核态切换,这就是重量级锁开销大的根本原因。

从这层意义上看,重量级锁才是最“正统”的锁实现,它保证了公平性和安全性,只是性能代价高。偏向锁和轻量级锁都是在其之上的“优化捷径”,能走捷径就走,走不了就老老实实排队。

5.2 wait 和 notify 与锁升级之间的关系

这里有个关键点很多人忽视:一旦对象执行了 wait/notify 方法,它就必然会升级为重量级锁,因为 wait 和 notify 操作本身需要操作系统的条件变量队列支持,而偏向锁和轻量级锁都没有这个能力。

所以如果你在代码里写了synchronized(obj) { obj.wait(); },那这把锁在进入同步块时无论竞争多小,JVM 都会直接膨胀为重量级锁。轻量级锁根本没有办法处理 wait 之后的挂起和唤醒。这个限制也回答了另一个问题:为什么偏向锁状态下的对象不能调用 wait。一旦调用,JVM 会先撤销偏向锁,再膨胀到重量级。

面试里如果面试官问“什么情况下锁会升级为重量级锁”,除了“多个线程真正竞争”之外,你最好能补一句“调用 wait/notify 也会直接导致重量级膨胀”,这一下子就能把自己和只会背八股文的人区分开。

5.3 锁升级是不可逆的吗

这是面试里最容易答错的一个问题。很多网上的文章说锁升级是单向的,一旦升级到重量级就不能降级。这个说法在 HotSpot 的实现里并不严格成立。

先说结论:HotSpot 里确实已经实现了锁降级机制,但在绝大多数业务场景中,降级条件极其严格,需要 JVM 在安全点执行 GC 时进行,且只适用于无竞争或低竞争的重量级锁对象。具体来说,当某个重量级锁在 GC 扫描时发现它的 Monitor 已经没有等待线程、且持有者已释放锁,JVM 可以把它的状态还原为无锁状态,从而避免后续加锁时直接跳到重量级路径。

不过这种降级并不能频繁发生,因为它依赖于 GC 的时机。对应用开发者来说,最稳妥的理解还是:锁状态整体是单向升级的,降级只是 JVM 内部的极少数场景优化。面试时你如果说“我认为锁升级不可逆,因为 JVM 没做降级”,这是有风险的说法,懂行的面试官可能会追问;如果你能主动补充“HotSpot 有锁降级机制,但依赖安全点和 GC,实际不常用”,那就是加分项。

5.4 可重入性在各种锁状态下的实现

synchronized 是可重入锁,同一个线程可以多次获取同一把锁。在偏向锁状态下,重入很简单:Mark Word 里已经记录了线程 ID,再次进入不过是一次线程 ID 比对。在轻量级锁状态下,每个重入会在线程栈中追加一个 Lock Record,Displaced Mark Word 置为 null 表示这是重入,而不是第一次加锁。释放时弹出对应的锁记录即可。

重量级锁状态下的重入则是靠 Monitor 中的递归计数实现的,每次重入计数加一,每次释放计数减一。这也是为什么重量级锁的状态信息最丰富,因为 Monitor 需要管理的东西远比偏向锁和轻量级锁复杂。

三个状态对重入的实现方式各不相同,但对外表现完全一致。这也是 JVM 设计的高明之处:对开发者透明,内部则各显神通。面试官问到“synchronized 可重入的实现细节”,你可以按锁状态分别描述,这会显得你理解得很深。

6. 完整升级链路的一次代码级演练

6.1 一个能观察升级过程的实验

理论讲多了,来点实操。我写了一个简单示例,可以配合 JVM 参数验证锁状态变化。先准备一个测试类:

public class LockUpgradeTest { private static final Object LOCK = new Object(); public static void main(String[] args) throws InterruptedException { // 场景1:单线程反复获取,预期走偏向锁(延迟4秒开启) Thread.sleep(5000); for (int i = 0; i < 100; i++) { synchronized (LOCK) { // do nothing } } System.out.println("单线程获取完成"); // 场景2:两个线程竞争,预期触发偏向锁撤销并升级为轻量级锁 Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) { synchronized (LOCK) { // 模拟短临界区 } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) { synchronized (LOCK) { // 模拟短临界区 } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("多线程竞争完成"); // 场景3:调用 wait,预期直接膨胀为重量级锁 Thread t3 = new Thread(() -> { synchronized (LOCK) { try { LOCK.wait(100); } catch (InterruptedException e) { e.printStackTrace(); } } }); t3.start(); t3.join(); System.out.println("wait 调用完成"); } }

这段代码本身不复杂,关键是配合 JVM 参数跑,能实时看到锁状态:

java -XX:+PrintBiasedLockingStatistics -XX:+UnlockDiagnosticVMOptions \ -XX:+PrintFlagsFinal LockUpgradeTest

-XX:+PrintBiasedLockingStatistics会输出偏向锁的获取、撤销、重偏向次数统计。如果你跑一下,大概率能看到偏向锁撤销次数在场景 2 后显著增加。如果你的 JDK 较新(比如 JDK 15 以上),注意偏向锁默认是开启的,但某些高版本 JVM 中偏向锁定默认被标记为 deprecated,这是另一个话题,后面细讲。

6.2 用 HSDB 直接查看对象头

想更直观地看到 Mark Word 的位变化,可以借助 JDK 自带的 HSDB 工具。启动一个进程后,用jps找到 PID,然后执行jhsdb hsdb --pid <pid>。在 HSDB 的 Inspector 窗口里输入锁对象的地址,可以看到内存中 Mark Word 的原始 16 进制值,对照咱们前面讲过的位布局,就能判断当前是什么锁状态。

比如锁定无锁状态的 Mark Word 最后一个字节是 01,偏向锁可能表现为 ...05 或 ...45(最后一位的 5 表示偏向标志加锁标志 01,45 则额外带上了 epoch 或年龄位),轻量级锁的最后字节是 00,重量级锁是 10 对应的位模式。对着 JVM 源码里的enum { locked_value = 0, unlocked_value = 1, monitor_value = 2, marked_value = 3, biased_lock_pattern = 5 }去比对,就不会看懵。

这部分操作属于进阶玩法,对准备面试或者做 JVM 底层研究的人来说很有价值。多数业务开发可以跳过,但知道有这条路,遇到疑难杂症时不至于无从下手。

6.3 wait/notify 与锁膨胀的代码佐证

很多网上的示例代码把 wait 放在 synchronized 块里,但很少有人提醒:这样写会直接导致锁膨胀。为了验证这一点,可以在 wait 之前打印锁对象头信息,wait 之后再打印一次,你会发现锁状态标志位从 00 变成 10。这是硬性的证据。

需要注意,代码里调用lock.wait()前一定要先持锁,否则会抛 IllegalMonitorStateException。wait 会让线程释放锁并进入 Monitor 的等待集,这正是重量级锁引入条件变量存在的意义。轻量级锁和偏向锁都没有实现完整的等待/通知模型,所以只能让位于重量级锁。

7. 除了锁升级,还有哪些同步优化

7.1 锁消除

锁消除是 JVM 在 JIT 编译阶段做的一项优化。如果 JVM 通过逃逸分析发现某个锁对象只能被当前线程访问,不可能被其他线程共享,那这把锁的存在就没有意义,JIT 会直接把 synchronized 代码块的加锁解锁操作去掉。

典型的例子是:StringBuffer 是线程安全的,它的每个方法都有 synchronized,但如果你在一个方法内部创建了一个局部 StringBuffer,并且没有把它传出去,这个对象没有被任何其他线程看到,那 JVM 就可能消除掉这些同步操作。

这个优化对开发者几乎是透明的,但理解它有助于回答“synchronized 是不是在所有情况下都有性能开销”的问题。答案是:在 JIT 优化后,单线程场景下可能根本没有开销。

7.2 锁粗化

锁粗化和锁消除相反,它是把一系列细粒度的加锁操作合并成一个更大范围的锁。比如在循环里反复对同一个对象加锁、执行、释放,JIT 会把这些加解锁点合并到循环外,减少频繁的加解锁操作。这个优化能显著提升性能,但也意味着临界区范围变大,如果多线程并发访问,反而会增加竞争。所以 JIT 在做锁粗化时也会谨慎判断。

这两个优化和锁升级经常一起出现在面试题里,比如“JDK 1.6 对 synchronized 做了哪些优化”,标准回答是四件套:偏向锁、轻量级锁、自旋与自适应自旋、锁消除与锁粗化。如果你能在这些基础上再补充锁降级和批量重偏向,回答就非常完整了。

7.3 JDK 15+ 的变化

特别提一句:从 JDK 15 开始,偏向锁被标记为 deprecated,并计划在未来版本移除。原因是偏向锁在现代应用中的收益正在变小,而维护成本高,且它与一些 JVM 特性(比如 JFR 的线程转储)存在冲突。

这并不意味着你不需要学偏向锁,恰恰相反,目前主流的 JDK 8 和 JDK 11 在生产环境依然大量存在,偏向锁机制在这些版本上完全生效。而且面试官问你锁升级,大概率就是想考察你对象头和偏向锁的理解。哪怕你用的是 JDK 17,也需要知道历史沿革,因为偏向锁的实现逻辑仍然是理解轻量级锁和重量级锁的钥匙。

8. 面试问答实录与加分思路

8.1 高频追问:synchronized 锁升级的过程

面试官最常这样问:“从 synchronized 的角度说说锁升级。”一个高分回答的框架可以是这样:

首先说明 synchronized 基于对象头中的 Mark Word 实现,一共有四种锁状态。然后逐个讲:

  • 无锁状态下,对象可以直接被访问,Mark Word 存 hash code 和 GC 年龄。
  • 偏向锁针对单线程重复获取的场景,通过记录线程 ID 避免重复 CAS。
  • 一旦发生竞争,偏向锁撤销为轻量级锁,通过 CAS 和自旋在用户态解决竞争。
  • 竞争进一步加剧或调用 wait/notify 时,膨胀为重量级锁,依赖 Monitor 和操作系统线程调度。

结尾点一句:锁升级是单向的,但 HotSpot 也有条件苛刻的锁降级机制。这样一个回答既有主线又有细节,面试官基本没法在这里把你问倒。

8.2 更需要关注的是连环追问

真正拉开差距的是连环追问。比如面试官可能会问:

  • “偏向锁适合什么场景?”答:单线程或低并发重复获取,线程池中交替使用同一个锁则不适合。
  • “自旋的坏处是什么?”答:CPU 空转,高竞争下可能拖慢整个 JVM,因此有自适应自旋动态调整。
  • “为什么调用 wait 会导致锁升级?”答:等待唤醒需要条件变量队列,只有 Monitor 支持。
  • “锁能降级吗?”答:常规不会,但 JVM 在 GC 时对无竞争的 Monitor 可能做降级。
  • “对象调用了 hashCode 还能偏向吗?”答:不能,identity hash code 需要占用 Mark Word 高位,偏向锁没有空间存它。

这些问题基本对应了我们在本文里展开的每一个细节。如果能把这些点串起来讲,面试官心里给你打的标签就不只是“背过八股文”,而是“真的理解并发底层”。

8.3 网上常见错误认知整理

网上关于锁升级的文章鱼龙混杂,有几个常见错误我帮你排一下雷:

第一个误区是“偏向锁默认开启”。准确说法是 JVM 默认有 4 秒延迟,期间所有锁都走轻量级锁路径。也就是说,如果你的应用启动后立即有大量同步操作,那 4 秒内不会用偏向锁。

第二个误区是“轻量级锁不会阻塞线程”。实际上轻量级锁在自旋失败后会膨胀为重量级锁并阻塞线程,轻量级锁本身只是尽量在自旋阶段解决问题,不能保证永不阻塞。

第三个误区是“锁升级一定会让性能变差”。升级是 JVM 的理性选择,是竞争增强后为了维持正确性而付出的必要代价。真正的问题是,一个锁如果长期处于重量级状态,你需要审视临界区大小和并发模型是否合理,而不是去怪锁升级机制。

第四个误区是“ synchronized 在 JDK 1.6 之后就完全没有性能问题了”。优化确实大幅改善了低竞争场景的性能,但在高竞争下它仍然不如显式的并发集合或者读写锁。所以选择并发工具时,要根据场景判断,而不是一律用 synchronized。

9. 实战建议与故障排查经验

9.1 线上问题:偏向锁撤销导致的 CPU 异常

有一次我排查一个线上服务 CPU 使用率间歇性飙升的问题,jstack 抓线程栈发现大量线程阻塞在 synchronized 方法上,但锁竞争似乎并不剧烈。后来开启-XX:+PrintBiasedLockingStatistics跑了一段时间,发现偏向锁撤销次数非常高,原因是某个框架内部使用线程池轮流处理请求,每个请求都会访问同一个共享锁对象,导致偏向锁反复撤销和重新偏向。

解决方法并不是换成 ReentrantLock,而是调整了代码逻辑,让每个线程能够尽量复用自己的锁对象,同时将该类对象的偏向锁关闭,避免无谓的撤销停顿。最终 CPU 尖峰明显缓解。这个案例说明:锁升级不仅仅是理论,它在真实业务场景中直接影响系统表现。

9.2 如何判断你的锁处于什么状态

判断锁状态最直接的办法还是看对象头,但生产环境不方便用 HSDB,可以借助 JFR(Java Flight Recorder)监控锁竞争事件。jfr里有jdk.JavaMonitorEnter、jdk.JavaMonitorWait等事件,能统计 Monitor 进入次数和等待时间。如果 Monitor 等待事件持续高位,说明你的锁已经升级到重量级并且竞争严重。

也可以关注 GC 日志中的偏向锁相关输出。如果某类对象的偏向锁被批量撤销,通常在日志里会有对应信息。这些都是辅助手段,核心思路是:先判断锁是否处于重量级状态,再判断竞争来源。

9.3 常用 JVM 参数速查

这里整理几个和锁最相关的 JVM 参数,方便你实验时查阅:

参数作用
-XX:BiasedLockingStartupDelay=0关闭偏向锁启动延迟,让 JVM 启动后立即启用偏向锁
-XX:-UseBiasedLocking彻底关闭偏向锁
-XX:+PrintBiasedLockingStatistics打印偏向锁统计信息
-XX:PreBlockSpin=10设置自旋次数(仅适用于非自适应场景)
-XX:+UseSpinning开启自旋(JDK 1.6 后默认开启)
-XX:+EliminateLocks开启锁消除(配合逃逸分析)
-XX:+DoEscapeAnalysis开启逃逸分析

需要说明的是,这些参数在不同 JDK 版本中行为有差异,实验前最好先确认你所使用的 JDK 版本是否支持。JDK 8 上这些参数基本都好使,JDK 11 大部分保留,JDK 17 里偏向锁相关参数已经标记为废弃但仍然可用。

9.4 日常编码中怎么利用锁升级的规律

理解锁升级机制,最实际的收益是指导你写并发代码时做取舍:

  • 临界区尽量小,锁持有时间尽量短,这样锁大概率停留在偏向锁或轻量级锁,且自旋成功率更高。
  • 避免在同步块中做 IO、网络请求等耗时操作,否则重量级锁会带来大量线程阻塞和唤醒开销。
  • 如果需要 wait/notify,注定走重量级锁,尽量用显式锁(ReentrantLock + Condition)替代,功能更强且可控。
  • 如果多线程对同一个对象频繁竞争,而临界区非常短,可以先考虑使用java.util.concurrent.atomic包下的原子类,减少锁升级带来的自旋开销。
  • 高并发读少写多的场景,优先考虑读写锁或者CopyOnWriteArrayList这类无锁或弱一致容器,而不是一味加 synchronized。

最后提醒一点:不要为了“利用偏向锁”而刻意写多线程代码,这是一种本末倒置的微观优化。锁升级是 JVM 的自动行为,你能做的是尽量让临界区代码高效,避免无意义的线程间共享。真正的性能提升来自整体并发架构的合理设计,锁优化只是最后一步棋。

在我实际的排查经验里,遇到 synchronized 性能问题时,第一步永远不是去调 JVM 锁参数,而是先看临界区代码是否有耗时的外部调用、锁粒度是否可以拆分、以及能否用无锁方案替代。把这些做完了,锁升级机制自然会替你处理剩下的大部分性能问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询