三个词,一段历史
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 的做法分三步:
- 逃逸分析(escape analysis):判断对象是否可能被方法外的代码看到。
sb只在本方法内使用,不赋值给字段、不作为返回值、不传给可能保存它的方法——判定为「不逃逸」。 - 标量替换(scalar replacement):既然对象不逃逸,就不必真的在堆上分配,把它的字段拆成局部变量放在栈/寄存器上。
- 锁消除(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 的正确姿势
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。