JVM 删掉偏向锁之后,一次无竞争锁从 0.3 纳秒变成 5.4 纳秒

先看两个数。

JDK 11,开着偏向锁,一个单线程里反复进入 synchronized 方法、给计数器加一,平均 0.29 纳秒一次。JDK 21,同样的代码,5.42 纳秒。

快了二十年、慢回去十八倍。中间发生的事,是 HotSpot 把偏向锁整个删掉了。

发生了什么:JEP 374 与一次没有 JEP 的删除

时间线不长,但容易记混。

版本 发生了什么 依据
Java SE 6 偏向锁默认开启 Oracle Java SE 6 性能白皮书
JDK 15 默认关闭,相关参数标记废弃 JEP 374
JDK 18 代码删除,参数失效 JDK-8256425
JDK 19 参数计划彻底过期 hotspot-dev 讨论
JDK 21 -XX:+UseBiasedLocking 被当成未知参数拒绝 实测

注意第三行和第四行:删除本身没有单独的 JEP。JEP 374 只做到「废弃并禁用」,真正把代码拿掉是一个普通变更单,编号 JDK-8256425。所以你在网上会看到「偏向锁是 JDK 15 删的」和「JDK 18 删的」两种说法,都不算错,说的是两件事。

作者在一台机器上装了 JDK 11、17、21、25,跑两个微基准:一个在 synchronized 方法里自增,一个往 StringBuffer 里 append(StringBuffer 的每个方法都是同步的)。JDK 11 和 17 上分别开关偏向锁,21 和 25 没得选。

环境 计数器(纳秒/次) StringBuffer(纳秒/次)
JDK 11,偏向锁开(默认) 0.29 1.7
JDK 11,偏向锁关 5.5 22.2
JDK 17,偏向锁开(需手动加参数) 0.24 1.9
JDK 17,默认(关) 5.4 21.9
JDK 21 5.4 22.0
JDK 25 5.4 21.4

StringBuffer 那一列更刺眼。偏向锁开着的时候,每个方法都同步的 StringBuffer,和完全不同步的 StringBuilder(JDK 25 上 1.65 纳秒)几乎一样快。关掉之后,同一段代码慢十三倍。

机制:偏向锁到底在省什么,删掉又省了什么

要理解这件事,得先知道 Java 的一个对象在内存里长什么样。

每个对象头部有一块叫 Mark Word 的区域,存哈希码、GC 分代年龄、锁状态。HotSpot 用最低两位标记锁状态:未锁定、轻量级锁、重量级锁、GC 标记。所谓加锁,第一步是把 Mark Word 换成一个指向锁记录的指针,这一步用 CAS(比较并交换)完成,是一条原子指令。

原子指令在 2006 年很贵。Dave Dice 当年那份 Java SE 6 同步机制的幻灯片里,把原子 CAS 的高延迟列为无竞争锁的主要成本。问题是绝大多数锁根本没人抢:一个线程锁住一个对象,用完放开,再锁,循环几百万次,从头到尾没有第二个线程出现。每次都付一次原子指令的钱,纯属浪费。

那个年代的硬件背景得说清楚。多核处理器刚普及,缓存一致性协议在核数上去之后开销陡增,一条 lock 前缀指令可能需要几十个时钟周期。同一时期 Java 企业应用里满地都是 Hashtable 和 Vector,每个 get、put 都同步一次,而这些对象在绝大多数代码里只有一个线程在用。偏向锁就是冲着这个场景去的,投入产出比在当时极高。

偏向锁的思路是让锁记住主人。第一次加锁时,JVM 把当前线程的 ID 写进 Mark Word,把偏向位置 1。之后同一个线程再进来,只要比较一下线程 ID 就放行,那条 CAS 直接跳过。JVM 在赌「这个线程会回来」。

赌输的代价不小。第二个线程来抢,JVM 必须撤销偏向,而撤销需要在安全点(safepoint)上做,要暂停线程、检查栈帧里还有没有这个对象的锁记录。JEP 374 的原话是撤销「昂贵」。Oracle 从 JRockit 迁到 HotSpot 的指南里写得更直白:竞争激烈的应用把偏向锁关掉可能更快。

那为什么还要删?JDK-8231265 给了三条理由。一是原子指令在新处理器上便宜了;二是真正受益的是 Hashtable、Vector 这类每个方法都同步的老类,新代码不怎么用;三是偏向锁的代码复杂度高,散落在 HotSpot 好几个模块里,维护成本压不住。

第三条值得多说一句。偏向锁不是一个独立的开关,它和对象头布局、栈上锁记录、安全点机制、JIT 的锁消除与锁粗化都缠在一起。删掉它,等于给整个运行时减一层特判。

