无竞争,换锁后 RT 飙了 15 倍——ReentrantLock 的 JIT 内联陷阱
场景:8 线程业务服务,接口 RT ~50ms,锁竞争几乎为零。因上游超时需加
tryLock(1s)降级,把synchronized换成ReentrantLock——上线后 RT 飙到 800ms+。 路径:现象(jstack 发现线程 WAITING)→ 还原(JDK8/17 双版本 Benchmark)→ 路径(排查决策树)→ 解读(synchronized vs Lock JIT 内联差异 + JDK 锁演变)→ 标记(代码审查与选型对照表)
上篇讲了线程池没满、JVM 先 OOM——Executors 的无界队列陷阱。这篇我们讲另一个"改一行代码 RT 涨 10 倍"的故事。
8 个线程,接口 RT 50ms,老年代使用率正常,Full GC 刚刚跑过。你说锁竞争?基本为零——synchronized 用了两年从不阻塞。但今天上线一个"优化":把 synchronized 换成了 ReentrantLock,因为需要 tryLock(1s) 做超时降级。上线 15 分钟后监控弹 RT 报警——50ms 变成了 800ms。不是死锁,不是流量突增,是 JIT 放弃了一串它没法内联的方法调用。
你的第一反应可能是搜 ReentrantLock 和 synchronized 哪个更快——Stack Overflow 上有人会告诉你"大多数情况下两者差不多,除非高竞争"。但这里的现象是:没有竞争,换了锁却慢了 15 倍。说明你搜到的答案漏了一个关键维度:JIT 编译器看不看得懂你的锁。
现象:锁没竞争,线程却在排队等锁
异常现场
| 时间 | 指标 | 变化 |
|---|---|---|
| 14:00 | 接口 P99 RT | 52ms |
| 14:12 | 代码上线 | synchronized → ReentrantLock |
| 14:15 | 接口 RT 开始上升 | 122ms |
| 14:18 | P99 RT 达到峰值 | 812ms |
| 14:20 | 回滚 synchronized | RT 回到 54ms |
CPU 没满(~35%),Old 区使用率正常,线程池没积压——不是资源耗尽。直觉说:锁竞争 = 慢。但这里锁竞争几乎为零,为什么换了锁反而慢了 15 倍?
jstack 现场

dump 显示 8 个线程里 6 个在 WAITING (parking),全部停在 LockSupport.park()。但锁真的竞争吗?jstack -l 看锁持有人——只有一个线程持有锁。
其他线程没被阻塞——它们每个都在 lock() 调用中做了一次完整的 acquire → park。即使锁是空闲的,每次请求都走完了 AQS 入队-等待-唤醒的完整流程。
为什么 synchronized 没这个现象?
换锁前用的 synchronized,同样的并发量(8 线程,低竞争),从来没出现过 WAITING 堆积。jstack 看 synchronized 场景长这样:
"http-thread-1" #12 prio=5 os_prio=0 cpu=98.12ms
java.lang.Thread.State: RUNNABLE
at com.example.OrderHandler.process(OrderHandler.java:88)
...
没有 BLOCKED,没有 WAITING。线程在 RUNNABLE 状态下就完成了临界区执行。不是说 synchronized 不阻塞——是它大部分时候根本不需要阻塞。
还原:最小 Benchmark + JDK 双版本对比
测试代码

条件:8 线程,100 万次/线程,无竞争(counter 写操作 < 1ns),JDK 8 / JDK 17 各跑一遍。


| JDK | synchronized | ReentrantLock | 倍数 |
|---|---|---|---|
| JDK 8 | 2,150 ops/ms | 142 ops/ms | synchronized 快 15x |
| JDK 17 | 1,880 ops/ms | 1,210 ops/ms | synchronized 快 1.5x |
关键发现
JDK 8 下差距 15 倍,JDK 17 下缩小到 1.5 倍。同样代码,你的 JDK 版本决定了这篇文章读完之后你选谁。
根因:JDK 8 的 synchronized 有偏向锁,无竞争时直接 CAS 到线程 ID,不再进入锁膨胀路径。JDK 15+ 默认关闭了偏向锁(-XX:-UseBiasedLocking),差距显著缩小。
但注意——1.5 倍差距在 JDK 17 下仍然存在。说明偏向锁不是唯一的差异点。真正的差异在 JIT 内联能力。
路径:排查决策树

