
压测群里最容易出现的一类对话往往不是报错而是这种有点别扭的现象同事这个接口没改逻辑只是在扣库存那段外面补了一个synchronized怎么QPS一下掉了这么多另一个同事锁嘛线程排队变慢不是很正常问题就在这里。这个回答不能算错但也几乎没提供什么信息。因为真正让人困惑的不是“锁会让线程排队”这句空话而是同样是synchronized为什么有的地方加上去几乎没感觉有的地方却像在路口突然放下闸门。继续往下拆答案不只是“锁会让线程排队”。真正拉开成本差距的是 JVM 会按竞争强度走不同路径轻则改对象头重则膨胀到ObjectMonitor进入阻塞和唤醒。后面要看的就是这条分界线怎么来的以及“锁升级”今天该带哪些版本前提。1.synchronized锁的到底是什么先把这层说清synchronized锁住的不是代码而是某个对象对应的进入权。放到最常见的三种写法里这层含义就很直观了classInventoryService{privatefinalObjectstockLocknewObject();privateintstock100;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 先动哪里线程进入同步区时入口大致分成两种同步代码块编译后通常会落成monitorenter和monitorexit这组监视器相关指令。同步方法通常不是简单塞两条显式指令而是通过方法访问标志告诉 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{privatefinalObjectlocknewObject();privateintcounter0;publicvoidincr(){synchronized(lock){counter;}}}这段代码虽然只有一行临界区但线程真正进去时通常会同时牵动两处运行时位置对象头里的Mark Word当前线程栈帧里的锁记录如果按经典HotSpot的理解口径把变化压成几步大致会长这样// ---------- 阶段 1进入同步块前 ----------lock.mark[unlocked|hash|age];// 对象头还是普通无锁状态此时还谈不上偏向锁、轻量级锁、重量级锁A.frame.lockRecordempty;// 线程 A 的栈帧里还没有锁记录因为还没真正进入轻量级锁路径// ---------- 阶段 2线程 A 尝试获取 ----------// 这里走的是经典 HotSpot 语境下的轻量级锁路径不是偏向锁A.frame.lockRecorddisplaced(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.ownerA;// 当前监视器持有者还是线程 AA 还没退出临界区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{privatefinalObjectlocknewObject();privateintstock100;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{StockCounterdemonewStockCounter();ThreadthreadAnewThread(()-demo.deduct(A,80),thread-A);ThreadthreadBnewThread(()-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 不会立刻把线程挂起而是先让线程在用户态多试几次在线程栈帧里先准备一份锁记录。把进入同步块前的对象头信息拷进去。用 CAS 尝试让对象头指向这份锁记录。如果成功说明当前线程拿到了这把锁。如果失败先判断是不是重入如果不是再进入自旋或后续膨胀逻辑。这就是老资料里常说的“栈上锁记录 CAS 改写对象头”那条轻路径。放到较新的 HotSpot 口径里也可以把它理解成先走 fast/lightweight locking只有竞争、自旋失败或者wait/notify这类必须依赖监视器的场景出现时才会进一步膨胀到ObjectMonitor。这里最好再补一层版本前提不然“经典轻量级锁”和“当前默认轻量级锁”很容易被混成一回事JDK 8-20常见资料口径很多文章还是按 classic/legacy stack-locking 来讲对象头、锁记录和膨胀。JDK 21-22新 lightweight locking 已经引入但还不是所有读者默认认知里的主叙事。JDK 23LockingMode默认已经从LM_LEGACY切到LM_LIGHTWEIGHT再把“对象头一定指向栈上锁记录”当成当前默认现实就不够稳了。所以轻量级锁真正“轻”的地方不是它完全没有竞争而是先不急着让线程进入内核阻塞态先试着靠 CAS 和短时间自旋把锁拿下来这种路径特别适合临界区短、锁持有时间短的代码。因为如果锁很快就会释放那么让线程稍微等等往往比直接挂起再唤醒更划算。4.4. 重量级锁在解决什么问题轻量级锁不是万能药。只要竞争持续、持锁时间拉长或者自旋多次还是拿不到继续空转就不划算了。这时候 JVM 会做的事叫锁膨胀把之前主要靠对象头和栈上锁记录维持的轻路径转成真正由ObjectMonitor管理的重路径。一旦走到这里事情就变了竞争线程可能会进入阻塞等待。JVM 需要管理谁持有锁、谁在等待、释放时唤醒谁。wait/notify这套机制也建立在监视器之上。这张图对应的是轻量级路径撑不住之后的膨胀过程沿着线程 B 这条线看最清楚CAS 失败自旋再失败最后才触发膨胀。真正拉开成本差距的不是“拿锁”这两个字而是线程开始阻塞、唤醒、重新竞争之后那一整套调度开销。也就是说真正让synchronized显得“很重”的并不是关键字本身而是它在高竞争下终于走到了监视器、阻塞、唤醒这一整套路径上。5. 偏向锁为什么当年重要后来又被默认关掉偏向锁并不天然更先进也不适合一直留着。JEP 374给过一个很清楚的官方背景偏向锁本来就是为了减少无竞争同步的成本尤其是那种“对象经常被同一线程反复拿到”的老式场景。早期Hashtable、Vector这类到处带同步的方法在这种优化下确实能吃到红利。可现代 Java 程序的形态变了单线程场景更多直接用不带同步的容器。多线程场景更常见的是并发容器、线程池和任务队列。原子指令的成本结构也和偏向锁刚诞生时不一样了。偏向锁的问题就在于它只在一类很窄的场景下特别值钱可一旦发生竞争撤销偏向的成本又不便宜。所以JDK 15的JEP 374才做了一个很关键的调整偏向锁默认关闭相关参数进入弃用状态。这件事很重要因为它直接影响今天该怎么讲“锁升级”。更稳的版本边界至少要拆成两组JDK 8-14偏向锁处在经典默认前景里。JDK 15-17偏向锁默认关闭但显式指定-XX:UseBiasedLocking仍可重新打开。JDK 18相关选项已经进入 obsolete 状态继续把偏向锁当成现行默认路径就不合适了。JDK 21-22新 lightweight locking 已经进入 HotSpot 口径讲“轻量级锁”时要开始区分新旧实现。JDK 23LockingMode默认切到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接口怎么突然慢了”的现场先追三个问题锁对象到底是谁是this、类对象还是一个被很多线程同时碰到的热点私有锁临界区里到底包了什么只是几行内存状态修改还是顺手把查库、远程调用、磁盘写入也一起包进去了竞争已经走到哪一层还停留在对象头和 CAS 这类轻路径还是已经膨胀到ObjectMonitor开始出现阻塞和唤醒这三个问题一拆开很多表面上都叫“锁太重”的问题根因就会立刻分开。有的是锁对象选得太大有的是临界区包得太长有的才是真的竞争已经重到该换设计了。工程上别急着给synchronized贴“重”或“轻”的标签先看代码到底卡在锁对象、临界区还是竞争层次。把这一层看清再看到线程栈里一片BLOCKED脑子里就不会只剩一句“锁会让线程排队”了。9. 参考资料OpenJDK WikiSynchronization and Object LockingJEP 374Deprecate and Disable Biased LockingJDK-8256425Obsolete Biased Locking in JDK 18JEP 450Compact Object Headers (Experimental)OpenJDKsrc/hotspot/share/oops/markWord.hpp