当前位置: 首页 > news >正文

【8】面试官:synchronized 锁原理知道吗?说下锁的升级过程

压测群里最容易出现的一类对话,往往不是报错,而是这种有点别扭的现象:

👨‍💻同事:这个接口没改逻辑,只是在扣库存那段外面补了一个synchronized,怎么QPS一下掉了这么多?

👨‍💻另一个同事:锁嘛,线程排队,变慢不是很正常?

问题就在这里。这个回答不能算错,但也几乎没提供什么信息。因为真正让人困惑的不是“锁会让线程排队”这句空话,而是:同样是synchronized,为什么有的地方加上去几乎没感觉,有的地方却像在路口突然放下闸门。

继续往下拆,答案不只是“锁会让线程排队”。真正拉开成本差距的,是 JVM 会按竞争强度走不同路径:轻则改对象头,重则膨胀到ObjectMonitor,进入阻塞和唤醒。后面要看的,就是这条分界线怎么来的,以及“锁升级”今天该带哪些版本前提。

1.synchronized锁的到底是什么

先把这层说清:synchronized锁住的不是代码,而是某个对象对应的进入权。

放到最常见的三种写法里,这层含义就很直观了:

classInventoryService{privatefinalObjectstockLock=newObject();privateintstock=100;publicvoiddeduct(){synchronized(stockLock){stock--;}}publicsynchronizedintcurrentStock(){returnstock;}publicstaticsynchronizedvoidreloadRule(){// 刷新静态配置}}
写法实际锁对象
synchronized (stockLock)stockLock这个对象
实例同步方法public synchronized int currentStock()当前实例,也就是this
静态同步方法public static synchronized void reloadRule()InventoryService.class这个类对象

所以,synchronized的核心不是“给一段代码贴标签”,而是让线程围绕某个对象依次进入临界区

这层语义至少包含三件事:

  • 互斥:同一时刻只有一个线程能持有这把内置锁。
  • 内存语义:对同一监视器的unlock,先行发生于后续的lock,所以既保证可见性,也保证必要的有序性。
  • 可重入:同一个线程已经拿到这把锁,再次进入不会把自己锁死。

这三个概念非常重要,后文会反复出现。

synchronized只理解成“Java 版互斥锁”还不够。它的语义不难,难的是成本差异:为什么同样是拿锁,有时候像一次轻量判断,有时候却会走到线程阻塞和唤醒。区别不在语义,在 JVM 走了哪条实现路径。

2. 线程进入同步块时,JVM 先动哪里

线程进入同步区时,入口大致分成两种:

  • 同步代码块,编译后通常会落成monitorentermonitorexit这组监视器相关指令。
  • 同步方法,通常不是简单塞两条显式指令,而是通过方法访问标志告诉 JVM:调用这个方法前要先拿到对应对象的监视器进入权。

真正影响快慢的,不是指令名字,而是线程进来之后,能不能先在对象头这一层走完快速路径。

这张图对应的是整条主链路:

线程不会一上来就进监视器,而是先检查对象头,优先走更轻的快速路径。CAS 和短时间自旋都扛不住时,才会进入ObjectMonitor。后面几个关键对象,就是对象头、Mark Word、锁记录和ObjectMonitor

  • 对象头:每个对象前面那一小段运行时元数据。
  • Mark Word:对象头里会随着运行时状态变化的那一部分,锁状态也会在这里体现。
  • 锁记录(Lock Record:在线程栈帧里与轻量级路径相关的记录,用来保存进入同步块前的对象头信息;这个说法主要对应经典 HotSpot /JDK 8一带的描述语境。
  • ObjectMonitor:竞争激烈时真正接管等待、阻塞、唤醒这类重活的监视器结构。

JVM 不会一上来就把线程挂起。它会先尽量在对象头和线程栈这一层把竞争处理掉,只有竞争真的上来了,才会走到监视器那条更重的路径。

3. 对象头里那点空间,为什么会决定锁的快慢

之所以大家总说synchronized和对象头有关,是因为HotSpot需要在对象头里复用一小块空间,记录当前对象处在什么锁状态。

这块信息通常会跟下面这些运行时元数据共处:

  • 对象的哈希值相关信息
  • 分代年龄
  • 锁状态位
  • 在经典实现里,偏向锁还会复用一部分位去记录偏向线程信息

这一层更适合按经典 HotSpot 的理解模型来读,尤其是涉及偏向锁时。到了较新的JDK版本,默认路径和对象头叙事已经比老资料收束得多。

所以,Mark Word的重点从来不是“位图要背下来”,而是:同一块对象头空间会随着锁状态变化,改写成不同含义。

对象头那一层,重点不是升级顺序,而是同一块空间在不同状态下到底让位给了谁

对象头不是固定不变的锁字段,而是一块会随着竞争形态改写含义的运行时空间。

Mark Word也不是“永远都长一样的锁字段”,它本来就是复用空间。对象有锁,也不等于对象里一直挂着一个完整互斥锁结构。大多数时候,JVM 会先用对象头和线程栈上的轻量元数据处理竞争,撑不住了才会把监视器搬出来。

顺着这个思路,偏向锁、轻量级锁、重量级锁就不再像三个孤零零的名词,而像三种不同竞争形态下的不同解法。

3.1. 只看一句synchronized,对象头和栈帧会怎么一起变

如果只盯着“加锁”这两个字,Mark Word、锁记录这些词还是容易飘在空中。先把代码缩到最小:

classCounterDemo{privatefinalObjectlock=newObject();privateintcounter=0;publicvoidincr(){synchronized(lock){counter++;}}}

这段代码虽然只有一行临界区,但线程真正进去时,通常会同时牵动两处运行时位置:

  • 对象头里的Mark Word
  • 当前线程栈帧里的锁记录

如果按经典HotSpot的理解口径把变化压成几步,大致会长这样:

// ---------- 阶段 1:进入同步块前 ----------lock.mark=[unlocked|hash|age];// 对象头还是普通无锁状态,此时还谈不上偏向锁、轻量级锁、重量级锁A.frame.lockRecord=empty;// 线程 A 的栈帧里还没有锁记录,因为还没真正进入轻量级锁路径// ---------- 阶段 2:线程 A 尝试获取 ----------// 这里走的是经典 HotSpot 语境下的轻量级锁路径,不是偏向锁A.frame.lockRecord=displaced(lock.mark);// 先把旧的 Mark Word 备份到锁记录里,因为下一步 CAS 会覆盖对象头// 不先备份,退出同步块时就不知道该把 hash、age 等原始信息还原回什么lock.mark=[lightweight|ptr->A.lockRecord];// 再用 CAS 把对象头改成指向这份锁记录,表示当前锁已由线程 A 持有// ---------- 阶段 3:线程 B 开始竞争 ----------// 线程 B 看到的已经不是无锁对象头,而是“轻量级锁已被 A 持有”的状态B.CAS(lock.mark)=fail;// 线程 B 尝试改对象头,但发现对象头已经指向 A 的锁记录B->自旋/膨胀判断;// 先短时间自旋,这一步仍属于轻量级锁在尽力自救,还没升级成重量级锁// ---------- 阶段 4:竞争升级为重量级 ----------// 自旋扛不住之后,锁才真正升级为重量级锁lock.mark=[monitor|ptr->ObjectMonitor];// 对象头不再指向锁记录,而是改指向监视器,说明已经走到重量级锁路径monitor.owner=A;// 当前监视器持有者还是线程 A,A 还没退出临界区monitor.entryList=[B];// 线程 B 进入等待队列,后面会由 ObjectMonitor 负责阻塞、唤醒和再次竞争// ---------- 阶段 5:线程 A 退出同步块 ----------A.restore(lock.mark);// 如果还停留在轻量级路径,会尝试把备份的旧 Mark Word 恢复回对象头A.release(monitor);// 如果已经膨胀成重量级锁,就改由 monitor 完成释放和后继线程唤醒

这组变化想说明的不是位图细节,而是两件事。

第一,对象头不是自己凭空“变成了一把完整锁”,它更像一个入口,先记录当前该把竞争导向哪条路径。

第二,轻量级路径也不是只改对象头。对象头要改,线程栈上也要先留一份锁记录,这样后面才知道原来的Mark Word是什么,退出同步块时该怎么还原。

第三,这条“备份到锁记录再恢复”的写法主要对应经典 HotSpot / legacy stack-locking的理解模型。讲面试题、讲历史演进,用它来理解对象头为什么会改写很合适;但如果讲的是今天的新版本默认实现,还得再往下补轻量级锁新口径。

这张图对应的,就是这几步在“对象头 + 栈帧 + 监视器”三层上的对应关系:

把这张图和上面那段伪代码对起来看,抽象词就会落地很多:轻量级锁不是凭空冒出来的状态名,而是对象头和栈帧一起配合出来的一条轻路径。

4. 为什么会有偏向锁、轻量级锁、重量级锁这三条路径

“锁升级”这四个字,本质上是在描述 JVM 对不同竞争强度的分层应对。

同一个synchronized,面对的现实场景其实完全可能不一样:

  • 有的锁,几乎永远都是同一个线程反复进入。
  • 有的锁,会被两个线程短时间交替拿到,但临界区很短。
  • 有的锁,本身就是热点竞争点,一到高峰期就会很多线程一起抢。

如果 JVM 对这三种情况都只给同一种实现,成本要么过高,要么扛不住竞争。所以经典 HotSpot 才会把路径分层。

这里先按经典 HotSpot 主线讲理解框架;到了JDK 15+,再补偏向锁默认关闭的版本前提。

这张图对应的是“竞争形态”和“锁路径”的对应关系:

这里的主线很简单:竞争越轻,JVM 越倾向在用户态解决;竞争越重,才越会把线程真正带到监视器那条慢路径上。

4.1. 线程 A 和线程 B 一竞争,锁路径就开始分叉

只看定义,还是容易觉得“竞争”这两个字太空。换成一段很小的代码就直观多了:

classStockCounter{privatefinalObjectlock=newObject();privateintstock=100;publicvoiddeduct(StringthreadName,longworkMillis){synchronized(lock){System.out.println(threadName+" enter");sleep(workMillis);stock--;System.out.println(threadName+" exit");}}privatestaticvoidsleep(longmillis){try{Thread.sleep(millis);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}publicstaticvoidmain(String[]args)throwsInterruptedException{StockCounterdemo=newStockCounter();ThreadthreadA=newThread(()->demo.deduct("A",80),"thread-A");ThreadthreadB=newThread(()->demo.deduct("B",10),"thread-B");threadA.start();Thread.sleep(5);threadB.start();threadA.join();threadB.join();}}

这段代码真正决定竞争强度的,主要就三个量:

  • 线程 B 到得有多晚
  • 线程 A 持锁多久
  • 同时来抢这把锁的线程到底有几个

只改这三个量,路径就会明显分叉。

  • 几乎不重叠:如果线程 B 在 A 退出之后才来,基本感受不到竞争。
  • 短时间重叠:如果线程 B 只晚几毫秒到,看到的是“锁已经被占着”,但持锁时间不长,常见表现更接近轻量级路径想处理的场景。
  • 长时间重叠或很多线程一起抢:如果线程 A 在临界区里待太久,或者后面不只一个线程 B,而是一串线程都往上撞,就更容易把事情一路推向膨胀和阻塞。

这张图放在这里,是为了把这三种竞争程度直接和线程视角对上:

A 和 B 这两个线程已经足够把问题看清了。真到高竞争时,区别也不是原理变了,而是把图里的线程 B 换成一串 B、C、D,一起挤向同一个锁对象。

4.2. 偏向锁在解决什么问题

偏向锁是经典 HotSpot 里很有代表性的一条优化路径,它的前提很朴素:

如果一个对象总是被同一个线程进入同步块,那每次都去做原子 CAS,其实也挺浪费。

于是 JVM 会尝试把这个对象“偏向”给第一次拿到它的线程。后续还是这个线程再来时,不需要每次都重新走完整竞争流程,只要确认“还是你”,就可以直接进入。

这条路径的好处,是无竞争、且同一线程反复获取同一对象锁时成本非常低。可它的问题也很明显:只要后来有别的线程来竞争,之前这份“偏向”就得撤销。

偏向撤销本身就有成本,严重时还会涉及安全点暂停和栈记录调整。所以偏向锁不是一直更快,它只是对某一类场景特别有效。

4.3. 轻量级锁在解决什么问题

如果说偏向锁针对的是“几乎没有线程切换”,那轻量级锁针对的就是另一类更常见的场景:

线程之间会有竞争,但竞争不算特别激烈,临界区也比较短。

在经典 HotSpot 的常见描述里,JVM 不会立刻把线程挂起,而是先让线程在用户态多试几次:

  1. 在线程栈帧里先准备一份锁记录。
  2. 把进入同步块前的对象头信息拷进去。
  3. 用 CAS 尝试让对象头指向这份锁记录。
  4. 如果成功,说明当前线程拿到了这把锁。
  5. 如果失败,先判断是不是重入;如果不是,再进入自旋或后续膨胀逻辑。

这就是老资料里常说的“栈上锁记录 + CAS 改写对象头”那条轻路径。

放到较新的 HotSpot 口径里,也可以把它理解成先走 fast/lightweight locking,只有竞争、自旋失败,或者wait/notify这类必须依赖监视器的场景出现时,才会进一步膨胀到ObjectMonitor

这里最好再补一层版本前提,不然“经典轻量级锁”和“当前默认轻量级锁”很容易被混成一回事:

  • JDK 8-20常见资料口径:很多文章还是按 classic/legacy stack-locking 来讲对象头、锁记录和膨胀。
  • JDK 21-22:新 lightweight locking 已经引入,但还不是所有读者默认认知里的主叙事。
  • JDK 23+LockingMode默认已经从LM_LEGACY切到LM_LIGHTWEIGHT,再把“对象头一定指向栈上锁记录”当成当前默认现实,就不够稳了。

所以轻量级锁真正“轻”的地方,不是它完全没有竞争,而是:

  • 先不急着让线程进入内核阻塞态
  • 先试着靠 CAS 和短时间自旋把锁拿下来

这种路径特别适合临界区短、锁持有时间短的代码。因为如果锁很快就会释放,那么让线程稍微等等,往往比直接挂起再唤醒更划算。

4.4. 重量级锁在解决什么问题

轻量级锁不是万能药。只要竞争持续、持锁时间拉长,或者自旋多次还是拿不到,继续空转就不划算了。

这时候 JVM 会做的事叫锁膨胀:把之前主要靠对象头和栈上锁记录维持的轻路径,转成真正由ObjectMonitor管理的重路径。

一旦走到这里,事情就变了:

  • 竞争线程可能会进入阻塞等待。
  • JVM 需要管理谁持有锁、谁在等待、释放时唤醒谁。
  • wait/notify这套机制也建立在监视器之上。

这张图对应的是轻量级路径撑不住之后的膨胀过程:

沿着线程 B 这条线看最清楚:CAS 失败,自旋,再失败,最后才触发膨胀。真正拉开成本差距的,不是“拿锁”这两个字,而是线程开始阻塞、唤醒、重新竞争之后那一整套调度开销。

也就是说,真正让synchronized显得“很重”的,并不是关键字本身,而是它在高竞争下终于走到了监视器、阻塞、唤醒这一整套路径上。

5. 偏向锁为什么当年重要,后来又被默认关掉

偏向锁并不天然更先进,也不适合一直留着。

JEP 374给过一个很清楚的官方背景:偏向锁本来就是为了减少无竞争同步的成本,尤其是那种“对象经常被同一线程反复拿到”的老式场景。早期HashtableVector这类到处带同步的方法,在这种优化下确实能吃到红利。

可现代 Java 程序的形态变了:

  • 单线程场景更多直接用不带同步的容器。
  • 多线程场景更常见的是并发容器、线程池和任务队列。
  • 原子指令的成本结构也和偏向锁刚诞生时不一样了。

偏向锁的问题就在于,它只在一类很窄的场景下特别值钱,可一旦发生竞争,撤销偏向的成本又不便宜。

所以JDK 15JEP 374才做了一个很关键的调整:偏向锁默认关闭,相关参数进入弃用状态。

这件事很重要,因为它直接影响今天该怎么讲“锁升级”。更稳的版本边界至少要拆成两组:

  • JDK 8-14:偏向锁处在经典默认前景里。
  • JDK 15-17:偏向锁默认关闭,但显式指定-XX:+UseBiasedLocking仍可重新打开。
  • JDK 18+:相关选项已经进入 obsolete 状态,继续把偏向锁当成现行默认路径就不合适了。
  • JDK 21-22:新 lightweight locking 已经进入 HotSpot 口径,讲“轻量级锁”时要开始区分新旧实现。
  • JDK 23+LockingMode默认切到LM_LIGHTWEIGHT,当前默认实现不再等同于 classic legacy stack-locking。

“偏向锁 -> 轻量级锁 -> 重量级锁”这条线,首先是一条经典 HotSpot 演进主线;它能帮人理解 JVM 为什么要分层处理竞争,但不能不加前提就当成所有新版本的默认事实。

把这层口径分清,后面很多资料里的冲突就都能解释了。

6. 今天再讲“锁升级”,哪句话最容易讲错

更容易讲错的说法是:

synchronized一定会按“无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁”这条固定路径升级。

这个说法拿来解释JDK 8一带的经典 HotSpot还行,但拿来描述今天所有版本就不准了。更稳的说法是:经典资料可以用这条主线解释 JVM 为什么不愿意一开始就把线程挂起;讲现状时,至少要同时补上“偏向锁默认关闭”和“JDK 23+默认已切到LM_LIGHTWEIGHT”这两层版本边界。

当前OpenJDK主干里的markWord.hpp注释,也已经主要在展示更收束的锁状态表达;再往新的 HotSpot 看,JEP 450这类演进也进一步说明,老式 stack locking 口径不该被直接拿来当成所有新版本的默认现实。

这张图放在这里,是为了把这两层口径摆在一起:

左边讲的是经典理解主线,右边补的是今天描述现实时必须带上的版本前提。这两个层次一旦混写,读者就很容易把历史实现误当成当前默认事实。

所以,今天再碰到“锁升级过程”这个面试题或者文章题,更稳的一句话不是死背路径,而是:

synchronized背后有一套针对不同竞争强度的多层实现;经典资料常用偏向锁、轻量级锁、重量级锁来解释这套分层,但讲现状时必须补 JDK 版本边界。

7. 工程上什么时候继续用synchronized,什么时候该停一下

原理讲清之后,还是要回到工程判断。

7.1. 这几种场景里,synchronized依然很好用

  • 临界区很小:只是保护几行内存状态读写,不夹带I/O、数据库、RPC。
  • 锁对象天然清晰:就是this、类对象,或者一个很明确的私有锁对象。
  • 代码可读性优先:只需要一个简单、可靠的内置互斥机制,不需要超时、可中断、公平锁这些额外能力。
  • 竞争不算高:偶尔会排队,但没有把它变成系统级热点瓶颈。

7.2. 这些信号一出现,就别再把问题想得太简单

  • 临界区里做了慢操作:比如查库、调远程接口、写磁盘。锁时间一长,再轻的路径也会被拖重。
  • 同一把锁保护了太多事情:热点对象成了十字路口,线程都往这里汇。
  • 需要更细的控制能力:例如超时获取、可中断等待、多个条件队列。
  • 已经出现明显竞争指标:线程栈里大量BLOCKED,吞吐掉得厉害,锁争用在 profiler 里非常醒目。

很多线上性能问题,并不是synchronized这个关键字本身不好,而是它本来适合保护一个很小的临界区,最后却被拿去包住了一整段慢路径。

该反思的往往不是“还要不要用synchronized”,而是“是不是把太多东西塞进了同一把锁里”。

8. 线上排查synchronized变慢,先问这三件事

回到开头那个“只是补了一个synchronized,接口怎么突然慢了”的现场,先追三个问题:

  1. 锁对象到底是谁?this、类对象,还是一个被很多线程同时碰到的热点私有锁?
  2. 临界区里到底包了什么?只是几行内存状态修改,还是顺手把查库、远程调用、磁盘写入也一起包进去了?
  3. 竞争已经走到哪一层?还停留在对象头和 CAS 这类轻路径,还是已经膨胀到ObjectMonitor,开始出现阻塞和唤醒?

这三个问题一拆开,很多表面上都叫“锁太重”的问题,根因就会立刻分开。

有的是锁对象选得太大,有的是临界区包得太长,有的才是真的竞争已经重到该换设计了。工程上别急着给synchronized贴“重”或“轻”的标签,先看代码到底卡在锁对象、临界区,还是竞争层次。

把这一层看清,再看到线程栈里一片BLOCKED,脑子里就不会只剩一句“锁会让线程排队”了。

9. 参考资料

  • OpenJDK WikiSynchronization and Object Locking
  • JEP 374Deprecate and Disable Biased Locking
  • JDK-8256425Obsolete Biased Locking in JDK 18
  • JEP 450Compact Object Headers (Experimental)
  • OpenJDKsrc/hotspot/share/oops/markWord.hpp
http://www.jsqmd.com/news/863361/

相关文章:

  • AI双轨制实战指南:MoE架构、异构模态与弹性推理的工程落地
  • AArch64虚拟化调试:HDFGWTR2_EL2寄存器详解与应用
  • git fsck 深度解析 Git 仓库的体检医生
  • 汽车软件维护性挑战与架构优化实践
  • 软考高项案例分析7:项目沟通管理
  • 多域名单证书如何配置 Nginx 实现共用同一个 SSL 证书
  • 5分钟搞定百度网盘限速:baidu-wangpan-parse全功能指南
  • 基于微信小程序的社区遗失物品登记与认领系统
  • 3分钟解锁:让魔兽争霸3在现代Windows系统上完美运行的完整指南
  • 2026年还在为去AI痕迹困扰?这7款降AI工具实测有效,助力提升论文通过率! - 降AI实验室
  • Mixtral 8x7B:稀疏专家模型(MoE)高效推理实战指南
  • 2026邯郸装修公司综合实力测评指南(业主实测版) - GEO排行榜
  • MoE大模型稀疏激活原理与生产部署实战
  • 终极M3U8下载指南:N_m3u8DL-CLI-SimpleG的完整使用教程
  • 2026年05月20日最热门的开源项目(Github)
  • 解锁米哈游游戏字体:11款开源字体库完整使用指南
  • Mixtral 8x7B本地部署指南:MoE架构下的高性价比大模型实践
  • AMD Ryzen系统深度调试指南:SMUDebugTool专家级硬件诊断与性能调优实战
  • 银川光伏护栏网厂家推荐|宁夏路弘 本地品牌、实景业务、全场景适配 - 宁夏壹山网络
  • MoE大模型核心揭秘:Router路由机制与活跃参数原理
  • 四平方和定理
  • Keil MDK 5.34调试器重复显示问题解决方案
  • 话费充值卡怎么变现?这份全流程攻略你一定要看! - 团团收购物卡回收
  • 建筑玻璃可见光透射比遮阳系数检测仪:行业洞察、核心产品解析与选型指南 - 品牌推荐大师
  • WebPlotDigitizer终极指南:5步从图表图像中提取精准数据的免费工具
  • 银川施工围挡选哪家?本地源头工厂宁夏路弘一站式靠谱推荐 - 宁夏壹山网络
  • Mythos大模型:跨栈系统直觉与自主运维能力解析
  • 下一代搜索引擎会是 AI Agent Harness Engineering 吗?从检索信息到完成任务
  • 2026云南旅游实测封神!10款西双版纳私人定制团口碑出众体验佳 - 十大品牌榜
  • 炉石传说佣兵战记自动化脚本:3步实现高效游戏流程的终极指南