RT 飙升 + CPU 未满
→ jstack 看线程状态
→ 大量 WAITING (parking) @ LockSupport.park()
→ 锁类型判断:ReentrantLock?
→ 读代码确认是 ReentrantLock.lock()
→ 回退 synchronized 验证是否回归
→ 确认回归 → JDK 版本环境检查
具体命令:
# Step 1:看线程状态分布
jstack <pid> | grep 'java.lang.Thread.State' | sort | uniq -c | sort -rn
# Step 2:看 WAITING 线程的堆栈
jstack <pid> | grep -A 20 'WAITING (parking)' | head -80
# Step 3:看锁持有人
jstack -l <pid> | grep 'Locked ownable synchronizers' -A 5
# Step 4:回退验证(改一行,重启)
命令输出示例:验证锁选型问题
# 快速统计 WAITING vs RUNNABLE vs BLOCKED 分布
jstack <pid> | awk '/State/{state=$NF; count[state]++} END{for(s in count) print s, count[s]}'
实际输出:
WAITING 6
RUNNABLE 2
关键信号:6 个 WAITING + 2 个 RUNNABLE + CPU 未满。如果这是锁竞争,你应该看到大量 BLOCKED 或者 CPU 飙高。这里两者都不是——说明问题不在"大家都在等同一把锁",而是"每个人拿到锁之前都走了一遍 AQS 全流程"。
# 确认锁持有人
jstack -l <pid> | grep -A 5 'Locked ownable synchronizers'
实际输出:
"http-thread-3" Locked ownable synchronizers:
- <0x00000007c0068d38> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
只有一个锁持有人——竞争确实为零。此时可以 90% 确认是锁实现本身的开销问题。
验证方法:改一行代码把 ReentrantLock 换回 synchronized,重启,观察 RT。如果回落到原值——根因锁定。
jstack 不是一个错误日志——它是你的线程在那一秒的现场照片。WAITING (parking) 堆栈里有每个线程的精确调用路径。
解读:为什么 synchronized 在无竞争时比 ReentrantLock 快
图 1:JIT 视角的调用链对比

synchronized 的 JIT 路径:
synchronized (this)
→ monitorenter (JVM intrinsic)
→ [偏向锁] CAS 写线程 ID → 成功 → 执行临界区 (3-5 CPU 周期)
→ monitorexit
整条路径被 JIT 内联为 ~5 条机器指令。因为 monitorenter/monitorexit 是 JVM 操作码,JIT 直接处理,不存在方法调用开销。
ReentrantLock.lock() 的 JIT 路径:
ReentrantLock.lock()
→ NonfairSync.lock() // 方法调用
→ compareAndSetState(0, 1) // JVM intrinsic,可内联
→ AQS.acquire(1) // 方法调用——335 字节,不内联
→ tryAcquire(1) // 方法调用
→ nonfairTryAcquire(1) // CAS 尝试
→ acquireQueued(..) // 方法调用——368 字节,不内联
→ shouldParkAfterFailedAcquire(..)
→ parkAndCheckInterrupt()
→ LockSupport.park() // 方法调用
→ Unsafe.park(true, 0L) // 最终调用
JIT 内联阈值默认 -XX:MaxInlineLevel=9、-XX:InlineSmallCode=4000,但单个方法超过 325 字节(-XX:MaxInlineSize)就不再内联。AQS.acquire() 和 acquireQueued() 各自超过 300 字节——JIT 在它们面前放弃。
等一下,再读一遍上面的调用链。
synchronized 的路径:3 步,JIT 内联为 ~5 条指令,CPU 周期数 = 个位数。
ReentrantLock 的路径:8 步,越过 JIT 内联阈值 325 字节,CPU 周期数 = 百位数。
这就是那 15 倍差距的根源。不是锁竞争——是 JIT 内联放弃。没有锁竞争的时候,synchronized 几乎不走锁逻辑,它走的是一条 JIT 优化到极致的路径。而 ReentrantLock 不管有没有竞争,都走完了 AQS 的完整骨架。
反直觉的时刻来了:工程师认为"无竞争 = 随便用什么锁都差不多"。但真实情况是——无竞争恰恰是 synchronized 的优势区间最大、ReentrantLock 的劣势区间也最大的时候。有竞争时两者的差距反倒缩小了。
synchronized 不只是锁——它是 JIT 能内联的 JVM 原语。ReentrantLock 也不只是锁——它是一个超过 325 字节、JIT 放弃内联的 AQS 调用链。
图 2:JDK 锁演变时间线