要看清它在整个体系里的位置,得把四种锁状态排一遍。

无锁时,Mark Word 存哈希码和分代年龄。偏向锁把线程 ID 写进去,第二次进入只做比较。轻量级锁换了个做法:把 Mark Word 复制到当前线程栈上的 Lock Record,再用 CAS 让对象头指向这个 Lock Record;如果 CAS 失败,说明有人抢,升级。重量级锁把请求交给 ObjectMonitor,线程进等待队列,由操作系统调度挂起。

偏向锁省掉的是「第一次之后的所有 CAS」,轻量级锁省掉的是「进内核」,重量级锁省掉的是「自旋等待」。三者解决的是不同层面的问题,所以删掉偏向锁并不会让轻量级锁也失效,只是把「同一线程反复进入」这个最常见场景重新打回每次一次 CAS。

当年这套设计还有一层补丁,叫批量重偏向(bulk rebias)。单个对象撤销偏向很贵,但如果一整类对象的偏向都失效了(典型情况是同一批对象换了使用线程),JVM 会用一个 epoch 值把这一批的偏向一次性作废再重新偏,避免逐个撤销。Russell 和 Detlefs 在 2006 年的 OOPSLA 论文里讲的就是这个。这套机制跟着偏向锁一起被删了,现在也没人需要它。

对照:被删的不只是「一个优化」

先看一个反例,说明这个优化的实际份量。

JDK-8329619 是一张与删除相关的缺陷单,里面记录了一个 Spark 压缩相关的微基准在失去偏向锁后大约四倍的回归。四倍听起来吓人,但要看基数:那是微基准,测的是纯锁路径,真实业务里锁的占比通常远低于这个比例。

再看尺度。5 纳秒一次无竞争锁,一百万次是 5 毫秒。如果你的服务每秒处理一万次请求、每次请求进出十几把锁,一天下来多出来的开销也就几百毫秒量级。真正会疼的是循环里高频加锁的老代码,比如用 StringBuffer 在循环里拼字符串。

同一个时期 JVM 还做了别的事,方向是反的:轻量级锁和重量级锁的分层、锁消除(逃逸分析证明对象不出线程就直接不加锁)、锁粗化(把相邻的同步块合并)。这些优化在偏向锁消失之后承担了大部分工作。JDK 21 之后的虚拟线程又带来一个新维度:在 JDK 21 到 23 上,synchronized 块里发生阻塞会把载体线程一起钉住(pinning),这个坑一直到 JDK 24 的 JEP 491 才填上。所以「synchronized 便宜了还是贵了」,答案取决于你问的是哪条路径。

横向比一下别的语言也很有意思。Go 的 sync.Mutex 快路径就是一次 CAS 加一次原子写,没有「记住主人」这种优化,设计上更接近偏向锁被删掉的 JVM。Rust 的 std::sync::Mutex 在 Linux 上落到 futex,无竞争时也是一次原子操作。0.3 纳秒那个数字是个特例,5.4 纳秒才是这类锁的常态成本。JVM 曾经比所有人便宜二十倍,现在回到了同一起跑线。

还有一层是编译器帮你藏起来的。C2 的锁消除会在逃逸分析证明对象不会跑出当前线程时,把整块同步直接删掉。所以有些代码你看着每个方法都 synchronized,实测却完全没有锁开销,因为 JIT 后发现根本不需要。这也解释了为什么很多人在 JDK 21 上跑自己的服务,感觉不到任何变化:热点循环里的锁早就被消掉了,剩下的都在冷路径上。

对谁有影响:三类代码,三种反应

第一类,单线程持有的同步集合。 这是最直接的受害者,也是最容易修的。StringBuffer 换成 StringBuilder,Vector 换成 ArrayList,Hashtable 换成 HashMap;如果确实有并发,用 ConcurrentHashMap 而不是把 Hashtable 包一层 Collections.synchronizedMap。JEP 374 自己就写了:依赖不必要同步的应用,换到不同步的类之后大概率会变快。

第二类,从 JDK 11 或 17 升级上来的服务。 升级前先搜一遍这三个类名。很多项目的依赖树里还挂着老库,它们内部用 Vector 或 StringBuffer,你自己代码干净不代表调用栈干净。

第三类,写基准测试的人。 这条最容易被忽略。如果你在 JDK 11 时代测过一个同步路径的吞吐,拿那个数当基线,升到 21 之后会看到莫名其妙的退化,然后开始怀疑 JIT、怀疑 GC、怀疑容器 CPU 限制。先查清楚当年那次测量是不是吃了偏向锁的红利。

