
如果你做后端开发synchronized绝对是最熟的关键字之一但很多人只是“会用”根本没搞懂它在 JVM 里到底怎么运作。尤其谈及“锁升级”很多人第一反应就是背出“偏向锁、轻量级锁、重量级锁”这三个名字至于为什么升级、升级的触发条件是什么、升级之后还能不能降回去基本是一问三不知。这篇内容就用项目复盘的方式把 Java 锁升级这件事彻底拆开讲清楚它解决的痛点、背后的存储结构、完整的流转链路以及我在实际验证中踩过的坑。无论你是刚接触并发的小白还是被线上锁竞争折磨过的老手这篇都能让你少走弯路。1. 锁升级到底是什么从一次线上性能问题说起1.1 旧版 synchronized 为什么是性能毒药先回到很多年前JDK 1.5 及其之前的时代。那时的synchronized是“一锤子买卖”一旦进入同步块JVM 就会直接把对象锁升级成操作系统级别的互斥量Mutex。听起来没什么问题但代价极其昂贵。只要发生一次锁的获取和释放线程就可能从用户态切到内核态。内核态做线程阻塞、唤醒、调度涉及上下文切换这个开销通常比一条普通指令贵几个数量级。你把这段代码放到一个高并发的服务里跑如果锁竞争稍微激烈一点CPU 时间几乎全耗在线程切换上真正的业务逻辑反而在“排队”。讽刺的是大部分业务代码里的锁竞争并没有那么严重。某个对象被一个线程反复进入临界区、或者几个线程偶尔碰一下的场面才是最常见的。对这种低竞争场景直接拉上操作系统的 Mutex 完全是高射炮打蚊子。1.2 JDK 1.6 的“组合拳”优化所以从 JDK 1.6 开始HotSpot 虚拟机对synchronized做了一次非常成功的改造引入的正是锁升级机制同时还有自旋锁、锁消除、锁粗化等一系列优化手段。锁升级的核心思想简单说就是能用无锁就别加锁能用轻量级锁就别用重量级锁。JVM 会随着竞争激烈程度动态地让锁“变身”而不是一开始就上最猛的重量级锁。这里贴一张经典的锁状态对比它定义了锁定从“无锁”到“重量级锁”的四个阶段锁状态适用场景主要开销无锁没有竞争无额外开销偏向锁同一线程反复进入同一临界区只在第一次获取时做一次 CAS轻量级锁多个线程交替、短暂竞争CAS 短暂自旋重量级锁激烈竞争/等待时间长用户态与内核态切换、线程阻塞唤醒这套机制最妙的地方在于它不是让开发者去手动选择而是 JVM 根据运行时的实际情况自动升级。理解了这一点再去看各种锁状态就不会觉得它们是一堆零散的概念。2. 埋下基础Mark Word 和对象头的存储2.1 Java 对象在内存里到底长什么样想理解锁升级必须先从对象头说起。任何 Java 对象在堆内存里其实都有三个部分对象头Header包含 Mark Word 和类型指针Mark Word 常是 8 字节64 位 JVM类型指针通常也是 8 字节开启指针压缩后为 4 字节实例数据Instance Data对象的成员变量实际存储区域对齐填充Padding保证对象大小是 8 的倍数Mark Word就是锁升级的“主战场”。它虽然只有 64 个 bit却一鱼多吃哈希码、GC 分代年龄、锁状态标志位、指向锁记录的指针、指向重量级监视器Monitor的指针全都塞在这 64 bit 里。这就是为什么 HashCode 和锁状态会互相冲突后面我会专门踩这个坑。2.2 Mark Word 的状态编码表不同锁状态下Mark Word 的 bit 含义完全不同我直接把 64 位下最常见的布局整理出来锁状态锁标志位2 bitMark Word 内容无锁01未使用位25 HashCode31 分代年龄4 偏向锁标志1偏向锁01偏向线程ID54 Epoch2 分代年龄4 偏向锁标志1轻量级锁00指向栈中锁记录的指针62重量级锁10指向 Monitor 的指针62GC 标记11转发指针用于垃圾收集仔细看会发现无锁和偏向锁的“锁标志位”都是01两者靠偏向锁标志位biased_lock来区分biased_lock 0且lock 01→ 无锁biased_lock 1且lock 01→ 偏向锁那这个设计有什么讲究因为无锁状态要把大多数 bit 留给 HashCode而偏向锁需要一大段 bit 存放线程 ID两者互不兼容。一旦对象调用过identityHashCodeHashCode 已经写进 Mark Word它就很难再进入偏向锁状态了。这一点是无数新手掉坑的高发区。2.3 升级的本质是 CAS 改写 Mark Word锁升级说白了就是 JVM 用 CASCompare And Swap指令去修改 Mark Word 里的内容把原来存 HashCode 的位挨个替换成“线程 ID”“锁记录指针”或者“Monitor 指针”。CAS 是一种原子操作它会先去读目标内存位置的旧值如果旧值和期望值一致才写入新值整个过程不被线程调度打断。每次锁升级都是一次或多次 CAS 操作来完成的。理解了 Mark Word 和 CAS你再看锁升级的每一步就不会再觉得抽象。3. 三种锁的工作机制逐一拆解3.1 偏向锁用线程ID换免检通行证偏向锁解决的是“同一线程反复获取同一把锁”的情况。这种场景在真实代码里太常见了比如一个线程循环往集合里加元素每次循环都碰到synchronized块。第一次时线程 A 进入同步块JVM 检查 Mark Word 处于可偏向状态就用 CAS 把线程 A 的 ID 写进 Mark Word。写入成功锁就进入偏向锁状态线程 A 获得锁。之后线程 A 再次进入这个同步块JVM 发现 Mark Word 里的偏向线程 ID 就是自己啥都不用做直接进入临界区。这个过程零加锁开销比 CAS 还省纯粹是看 ID 匹配就放行。那如果有另一个线程 B 跑过来想要这把锁呢这就意味着“竞争”出现了。JVM 不会简单地把偏向线程改成 B因为 A 可能还在临界区里执行。偏向锁必须执行撤销操作。偏向锁撤销的步骤并不轻松JVM 到达一个安全点暂停所有 Java 线程检查偏向线程 A 是否还存活、是否还在临界区如果 A 已经退出临界区把 Mark Word 改成无锁状态或者直接偏向新的竞争线程如果 A 还在临界区就把锁升级成轻量级锁B 再通过自旋去抢恢复所有暂停线程你会发现“偏向锁的撤销”其实不是免费午餐。如果锁频繁地在多个线程间切换撤销偏向锁的成本会非常高。JVM 为此设计了“批量重偏向”和“批量撤销”机制这个后面展开讲。3.2 轻量级锁靠自旋避免立刻阻塞当偏向锁被撤销后如果锁对象已经处于无锁状态且还有多线程竞争请求锁通常会升级为轻量级锁。轻量级锁的核心思路非常有意思它不立刻把线程阻塞掉而是让抢不到锁的线程在用户态自旋也就是空转 CPU 循环尝试直到获取锁。为什么敢这么干因为多数锁竞争只持续很短的时间线程自旋等待比你把它挂起再唤醒的上下切换成本更低。获取逻辑是线程 B 在自己的栈帧里创建一个“锁记录”Lock Record把 Mark Word 的当前值复制到这个锁记录里用 CAS 尝试把对象头 Mark Word 改成指向自己锁记录的指针CAS 成功B 获得锁CAS 失败说明锁已经被别人占了B 进入自旋这里还要区分一下可重入的情况。如果 B 本来就是持锁线程它再次进入同一个synchronized块CAS 也会失败但 JVM 发现 Mark Word 里指向的锁记录已经属于自己栈了不会让它去自旋而是直接在新栈帧里再存一个“空”锁记录表示重入计数加一。自旋不可能无限转下去。若竞争一直很激烈长时间抢不到锁就会触发锁膨胀升级为重量级锁。现代 JVM 的自旋次数是自适应的跑得越久越“聪明”上一次自旋成功拿到锁下次多转几圈上一次自旋没成功下次就减少自旋次数竞争太猛就干脆不转直接膨胀到重量级锁3.3 重量级锁Monitor 加内核互斥量轻量级锁自旋失败的线程会把锁升级为重量级锁。这时 Mark Word 里存储的变成指向 Monitor 对象的指针而 Monitor 底层的加锁/阻塞操作依赖的是操作系统提供的 Mutex 互斥量。重量级锁意味着抢不到锁的线程真的会被挂起阻塞在操作系统内核态。一旦被唤醒又要从内核态切换回用户态。所以重量级锁的获取和释放成本是三种锁里最高的。Monitor 的结构有几块核心Owner当前持有锁的线程EntryList在同步队列里等待抢锁的线程WaitSet被wait()挂起的线程队列等notify()唤醒后回到 EntryList从此刻开始synchronized的语义才真正和“等待/通知机制”绑定起来。换句话说对象如果调用了wait/notify它就必须进入重量级锁状态因为只有 Monitor 才能维护等待队列。重量级锁也不是一无是处。在竞争极度激烈的场景线程老老实实排队比一堆线程在 CPU 上自旋空转要节省资源。它牺牲了响应速度换来了整体吞吐的稳定。4. 完整升级链路和两个关键细节4.1 从偏向锁到轻量级锁的完整流转把流程串起来看大概是这个样子无锁 → 偏向锁线程 A 第一次进入同步块CAS 将自身 ID 写入 Mark Word成功进入临界区偏向锁 → 无锁/轻量级锁线程 B 尝试获取锁发现偏向线程不是自己JVM 执行偏向锁撤销撤销后若 A 已退出Mark Word 恢复无锁若 A 仍在临界区锁升级为轻量级锁轻量级锁 → 重量级锁B 在自旋一定次数后仍抢不到锁膨胀Mark Word 改为指向 Monitor后续竞争线程全部走阻塞唤醒典型的重入场景下同一线程二次进入同步块时锁不会一路升级到重量级而是原地加一个锁记录计数。这也是很多人误以为“多重入就会升级”的错误认知。4.2 批量重偏向和批量撤销JVM 的自我保护前面说偏向锁撤销成本很高。如果一段代码频繁被不同线程轮流进入每换一个线程就要撤销一次偏向锁那 JVM 的性能会很难看。HotSpot 为此设计了两个重要参数-XX:BiasedLockingBulkRebiasThreshold默认值 20-XX:BiasedLockingBulkRevokeThreshold默认值 40当同一个类的大量实例单次撤销偏向锁次数达到 20JVM 会触发“批量重偏向”。它会认为偏向锁在这种多线程轮换场景里还是有价值的只不过需要换个偏向对象。于是 JVM 会对这些实例重新计算 Epoch并允许偏向当前竞争的线程。当撤销次数达到 40JVM 会触发“批量撤销”。这回它认为偏向锁已经不适合这个类了会直接撤销所有该类的偏向锁后续新建的对象默认也是不可偏向的。这相当于一种“信用破产”机制避免 JVM 在无意义的偏向撤销上浪费资源。这两个阈值完全可以通过 JVM 参数调整但在生产环境很少去动因为默认值经过大量压测适合大多数场景。4.3 HashCode 和 wait/notify 带来的状态影响这节是很多人容易忽略的。情况一调用 identityHashCode无锁状态的 Mark Word 前 31 位存的是 HashCode而偏向锁状态根本没有地方存 HashCode。所以一旦对象被调用过System.identityHashCode()或者默认hashCode()未重写的那套对象就无法进入偏向锁状态或者已经从偏向锁状态被“挤”了出去。如果你的程序在同步块里用了对象的 HashCode并且 JVM 还开着偏向锁你很可能会观察到“每次进入同步块都走撤销流程”的诡异现象性能不升反降。情况二调用 wait / notifywait/notify这套机制强依赖 Monitor 的 WaitSet所以只要对象进入过wait()它必然已经是重量级锁。轻量级锁状态下调用wait会立刻触发锁膨胀。这也是锁升级链路中的一个硬性分支。5. 实操复现亲手把锁升级“逼”出来5.1 一个能跑的观察代码要验证锁升级最直接的办法是准备一段代码分别用单线程和多线程去执行同一个synchronized方法然后对比耗时并配合 JVM 参数观察行为。下面是我在模拟环境里常用的 Demopublic class LockUpgradeDemo { private static final Object LOCK new Object(); private static int counter 0; public static void main(String[] args) throws InterruptedException { // 偏向锁默认有4秒启动延迟先睡过它 Thread.sleep(6000); Runnable task () - { for (int i 0; i 1_000_000; i) { synchronized (LOCK) { counter; } } }; // 阶段一单线程执行大概率走偏向锁 long start System.nanoTime(); task.run(); System.out.println(单线程耗时(ms): (System.nanoTime() - start) / 1_000_000); // 阶段二两个线程竞争大概率升级为轻量级/重量级锁 Thread t1 new Thread(task); Thread t2 new Thread(task); start System.nanoTime(); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(双线程竞争耗时(ms): (System.nanoTime() - start) / 1_000_000); } }实测下来单线程那一段通常会快很多。因为偏向锁让同一个线程反复进入临界区时几乎零开销而双线程竞争后偏向锁被撤销锁很可能一路膨胀线程之间不断切换、自旋、阻塞耗时会成倍增加。需要注意的是这个 Demo 不能保证在每台机器上都复现出相同的竞争梯度。但配合下面几个 JVM 参数你就能明显看到差异。5.2 关键 JVM 参数和观察手段想验证偏向锁的作用最经典的一组对比是# 开启偏向锁并把启动延迟设置为0让 JVM 启动后立刻启用 java -XX:UseBiasedLocking -XX:BiasedLockingStartupDelay0 LockUpgradeDemo # 关闭偏向锁让 synchronized 直接走轻量级锁 java -XX:-UseBiasedLocking LockUpgradeDemo对比两次运行结果关闭偏向锁后单线程阶段耗时通常明显上升因为每次进入同步块都多了一次 CAS 操作。可用的参数我都整理进了这个表JVM 参数默认值作用-XX:UseBiasedLockingJDK 15 前开启是否启用偏向锁-XX:BiasedLockingStartupDelay4000 ms偏向锁启动延迟避免 JVM 刚启动时频繁撤销-XX:BiasedLockingBulkRebiasThreshold20触发批量重偏向的撤销次数-XX:BiasedLockingBulkRevokeThreshold40触发批量撤销的撤销次数-XX:PreBlockSpin10自旋次数旧参数新版本为自适应5.3 我踩过的坑对象 HashCode 破坏了偏向锁有一次我在做锁竞争优化时发现业务代码里有一段逻辑先调用了System.identityHashCode(obj)随后又把obj当作锁对象。当时怎么调优都没效果单线程进同步块也一样慢。后来排查才发现identityHashCode一旦调用Mark Word 里已经写入了 HashCode对象后面对锁的状态通道已经被占用JVM 根本没法让它进入偏向锁状态。这个对象等于一开始就被架在“无法偏向”的境地每次加锁只能走轻量级锁逻辑。所以要记住一个实战原则如果你希望借偏向锁优化锁对象在进入同步块之前绝不能调用hashCode()/identityHashCode()也不能是用toString()直接包装出来的特殊锁对象。这也是很多锁升级分析文章里一笔带过但线上真实存在的坑。还有一类坑是Thread.sleep和偏向锁启动延迟。JVM 刚启动前几秒偏向锁默认还没有启用如果你压测脚本很早就把临界区代码跑完了很可能测出来的根本不是偏向锁的效果。这也是我建议 Demo 里的循环次数和初始 delay 都拉长的原因。6. 生产环境避坑与选型建议6.1 常见问题与排查速查表我把自己实际排查锁问题时的经验整理成一个速查表遇到类似情况可以直接对照着看现象可能原因处理方式锁竞争线程 CPU 飙升自旋时间过长观察锁持有时间考虑ReentrantLock或降低自旋单线程同步块也很慢对象不可偏向每步都走 CAS检查 HashCode 调用检查 JVM 是否关闭偏向锁wait之后性能骤降调用wait/notify强制重量级锁重新设计等待通知逻辑减少阻塞范围JDK 15 锁竞争行为变化偏向锁默认禁用明确以轻量级锁为基础进行性能评估锁对象被多个线程频繁轮换偏向锁频繁撤销可能触发批量撤销改用无锁结构或ReentrantLock6.2 synchronized 和 java.util.concurrent 锁怎么选很多人问既然synchronized有锁升级机制了是不是无脑用它就够了我的看法是绝大多数场景用它确实没问题而且 JVM 会替你兜底。但有些情况显式锁更合适需要超时中断ReentrantLock.tryLock(timeout)更灵活synchronized做不到主动超时需要公平锁显式锁可以指定公平策略synchronized依赖 Monitor 的非公平队列读多写少ReentrantReadWriteLock或StampedLock提供了更好的读并发度需要多个条件队列显式锁的Condition比wait/notify更精细但显式锁不是银弹。ReentrantLock默认没有锁升级机制它的 CAS 与阻塞本身也有开销。并发量不大、临界区很短时synchronized反而是最省心的选择。6.3 JDK 15 之后偏向锁被“打入冷宫”如果你用的是 JDK 15 及以上的版本要注意一个趋势JVM 从 JDK 15 开始默认禁用了偏向锁JEP 374后续版本把它标记为废弃。原因很简单现代服务器 CPU 的 CAS 和内存屏障开销已经大幅下降偏向锁节省的那点成本越来越不明显反而它的撤销逻辑和复杂度成为负担。所以现在讨论锁升级偏向锁更像一个“历史经典设计”。你理解了它能读懂老版本 JVM 的性能分析也能明白为什么新版本并发代码的运行轨迹和旧版本文章描述的不太一样。真正在 JDK 17、JDK 21 环境下锁升级的核心路径其实更简单无锁 → 轻量级锁 → 重量级锁。我在实际项目里也观察到了这种差异。同一个压测程序在 JDK 8 上跑偏向锁带来的收益肉眼可见迁移到高版本 JDK 后整体行为反而更依赖轻量级锁和自适应自旋的表现。做性能评估时建议先把基准跑在新 JDK 下再决定是否需要从代码层面替换并发工具。锁升级这套设计本质上就是“能省则省、动态降级到够用为止”。亲自写完这些验证代码后我对synchronized的判断已经不再停留在语法层面而是能直接推断某段代码在底层走了哪条路径、会出现什么瓶颈。这份理解比记住一堆锁名词有用得多。