无竞争,换锁后 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 橙色标注

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 双版本对比

测试代码

LockBenchmark.java

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

JDK 8 Benchmark 结果

JDK 17 Benchmark 结果

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 vs ReentrantLock 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 锁演变时间线

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() 吗?

如果你遇到代码审查看到 synchronizedReentrantLock 的迁移,打开这篇给他看。不用说话。

还有一个更隐蔽的:Lock lock = new ReentrantLock(); — 很多老旧项目为了"以后可以换成读写锁"用接口注入,但十年没换过。这个接口引用让 JIT 的 devirtualization 又加了一层开销,比直接用 ReentrantLock 还慢。


上篇讲了线程池没满 JVM 先 OOM,这篇说了锁选型背上 JIT 内联债,下篇我们聊读写锁 ReentrantReadWriteLock 锁降级导致死锁——三把锁两个线程,看着没问号,一到线上就卡死。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」