顺带说一句启动脚本。任何还带着 -XX:+UseBiasedLocking 或 -XX:-UseBiasedLocking 的脚本都要清掉,JDK 21 会直接拒绝启动,报「Unrecognized VM option」。这个错误很显眼,反倒是好事;真正麻烦的是那些写在 Dockerfile、Helm chart、systemd unit 深处、平时没人看的参数。

如果你在用虚拟线程,还有一条和锁直接相关的取舍:JDK 21 到 23 上,synchronized 块里一旦阻塞就会把载体线程钉住,吞吐掉得比预期快。这期间能换 ReentrantLock 的地方就换,JDK 24(JEP 491)之后不必再迁就。

边界:这组数字能推到多远

作者自己交代了测量条件:没有用 JMH,单核沙盒,两次运行、每次九轮取中位数。这种测法能看出量级,不能当精确基准。0.29 和 5.42 之间的二十倍差距,方向可信;具体到 5.42 还是 5.1,没意义。

还有三点会让结论走样。

一是只测了无竞争路径。偏向锁在真有竞争的场景下本来就是负资产,撤销成本会吃掉收益。如果你的锁大部分时间都在抢,删掉偏向锁对你可能是好事。

二是原子指令的成本跟 CPU 架构、核数、缓存一致性协议都有关。测试机是一台机器,换一台 EPYC 或者 Apple Silicon,绝对值会变。

三是 JIT 分层编译的介入时间。偏向锁在启动几秒后才激活(HotSpot wiki 有这条),而微基准如果跑在 C1 阶段,测到的可能根本不是 C2 优化后的代码。

最该补的一块是多线程场景。这次只测了单线程反复进入,没有测第二个线程出现时会发生什么。按 JEP 374 的说法,撤销偏向要在安全点上做,成本远高于一次 CAS,所以在有轻度竞争的场景里,偏向锁大概率是净亏。删掉它,对这类负载是改善。文章里那二十倍的差距只描述了天平的一端,另一端现在无从测量------代码已经没了。

接下来看什么

三件可以自己动手验证的事,不需要等谁下结论。

一,在你的代码库和依赖树里 grep StringBuffer、Vector、Hashtable,看有多少是单线程持有的。这个数通常比想象的多。

二,升级到 JDK 21 或 25 之后,用 JMH 把最关键的那条同步路径重测一遍,带上 -prof perfasm 看热点是不是落在锁指令上。

三,跑 java -XX:+PrintFlagsFinal -version | grep -i biased。如果什么都没有输出,说明这个开关在你的 JVM 上已经不存在了------这也是判断「网上的调优建议是不是过期」最快的一招。


来源:

  • Java Code Geeks,《Why synchronized Got Fast: The Rise and Removal of Biased Locking》,2026-11-04,www.javacodegeeks.com/2026/11/why...
  • OpenJDK,《JEP 374: Deprecate and Disable Biased Locking》,openjdk.org/jeps/374
  • OpenJDK 缺陷单 JDK-8256425(JDK 18 移除偏向锁代码)、JDK-8231265(移除理由)、JDK-8329619(Spark 压缩基准回归)
  • Oracle,《Java SE 6 Performance White Paper》,www.oracle.com/java/techno...
  • Russell & Detlefs,《Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing》,OOPSLA 2006
相关推荐
IT枫斗者枫哥29 分钟前
用了BigDecimal还差一分?用四组Java代码查清原因
java·后端
一条泥憨鱼1 小时前
【消息队列】ACK确认了什么:消息队列可靠投递的完整链路
java·后端·kafka·消息队列·rabbitmq
从琳开始9892 小时前
网络原理入门(二)------OSI七层模型?TCP/IP五层(四层)模型原来是这样的
java·网络·tcp/ip·java-ee·php
文丑颜不良啊2 小时前
LeetCode1861~1880题目解析
java·leetcode
Flynn Liang2 小时前
AgentScope Java 深度解析:ReActAgent、HarnessAgent 与多智能体编排——JVM 上的自治 Agent 底座
java·大模型·架构设计·ai agent·spring ai·langchain4j·agentscope
Flynn Liang2 小时前
上线后怎么排查 AI 调用?Spring AI Observability:token/延迟/成本埋点与全链路追踪
java·人工智能·spring·监控·可观测性·observability·springai
文丑颜不良啊2 小时前
LeetCode1921~1940题目解析
java·leetcode
Tt有了t3 小时前
设计模式速查
java·python·设计模式
Thomas.Sir3 小时前
第15课:SpringBoot连接池详解:HikariCP配置、调优、监控,4.x连接池新特性
java·spring boot