| JDK | 变化 | 对 synchronized 影响 |
|---|---|---|
| JDK 6 | 偏向锁引入 + 锁消除/锁粗化 | 无竞争 ≈ CAS 开销,3-5 CPU 周期 |
| JDK 8 | 偏向锁默认开启 | 最佳选择,无竞争下比 ReentrantLock 快 10-15x |
| JDK 15 | 偏向锁默认关闭(JEP 374) | 差距缩小到 ~1.5x |
| JDK 21 | 虚拟线程 + scope-local | 锁选型需要重新评估 |
重要:JDK 15+ 关闭偏向锁的原因是偏向锁在高并发撤销时需要 STW 安全点,代价大于收益(JEP 374 白皮书有详细数据)。这个决策本身也在告诉你:synchronized 的代价在变高,但 ReentrantLock 的 AQS 调用链开销始终存在。
实战决策指南
| 场景 | 用 | 因为 |
|---|---|---|
| 临界区极短 + 低竞争 + JDK ≤11 | synchronized |
偏向锁 + JIT 内联 ≈ 5 条指令 |
| 临界区极短 + 低竞争 + JDK 17+ | synchronized |
仍然比 ReentrantLock 快 1.5x |
| 需要 tryLock() / lockInterruptibly() | ReentrantLock |
synchronized 不支持 |
| 需要公平锁 | ReentrantLock(true) |
synchronized 不支持公平性 |
| 需要 Condition 条件等待 | ReentrantLock.newCondition() |
synchronized 只能用 wait/notify |
| 读多写少 | ReentrantReadWriteLock / StampedLock |
读读不互斥 |
| 高性能读多写少(JDK 8+) | StampedLock |
乐观读完全无锁 |
核心原则:你不是因为 synchronized 慢才换 ReentrantLock——是因为你需要 synchronized 没有的特性才换。 换锁要付出 JIT 内联断裂的代价,这个代价在你用 tryLock() 或 newCondition() 时才值得。
现在你知道了:每次 new ReentrantLock() + 只调 lock()/unlock(),你就在为不需要的特性付出 15 倍的 JIT 代价。翻翻项目里的代码:
标记:在你的项目中搜 ReentrantLock 误用
# 搜索 ReentrantLock 使用
grep -rn 'new ReentrantLock' src/ --include='*.java'
# 搜索 synchronized 到 ReentrantLock 的迁移
grep -rn 'ReentrantLock' src/ --include='*.java'
代码审查对照表
| 代码 | 问题 | 建议 |
|---|---|---|
new ReentrantLock() + 只调 lock()/unlock() |
浪费——不需要 ReentrantLock 特性但付出了开销 | 改用 synchronized |
synchronized + 需要超时/可中断 |
合理——就是写起来麻烦 | 保持 ReentrantLock |
ReentrantLock(true) 公平锁 + 低竞争 |
公平锁性能比非公平差 10x | 确认你真的需要公平性 |
无竞争场景用 Lock 接口注入 |
JIT 无法去虚拟化 → 又一层 indirection | 用 synchronized 或具体类型 |

并发问题的本质不是代码错了——是代码的执行路径在你的脑子里和 JVM 里不一样。你以为换了一个锁只是换了一个实现,但 JIT 编译器看到的是一整条它无法内联的调用链。
今天下班前做一件事:git grep 'new ReentrantLock' 你的项目。数数结果。对每一个,问自己:我真的需要 tryLock() 吗?
如果你遇到代码审查看到 synchronized → ReentrantLock 的迁移,打开这篇给他看。不用说话。
还有一个更隐蔽的:Lock lock = new ReentrantLock(); — 很多老旧项目为了"以后可以换成读写锁"用接口注入,但十年没换过。这个接口引用让 JIT 的 devirtualization 又加了一层开销,比直接用 ReentrantLock 还慢。
上篇讲了线程池没满 JVM 先 OOM,这篇说了锁选型背上 JIT 内联债,下篇我们聊读写锁 ReentrantReadWriteLock 锁降级导致死锁——三把锁两个线程,看着没问号,一到线上就卡死。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」