
Java 并发编程里synchronized 是个怎么都绕不开的话题。面试问八股文第一波几乎就是“你说说 synchronized 的锁升级过程”然后等着你背出无锁、偏向锁、轻量级锁、重量级锁这四个名词。但在实际工作中我发现能背出四个阶段名字的人很多能讲清楚为什么需要升级、Mark Word 里每个 bit 怎么流转、批量重偏向的阈值是多少、为什么调了 hashCode 就偏向不了的少之又少。这篇文章不打算复述教科书。我会从 JVM 底层实现的角度把锁升级这条路从头捋到尾讲清楚每个阶段的设计意图、触发条件、切换细节以及我踩过和见过的那些“看着不对实际是锁状态导致”的坑。不管你是准备面试还是写并发代码想搞明白 synchronized 到底干了什么这篇都比较适合静下心来看完。1. synchronized 为什么需要“升级”机制1.1 JDK 1.6 之前的 synchronized 为什么慢很多人对 synchronized 的印象还停留在“重锁”阶段每次加锁都要向操作系统申请 mutex线程获取不到锁就挂起挂起和恢复涉及用户态到内核态的切换开销非常大。一个简单的计数器累加如果每次都走这个流程吞吐量直接腰斩。这就是 JDK 1.6 之前 synchronized 的真实处境也是那个年代面试必问“synchronized 和 Lock 哪个性能好”的原因。但请注意这个问题的答案早就变了。JDK 1.6 之后HotSpot 对 synchronized 做了一次大手术引入了偏向锁、轻量级锁、适应性自旋、锁消除、锁粗化等一系列优化。从此synchronized 的加锁代价不再是固定的“mutex 一次”而是根据竞争激烈程度动态调整。一句“synchronized 性能差”挂在嘴边的说法放到现在是不准确的。锁升级本质上解决的是一件事没有竞争的时候别让我付出锁的成本有竞争的时候再根据竞争烈度逐步加码。这就像一家公司只有一个人的时候不需要开会定流程两个人配合口头打个招呼就行等到几十人混战才需要正式会议和邮件审批。JVM 选择锁策略的思路和现实中的管理逻辑高度一致。1.2 锁升级的整体设计思路整个升级路径是单向的无锁 → 偏向锁 → 轻量级锁 → 重量级锁。JVM 希望用最少成本的方案解决问题只有当低成本的方案撑不住时才升级到下一档。无锁态对象刚创建出来没有任何线程访问同步块此时连“锁”的概念都没有只有对象头里一个普通的 Mark Word。偏向锁只有一个线程反复进入同步块。JVM 发现这个模式后直接把线程 ID 记在对象头里下次这个线程再来什么都不用做直接进。适用于“同一个线程反复访问同一个锁”的场景。轻量级锁出现了两个以上线程交替访问同步块但没有真正同时竞争。此时 CAS 置锁配合自旋硬扛尽量不挂起线程。重量级锁多个线程真正抢同一个锁自旋已经消耗了大量 CPU果断交给操作系统管用互斥量阻塞唤醒代价最大但最公平。这里有两个非常关键的认知。第一升级是不可逆的对象一旦进入重量级锁不会再降回轻量级或偏向锁这是 HotSpot 为了简单可靠做的取舍。第二升级不是每次加锁都必须走完全程比如一个对象创建后第一次加锁JVM 可以直接让它尝试进入轻量级锁流程也可能让它直接走偏向锁流程取决于对象当前状态和 JVM 参数。2. 读懂对象头锁升级的基础2.1 Mark Word一把锁的状态全在这 8 个字节里对象头是理解锁升级的根。在 64 位 HotSpot 虚拟机里一个 Java 对象的内存布局分为三部分对象头Mark Word Class Pointer、实例数据、对齐填充。其中 Mark Word 固定占 8 字节64 bit里面的内容会随着锁状态变化而完全改变。我整理了一张常见布局表建议收藏面试前反复看锁状态Mark Word 存储内容标志位末尾 bit无锁对象 hashCode、分代年龄、偏向标记001偏向锁线程 ID54 bit、epoch2 bit、分代年龄、偏向标记101轻量级锁指向栈中 Lock Record 的指针62 bit00重量级锁指向 ObjectMonitor 的指针62 bit10注意一个细节无锁和偏向锁的末尾标志位都是 01要靠中间的 biased_lock 位来区分。biased_lock0 表示无锁不可偏向biased_lock1 表示可偏向或已偏向。JVM 在判断时先用最后两位粗分类再细看中间位这也是为什么很多人看锁状态源码时容易绕晕的原因。2.2 hashCode、age、threadId 挤在同一个字段里的冲突Mark Word 只有 64 bit要存的信息却很多identity hashCode31 bit、分代年龄 age4 bit、偏向线程 ID54 bit、epoch2 bit、锁标志位2 bit。这天然决定了同一时刻只能有一种状态。最典型的冲突就是 hashCode 和偏向锁。当一个对象的 identity hashCode 被计算过比如调用了默认的 hashCode() 或 System.identityHashCode()hashCode 值已经写入 Mark Word 的 31 bit 区域。此时这个对象永远无法进入偏向锁状态因为如果是偏向锁这 31 bit 就要让位给偏向线程 IDhashCode 没地方放。反过来如果对象已经在偏向锁状态你再去调 hashCode()JVM 会先撤销偏向锁腾出空间写 hashCode。这一点面试里问“为什么调用 hashCode 后 synchronized 偏向不了”就是这个原因。理解了 Mark Word 的位布局再看 JOL 输出的时候你就能直接读出当前对象处于哪一档锁状态。这是每个 Java 工程师都应该掌握的基本功不需要背眼源码但布局图一定要印在脑子里。3. 锁升级的四个阶段与完整切换过程3.1 无锁 → 偏向锁单线程场景下的“零成本”加锁偏向锁是 JVM 团队设计的第一个优化档位它的核心假设是很多锁在整个生命周期里从头到尾只有一个线程访问。这种情况下反复走 CAS、自旋甚至 mutex 都是浪费。偏向锁的获取流程很简单对象第一次被某个线程执行 synchronized 块时Mark Word 已经处于“匿名偏向”状态biased_lock1threadId 为空。此时 JVM 用一次 CAS 原子操作把自己的线程 ID 写入 Mark Word。写入成功后这个锁就“归属”于当前线程了。之后同一条线程再次进入同一个同步块JVM 只需要检查 Mark Word 里的偏向线程 ID 是否等于自己。相等就直接执行临界区代码连 CAS 都不用再发。这就是偏向锁“快”的本质单线程场景下加锁就是一次字段比较比普通代码多不了几纳秒。这里有个容易忽略的点synchronized 是可重入的偏向锁的重入开销更低。因为锁对象和当前线程 ID 匹配后JVM 不需要在栈上创建额外的锁记录直接往里走就行。真要验证“这段代码进了几次锁”只能靠字节码或者 instrumentation常规手段看不出来。3.2 偏向锁 → 轻量级锁出现竞争后的第一道防线假设线程 A 已经持有了某个对象的偏向锁线程 B 此时也想抢这个锁。B 发现 Mark Word 里的偏向线程 ID 是 A不是自己于是触发偏向锁撤销。偏向锁撤销不是简单地把线程 ID 换掉它需要等待全局安全点SafePoint。所谓安全点就是 JVM 暂停所有线程执行此时堆内存状态是稳定的可以安全地修改对象头。JVM 把线程 A 停住后检查 A 是否还处于临界区如果 A 已经执行完了同步块偏向锁就作废Mark Word 恢复成无锁状态A 和 B 重新公平竞争。如果 A 还在临界区说明确实有竞争偏向锁立刻升级为轻量级锁并把锁的“所有权”交给还在临界区里的 A 继续持有。这个安全点停顿是有代价的所以偏向锁的撤销并不便宜。如果一个锁频繁地被多个线程“你用完我用”偏向锁反而会反复触发撤销得不偿失。当初 JVM 对偏向锁的定位非常明确它牺牲撤销时的复杂度换取绝大多数单线程场景的无条件加速。升级成轻量级锁之后线程 B 开始在栈帧中创建一个 Lock Record里面保存当前希望持有锁的“副本”Mark Word然后通过 CAS 尝试把对象头里的 Mark Word 替换成指向自己栈帧 Lock Record 的指针。如果 CAS 成功说明锁抢到了如果失败说明对象头里的 Mark Word 指向了别的线程的 Lock Record锁被占着。这时 B 不会立刻挂起而是进入自旋——原地空转几纳秒赌 A 马上就要释放。为什么敢这么赌因为临界区代码通常很短挂起线程再唤醒的开销比自旋大得多等一小会儿是划算的。3.3 轻量级锁 → 重量级锁自旋扛不住的时候自旋不能是无上限的否则大量线程在空转CPU 利用率上去了、任务却什么都没干反而拖垮整个应用。JDK 1.6 引入了适应性自旋JVM 会根据上一次自旋的成功率动态调整下一次自旋的次数。上次自旋拿到锁了下次多转几圈上次白转了下次少转或者直接放弃。如果自旋都没等到锁轻量级锁就正式“膨胀”为重量级锁。这里的关键组件是ObjectMonitorJVM 内部的监视器对象它维护着 owner当前持锁线程、recursions重入计数、EntryList等待获取锁的线程队列、WaitSet执行 wait 后挂起的线程队列。线程获取不到重量级锁时会进入 EntryList 排队由操作系统负责阻塞与唤醒。重量级锁之所以“重”就在于阻塞和唤醒线程需要依赖操作系统底层的 mutex 原语必然发生用户态到内核态的切换。一次切换几十上百纳秒如果同时有大量线程频繁竞争这个开销会被无限放大。这也是为什么写了不合理的锁竞争代码时top 命令里能看到 CPU 的 sys 占比明显升高因为大量时间耗在内核切换上。3.4 一条时间线看清完整升级路径我画个时间线帮助理解虽然是文字版但只要跟着走一遍就清楚了对象刚 new 出来Mark Word 是无锁状态末尾标志位 01偏量标记为 0。线程 A 第一次执行 synchronized 块JVM 把 A 的线程 ID 写入 Mark Word进入偏向锁标志位还是 01偏量标记变为 1。线程 A 反复进出每次都只比一次线程 ID直接通行。线程 B 也想抢偏向锁在 SafePoint 处撤销升级为轻量级锁B 用 CAS 自旋尝试获取。B 自旋到极限也没拿到锁膨胀为重量级锁Mark Word 指向 ObjectMonitorB 阻塞在 EntryList 中。A 释放锁唤醒 BB 获得锁执行锁状态已经不会再回到偏向锁。这套流程最反直觉的地方是一个锁一旦升到重量级释放后它也不会变回轻量级或偏向锁。后续所有线程再访问都直接走重量级锁流程。你在线上看到的“某个对象明明已经没人竞争了但每次加锁还是慢”很可能就是锁状态已经永久停留在了高级别档位。4. 容易被忽视的锁升级细节机制4.1 批量重偏向与批量撤销阈值背后的两个数偏向锁撤销太频繁时JVM 会判断当前类的对象“不适合继续偏向”于是引入两个阈值批量重偏向阈值默认 20。当某个类的对象偏向撤销次数达到 20 次时JVM 判定这些对象存在“频繁换手”的倾向但整体上还存在偏向价值于是触发批量重偏向。批量撤销阈值默认 40。当一个类的对象偏向撤销次数达到 40 次JVM 直接判定该类彻底不适合偏向锁从此这个类 new 出来的所有对象直接跳过偏向锁默认走轻量级锁流程。批量重偏向我用“标签版本号”来类比每个类有一个 epoch 值相当于版本号。正常偏向锁的 threadId 和 epoch 匹配时说明偏向依然有效。触发批量重偏向后类的 epoch 加 1所有对象如果 old_epoch 不等于当前 epoch那就当作自己没有偏向可以重新被其他线程偏向。这样就不用一个个去撤销成本低了很多。这个机制在排障时很有用。如果你观察到系统里频繁出现“偏向锁撤销”日志或者性能监控里某个 synchronized 方法的耗时突然升高很可能是某个类对象的加锁线程频繁切换触发了批量重偏向甚至禁用偏向。此时可以考虑调整-XX:BiasedLockingBulkRebiasThreshold和-XX:BiasedLockingBulkRevokeThreshold但更推荐的做法是审视这段代码的锁粒度是不是设计得太粗了。4.2 hashCode 与 wait/notify 对锁状态的强影响前面提到 hashCode 会阻止对象进入偏向锁这里再补全一下。如果你在任何一次加锁前调用过System.identityHashCode(obj)Mark Word 的 31 bit 已经被 hashCode 占用JVM 无法再写入偏向线程 ID所以该对象直接跳过偏向锁进入轻量级锁流程。反过来一个偏向锁状态中的对象第一次调用了 hashCode需要先经过 SafePoint 撤销偏向锁然后才能写 hashCode 值。wait/notify 的影响更直接。synchronized 的 wait 和 notify 依赖 ObjectMonitor 的 WaitSet 数据结构而只有重量级锁才拥有完整的 ObjectMonitor 实例。所以代码里一旦调用了 wait() 或 notify()JVM 会无条件膨胀锁到重量级即便当前明明没有竞争。这是很多人在写“锁内 wait 通知模型”时没注意到的隐藏成本每次 wait 都会让锁永久性地变成重量级。4.3 JIT 的锁消除与锁粗化不写锁也能白嫖优化面试问到锁升级如果能在最后补一句 JIT 优化会很加分。锁消除是基于逃逸分析的优化JVM 发现某个对象的引用根本不会逃逸出当前线程那加锁毫无意义编译期直接删除这些加锁指令。比如 StringBuilder 的 append 方法本身有 synchronized但 JIT 通过逃逸分析识别出局部变量不会逃逸后就会把锁消掉这也是为什么局部变量拼字符串用 StringBuilder 性能依旧很高的原因之一。锁粗化则相反JVM 发现同一线程对同一对象连续加锁解锁比如循环里反复 append 同一个 StringBuilder加锁解锁的缝隙里根本没有其他线程能插进来于是把加锁范围扩大合并成一次完整的加锁解锁省去中间的重复开销。这两个优化是 JIT 在运行时动态做的不用你手动写但理解它们能解释很多“为什么我写了加锁代码性能反而没受影响”的疑惑。5. 实操验证用 JOL 亲眼看看锁状态5.1 JOL 环境搭建5 分钟跑起来说再多理论不如自己亲眼看一下对象头。推荐用 OpenJDK 的 JOLJava Object Layout工具Maven 加一行依赖就能用dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency核心代码非常简单import org.openjdk.jol.info.ClassLayout; public class LockStateDemo { public static void main(String[] args) throws Exception { Object obj new Object(); System.out.println( 无锁状态 ); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println( synchronized 持有中 ); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } } }这段代码打印出来的 Mark Word 十六进制值中看末尾两位就能区分锁状态01 是无锁或偏向00 是轻量级10 是重量级。再配合偏置标记位基本一眼就能读出状态。注意运行 JOL 时要用-XX:UseBiasedLocking -XX:BiasedLockingStartupDelay0让偏向锁立即生效否则默认要等 JVM 启动 4 秒后才开启你第一眼看到的永远是轻量级锁。5.2 三种状态的实测结果解读我拿自己的测试环境跑过一遍打印结果大致长这样十六进制值因 JVM 版本略有差异但状态判断逻辑一致新建对象后Mark Word 末尾是01biased_lock 位为 0输出标注为non-biasable说明此刻是无锁状态。单线程持有锁并开启偏向锁时Mark Word 会变成偏向锁布局输出标注里能看到biased: thread以及具体的线程 ID 偏移量。两个线程交替抢锁时Mark Word 的末尾变成00输出标注为thin lock这是轻量级锁的典型特征锁的状态变成了指向线程栈中 Lock Record 的指针。如果真的调用一次 wait()再看对象头Mark Word 末尾会变成10输出为fat lock从此这个锁就是重量级锁再也不会回退。整个测试做下来最大的感受是“眼见为实”。以前看八股文里的锁升级流程总觉得抽象看完 JOL 输出之后Mark Word 里的每一位变化全部落地了。建议读者把这段代码直接复制到自己项目里跑一遍多花 5 分钟事半功倍。5.3 这个实验里容易踩的坑JOL 验证有几个细节第一次做很容易翻车。第一JVM 参数没配对。默认偏向锁延迟 4 秒启动你如果直接跑上面代码看到的基本都是轻量级锁和无锁以为偏向锁不存在。必须在启动参数里加上-XX:BiasedLockingStartupDelay0关闭延迟。第二JDK 15 之后偏向锁默认关闭。如果你用的是 JDK 17 或更高版本跑实验需要显式加-XX:UseBiasedLocking否则进入的就是轻量级锁而非偏向锁。第三打印偏向前别先调用 hashCode。实验前如果调用了obj.hashCode()或System.identityHashCode(obj)这个对象就再也不能偏向实验结果直接失效。我的建议是“读完 Mark Word 之后再做其他操作”顺序错了结论就反了。6. 高频面试问答与实战避坑速查6.1 面试官常问的 7 个判定题我把常被追问的细节整理成了表格每一条都是在面试里真实出现过的问题一句话结论展开说明锁升级是单向的吗是单向不可逆一旦升到重量级锁不会回退释放锁后对象头回到无锁但下一次加锁不会再走偏向偏向锁适合什么场景单线程反复进入锁多线程交替进来时会反复撤销成本比收益高自旋锁是重量级锁的一部分吗不是属于轻量级锁阶段轻量级锁获取失败先自旋扛不住才膨胀成重量级hashCode 对偏向锁有什么影响调用过 hashCode 就无法偏向Mark Word 空间冲突biased_lock 置 0wait/notify 一定会升级重量级锁吗会wait/notify 依赖 ObjectMonitor只有重量级锁有 WaitSet偏向锁重入要 CAS 吗不要直接比对线程 ID相等就是自己的偏向锁重量级锁为什么慢涉及用户态内核态切换阻塞唤醒由操作系统 mutex 完成切换开销远大于 CAS这七条如果都能脱口而出synchronized 锁升级这一块就基本过关了。我面试时看到候选人能主动把“hashCode 冲突”和“wait 强制膨胀”这两个冷门点说出来无论最终结论如何基础功底都是明显达标的。6.2 我在实际项目里见过的三个真实问题第一个问题是锁状态监控误判。有同事用 JOL 打印线上对象的状态发现一堆对象处于重量级锁状态直接得出“锁竞争严重”的结论。但实际压测数据显示线程阻塞率几乎为零。后来排查发现这些对象在某个初始化流程里被调过 wait()锁从此永久变成重量级后续即使单线程访问也回不去。所以看到重量级锁不一定等于当前有竞争要结合线程 Dump 一起看。第二个问题是偏向锁参数被无脑调低。有团队为了“避免偏向锁撤销开销”把BiasedLockingBulkRevokeThreshold调到 1结果 JVM 启动后大量类直接被禁用偏向锁。线程反复进入同一个锁时每次都要走完整的加锁和解锁流程性能反而比默认参数低了 5% 左右。调整这类参数前先用 JFR 或 Async Profiler 确认偏向锁撤销确实是热点不要凭感觉优化。第三个问题是对 synchronized 的偏见。直到现在还有人在并发场景里“逢锁必用 Lock”理由是 synchronized 慢。JDK 18 之后synchronized 在无竞争时的开销已经非常低偏向锁配合 JIT 优化后绝大多数场景和 ReentrantLock 已经没有数量级差距。我对团队的建议一直是能用 synchronized 解决的并发问题优先 synchronized需要超时、可中断、多个条件队列再上 ReentrantLock。锁升级机制的存在就是为了让 synchronized 能在大多数场景下自己“瘦身”别把它当老古董。回到锁升级本身我个人的体会是真正影响系统性能的从来不是锁机制本身而是临界区太长、锁粒度太大。锁升级在做的就是一件事——让你的同步代码“在该便宜的时候便宜在该昂贵的时候昂贵”。理解这一点比背下四个阶段的名字有用得多。面试官追问细节时你如果能从 Mark Word 的位布局讲到批量重偏向阈值再补一句“Java 15 之后偏向锁默认关闭因为现代框架和容器环境下偏向锁收益下降”这一题就基本稳了。最后再送一个排查技巧线上如果怀疑锁竞争开销大先别急着改代码用jcmd pid Thread.print -l看看线程到底阻塞在哪个锁上再配合 JOL 看对象头到底是哪一档锁状态。两步定位下来问题往往出在锁竞争激烈导致的重量级阻塞而不是 synchronized 本身。