《Java 并发编程·线程与内存模型》4.3 锁消除、锁粗化与偏向锁的移除

JIT 对 synchronized 做了哪些手脚:逃逸分析如何让方法内的锁消失、锁粗化如何合并循环里的加锁、偏向锁为什么在 JDK 25 已彻底移除。附 PrintFlagsFinal、UseBiasedLocking 报错与 LockingMode 弃用的本机真实输出。

三个词,一段历史

synchronized 的性能印象在十几年里翻了个面。

JDK 1.4 时代,synchronized 是「慢」的代名词,大家都改用手写 CAS。JDK 5 引入了 java.util.concurrent,ReentrantLock 被推荐为「更快的锁」。JDK 6 之后 HotSpot 连续做了几轮优化——偏向锁、轻量级锁、锁消除、锁粗化、自适应自旋——synchronized 的性能追了上来。JDK 15 开始,偏向锁被默认关闭;JDK 18 之后彻底移除。

这一节讲三件 JIT 做的事:

优化做了什么前提
锁消除把整个 synchronized 删掉锁对象不逃逸
锁粗化把多次加解锁合并成一次相邻临界区锁同一对象
偏向锁单线程重复进入免 CAS无竞争(JDK 25 已移除)

三者都不是 Java 语言规范的一部分,是 HotSpot C2 的实现细节。这意味着:写代码不能依赖它们发生,只能利用它们作为优化。

锁消除的前提:逃逸分析

先看一段看起来有锁、实际可能没有的代码:

public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer();
    sb.append(a);
    sb.append(b);
    return sb.toString();
}

StringBuffer.append 是 synchronized 方法,toString 也是。按字面理解,这个方法要加三次锁。但它其实是线程安全的——sb 是局部变量,方法返回后就不存在了。

C2 的做法分三步:

  1. 逃逸分析(escape analysis):判断对象是否可能被方法外的代码看到。sb 只在本方法内使用,不赋值给字段、不作为返回值、不传给可能保存它的方法——判定为「不逃逸」。
  2. 标量替换(scalar replacement):既然对象不逃逸,就不必真的在堆上分配,把它的字段拆成局部变量放在栈/寄存器上。
  3. 锁消除(lock elision):既然对象退化成一组局部变量,别的线程根本不可能访问,synchronized 就没有意义,直接删掉。

三个开关在 JDK 25 上默认全开。用 -XX:+PrintFlagsFinal 可以看到:

bool DoEscapeAnalysis                         = true                                   {C2 product} {default}
bool EliminateAllocations                     = true                                   {C2 product} {default}
bool EliminateLocks                           = true                                   {C2 product} {default}
bool EliminateNestedLocks                     = true                                   {C2 product} {default}

EliminateAllocations 负责标量替换,EliminateLocks 负责删锁,EliminateNestedLocks 处理嵌套的同一把锁。可以单独关掉验证:

$ java -XX:-EliminateLocks -XX:+PrintFlagsFinal -version | grep EliminateLocks
     bool EliminateLocks                           = false                                  {C2 product} {command line}

注意来源从 {default} 变成了 {command line},这是确认开关真的生效的最简单办法。

逃逸分析的边界

锁消除只在「确定不逃逸」时发生。下面几种写法会让对象逃逸,锁消除失效:

private StringBuffer buffer;          // 逃逸一:赋值给字段

public void spill(String s) {
    buffer = new StringBuffer();
}

public String leak(String s) {
    StringBuffer sb = new StringBuffer();
    register(sb);                     // 逃逸二:传给外部方法
    return sb.toString();
}

public Object returnIt() {
    return new StringBuffer();        // 逃逸三:作为返回值
}

「传给外部方法」要看方法是否内联。register 如果被内联且内部没有保存引用,逃逸分析仍可能判定为不逃逸;没内联就直接放弃。所以锁消除是否发生取决于当时的编译决策,不是稳定的程序性质。

这带来一个实践推论:不能用「反正 JIT 会消除锁」来为写了多余 synchronized 的代码辩护。优化可能因为一次无害的重构就失效。

为什么 product build 上看不到锁消除

想直接观察锁消除是否发生,需要用 -XX:+PrintEliminateLocks。但它是 develop 级开关:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateLocks -version
Error: VM option 'PrintEliminateLocks' is develop and is available only in debug version of VM.
Improperly specified VM option 'PrintEliminateLocks'
Error: Could not create the Java Virtual Machine.

Temurin 是 product 构建,这个开关不存在。要看只有两条路:编译一个 fastdebug 版 JDK,或者用基准间接判断。

间接判断的正确做法是 JMH 对比同一段逻辑在不同逃逸状态下的吞吐,并显式控制开关:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
public class StringConcatBench {

    @Benchmark
    public String inMethod() {
        StringBuffer sb = new StringBuffer();   // 不逃逸,锁可能被消除
        sb.append("a");
        sb.append("b");
        return sb.toString();
    }

    private final StringBuffer shared = new StringBuffer();

    @Benchmark
    public String escaped() {
        shared.setLength(0);                    // 逃逸到字段,锁消除不适用
        shared.append("a");
        shared.append("b");
        return shared.toString();
    }
}

跑三组:默认参数、-XX:-EliminateLocks、-XX:-DoEscapeAnalysis。如果默认参数下 inMethod 显著快于 escaped,而关掉开关后差距缩小,就说明锁消除确实在起作用。

本机当前系统负载很高(load averages 长时间在 50 以上,有其它进程在做基准测试),JMH 的误差会超过 100%,取不到可用数据。所以这一节不贴对比数字——在负载不可控的机器上跑出来的微基准数字比没有数字更危险。读者可以在空闲环境按上面的方法复现。

一个常被误读的 JFR 现象

有人用 JFR 的 jdk.JavaMonitorEnter 排查锁竞争,发现事件数为 0,就认为「没有 synchronized」。这个推断是错的。

我构造了两段代码对比。四个线程各跑 20 万次预热后再录制 2 万次:

static final Object SHARED = new Object();
static long sink;

static void localLock() {
    Object lock = new Object();   // 每个线程各自新建
    synchronized (lock) { sink += 1; }
}

static void sharedLock() {
    synchronized (SHARED) { sink += 1; }
}

用 JFR API 打开 jdk.JavaMonitorEnter 并把阈值设为 0,然后统计事件数:

local:  jdk.JavaMonitorEnter = 1, sink = 382027
shared: jdk.JavaMonitorEnter = 586, sink = 880000

local 复跑一次是 0。这个结果不能解读为「local 的锁被消除了」——因为 local 模式下每个线程锁的是自己新建的对象,本来就没有竞争。没有竞争,锁就不会从轻量级膨胀为重量级,ObjectMonitor 根本没有被创建,事件自然不会出现。

jdk.JavaMonitorEnter 记录的是进入 monitor 的等待,只有锁膨胀到重量级、线程需要排队时才触发。轻量级锁通过 CAS 抢占,不经过 ObjectMonitor,不产生这个事件。

所以正确结论是:

  • 事件数为 0 只能说明「没有发生重量级竞争」,不能说明「没有锁」;
  • 要证明锁存在,得看字节码(monitorenter)或 ACC_SYNCHRONIZED 标志;
  • 要证明锁是瓶颈,得结合 jdk.ThreadPark、线程转储里的 BLOCKED 状态和 CPU 火焰图一起看。

卷三第 3 章会把 JFR 的完整诊断流程展开。

锁粗化

考虑这个循环:

for (int i = 0; i < list.size(); i++) {
    synchronized (lock) {
        result += list.get(i);
    }
}

每次迭代都加锁解锁。如果 lock 没有竞争,每次的加解锁开销都是纯粹的浪费。C2 会把相邻的、锁同一对象的临界区合并成一个:

synchronized (lock) {
    for (int i = 0; i < list.size(); i++) {
        result += list.get(i);
    }
}

语义等价吗?在没有其它线程介入的前提下等价,而且合并后的临界区更大,持有锁的时间更长。如果这段代码运行时有竞争,粗化反而可能增加其它线程的等待。

C2 对此是保守的:粗化主要发生在循环体内、循环次数不可预知、且临界区很短的场景。它不会为了省几次锁而制造长临界区。

粗化对写代码的启示很直接:不要在循环里手动加解锁同一个对象。如果你确实需要在循环中保持互斥,把 synchronized 提到循环外,语义更清楚,也不用指望 JIT:

synchronized (lock) {
    for (Book b : books) {
        result += b.stock();
    }
}

但要注意,临界区变大后里面的操作必须都满足「必须是原子的」这个条件。把 IO 或远程调用包进循环外的锁,就是从「频繁加锁」变成「长时间持锁」,通常更糟。

偏向锁的来与去

偏向锁解决的是一个特定场景:同一个线程反复进入同一把锁。传统轻量级锁每次进入都要 CAS 写对象头,偏向锁把这个开销也省了——第一次 CAS 成功后,把线程 ID 写进 Mark Word,之后同一线程进入时只需比较 ID,不再写内存。

它的代价是撤销(revocation)非常昂贵:一旦有第二个线程来竞争,必须等到安全点(safepoint)才能撤销偏向。这个代价在大量使用并发容器的应用里抵消了收益。

JDK 15 通过 JEP 374 默认禁用偏向锁,之后版本逐步移除。到 JDK 25,相关参数已经不存在:

$ java -XX:+UseBiasedLocking -version
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

注意报的是 Unrecognized VM option,不是 deprecated。说明这个参数已经从代码里删掉了,不是「还能用但会警告」。

所以 4.2 节里那张锁状态表只有三行:无锁、轻量级锁、重量级锁。很多中文资料还在讲「偏向锁 → 轻量级锁 → 重量级锁」的三段式升级,那是 JDK 15 之前的情况。在 JDK 25 上这个模型是错的。

偏向锁移除后,单线程重复加锁的代价增加了吗?增加了,但幅度很小:轻量级锁的 CAS 在无竞争时命中缓存行,开销是纳秒级。HotSpot 团队在 JEP 374 里的评估结论是,绝大多数应用的吞吐没有可测量变化,而撤销偏向带来的停顿消失了。

LockingMode:JDK 24 引入又弃用的开关

JDK 24 的 JEP 491 重写了 monitor 的实现,目的是让 synchronized 不再固定(pin)虚拟线程——原来的 ObjectMonitor 是 JVM 内部结构,阻塞在它上面时载体线程无法被卸载。

为了让旧实现还能回退,JDK 24 引入了 -XX:LockingMode:

$ java -XX:LockingMode=3 -version
OpenJDK 64-Bit Server VM warning: Option LockingMode was deprecated in version 24.0 and will likely be removed in a future release.
int LockingMode=3 is outside the allowed range [ 0 ... 2 ]
Improperly specified VM option 'LockingMode=3'

两个信息:取值范围是 0 到 2,且这个参数在 JDK 24 就被标记为弃用。默认值在 JDK 25 上是 2:

int LockingMode                              = 2                                         {product} {default}

这个开关的细节和虚拟线程 pinning 的关系,第 14 章会展开。这里只需要记住:JDK 25 的 synchronized 实现和 JDK 21 已经不同,依赖旧 monitor 行为的性能调优经验需要重新验证。

这些优化对写代码意味着什么

四条实践规则:

一、不要靠锁消除来「写好性能代码」。 写了 synchronized 就按有锁来推理正确性;锁消除只是可能发生的优化,不是可依赖的语义。

二、不要在循环里频繁加解锁。 如果临界区确实需要跨迭代保持,提到循环外;如果不需要,那就是设计问题,应该改成一次性计算。

三、不要为了「避免锁开销」而放弃正确性。 把 synchronized 换成 volatile 来「优化」计数、check-then-act,是本卷反复出现的错误模式,4.1 节已经用实测数据展示过后果。

四、性能问题必须先测量。 锁消除是否发生、粗化是否生效、竞争是否真实存在,都取决于运行时的编译决策和负载形态。猜和推理只能给出假设。

在 libraryd-conc 里落地

SynchronizedLoanService 的锁不会被消除。原因很直接:

public final class SynchronizedLoanService implements LoanService {
    private final BookRepository books;

    @Override
    public synchronized Reservation reserve(String isbn, String memberId) {
        // this 逃逸:服务对象被注入到调用方,一直存活
    }
}

this 是长期存活、被多个线程共享的对象,逃逸分析必然判定它逃逸。这里的 synchronized 是真实开销,也是真实保护。

要评估它的成本,正确做法不是算「一次加锁多少纳秒」,而是测「在目标并发下,有多少请求在等锁」。用 JFR:

java -XX:StartFlightRecording=filename=loan.jfr,settings=profile \
     -cp conc-lab com.plume.conc.lab.LoanLoadTest
jfr print --events jdk.JavaMonitorEnter loan.jfr
jfr print --events jdk.ThreadPark loan.jfr

如果 jdk.JavaMonitorEnter 的 duration 分布里出现了毫秒级的长尾,说明锁是瓶颈;如果全是几十微秒,那锁不是主要问题,该去看 IO 或 GC。

这个判断方法比任何微基准都可靠,因为它测的是真实负载下的真实等待。

小结

  • 锁消除依赖逃逸分析,对象不逃逸时 synchronized 会被整体删除;DoEscapeAnalysis、EliminateAllocations、EliminateLocks 在 JDK 25 上默认全开。
  • product 构建没有 PrintEliminateLocks,-XX:+PrintEliminateLocks 会报 develop and is available only in debug version of VM;观察只能靠基准或 debug build。
  • jdk.JavaMonitorEnter 只在锁膨胀为重量级时触发,事件数为 0 不代表没有 synchronized。
  • 锁粗化合并相邻的同对象临界区,会让持锁时间变长;不要在循环里手动加解锁。
  • 偏向锁在 JDK 15 默认禁用、之后移除,JDK 25 上 -XX:+UseBiasedLocking 报 Unrecognized VM option;三段式锁升级模型已经过时。
  • JDK 24 起 LockingMode 可切换新旧 monitor 实现,但已被标记弃用,取值范围 0–2,默认 2。

下一章进入等待通知机制,看 wait/notify 为什么必须和 synchronized 一起用,以及虚假唤醒该怎么处理。

阅读导航:上一节 4.2 synchronized 的字节码与对象头 | 下一节 5.1 wait/notify 的正确姿势

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收