本文摘要 :开启虚拟线程后吞吐不升反降,5000 并发反而输给固定线程池基线。根因是
synchronized钉住载体线程,改用ReentrantLock即可解开。
一、问题与结论
现象(Spring Boot 3.2.x + JDK 21.0.x,spring.threads.virtual.enabled=true):接口内部经过一个带 synchronized 的内存限流器。JMeter 固定并发 500 时,虚拟线程版本吞吐高于平台线程基线(server.tomcat.threads.max=200 固定池);并发提到 5000,吞吐反而跌到基线之下,P99 拉长一个数量级。
结论:这不是虚拟线程"不划算",而是 synchronized 的钉住(pinning)。JDK 21 中虚拟线程在等 monitor 或持 monitor 阻塞时无法与载体线程解绑;载体来自 ForkJoinPool,并行度约等于 CPU 核数,被占满后后续请求全部排队。把锁换成 ReentrantLock 后,同一压测下钉住消失、吞吐回升(数字见第四节记录表,需在你环境复跑)。
二、排查与选择依据
步骤如下,每步先说目的再给动作。
- 开启钉住跟踪:JVM 参数加
-Djdk.tracePinnedThreads=full(或any),发生钉住时把堆栈打到 stderr。 - 压测打满后抓两份快照:
bash
jcmd <pid> Thread.print -l -e > snap-5000.txt
jstack -e <pid> > jstack-5000.txt
-e 用于打印虚拟线程;不加它,快照里只剩载体线程,极易误判成"压测没生效"。
- 在快照里认两种形态(简化示意,字段名随小版本略有差异):
text
"VirtualThread-per-WorkerPool-1" #71 (virtual) waiting to lock <0x0000000770aa10> (a java.lang.Object)
com.example.demo.RateLimiter.acquire(RateLimiter.java:18)
com.example.demo.OrderController.order(OrderController.java:31)
"ForkJoinPool-1-worker-4" #32
com.example.demo.RateLimiter.acquire(RateLimiter.java:18) ← 载体线程跑到虚拟线程的栈帧上
com.example.demo.OrderController.order(OrderController.java:31)
判断依据:同一把锁同时出现在成百条虚拟线程栈上,而载体线程数长期稳定在 CPU 核数附近不再增长,即为钉住。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 何时不用 |
|---|---|---|---|
synchronized → ReentrantLock(本文) |
锁在自有代码、锁内有 sleep/IO/嵌套等待 |
需 finally 手动 unlock,可中断获取改用 lockInterruptibly |
锁在无法修改的第三方库 |
| 升级 JDK 24(JEP 491) | 基线允许升 LTS、代码改不动 | 依赖兼容与整体回归成本 | 公司基线锁定 JDK 21 |
| 回退平台线程 + 固定池 | 并发低、阻塞少、需严格限制资源 | 池耗尽即排队,吞吐上限受池大小约束 | 高并发长阻塞场景 |
| 缩小锁粒度 / 分段 | 高争用、临界区极短 | 重构数据结构,正确性更难验证 | 锁语义复杂、跨字段一致 |
三、关键原理
JEP 444 的规则:虚拟线程进入 synchronized 块时若 monitor 被占,就钉住当前载体线程;持锁期间的 Thread.sleep、阻塞 IO、park 同样独占载体。ReentrantLock/Condition 基于 LockSupport,等待时虚拟线程可整体解绑(unmount),把载体还给池。所以同样"等 1 ms",synchronized 版本烧掉一个核的载体,ReentrantLock 版本几乎零成本。

两个易错点:Object.wait() 期间释放 monitor,不钉住,但 wait() 返回后重新进入 synchronized 块时仍可能钉住;钉住有"等锁"与"持锁执行阻塞操作"两条路径。JDK 21 另提供 jdk.VirtualThreadPinning JFR 事件,可抽样统计钉住次数。
四、可运行示例
环境:JDK 21.0.x、Spring Boot 3.2.x、spring-boot-starter-web。
java
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// 问题版本:锁内执行 sleep,钉住载体线程
class RateLimiter {
private final long intervalNanos = TimeUnit.MILLISECONDS.toNanos(1);
private long nextAllowedAt;
public synchronized void acquire() throws InterruptedException {
long now = System.nanoTime();
if (now < nextAllowedAt) {
TimeUnit.NANOSECONDS.sleep(nextAllowedAt - now);
}
nextAllowedAt = System.nanoTime() + intervalNanos;
}
}
@RestController
@RequestMapping("/api")
public class OrderController {
private final RateLimiter limiter = new RateLimiter();
@GetMapping("/order")
public Map<String, Object> order() throws InterruptedException {
limiter.acquire();
return Map.of("ok", true, "ts", System.currentTimeMillis());
}
}
配置分两组,压测时分别启动:
yaml
# 虚拟线程组
spring:
threads:
virtual:
enabled: true
# 平台线程基线组(另跑一份)
# spring.threads.virtual.enabled=false
# server.tomcat.threads.max=200
操作步骤:
bash
mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Djdk.tracePinnedThreads=full"
# JMeter:HTTP 请求打 /api/order;并发 500、Ramp-Up 10s 跑一轮,再跑 5000
jcmd <pid> Thread.print -l -e > snap-5000.txt
预期输出:500 并发下虚拟线程版吞吐高于平台基线;5000 并发下快照出现同一 java.lang.Object 被成百虚拟线程争用,stderr 出现钉住警告。
实际输出:吞吐数字须用本机 JMeter 报告填入下表(未验证,此处只提供记录结构):
| 并发 | 平台线程(池=200)req/s | 虚拟线程(修复前)req/s | 虚拟线程(ReentrantLock)req/s |
|---|---|---|---|
| 500 | 待填 | 待填 | 待填 |
| 5000 | 待填 | 待填 | 待填 |
修复版本只改锁,其余不变:
java
private final ReentrantLock lock = new ReentrantLock();
public void acquire() throws InterruptedException {
lock.lockInterruptibly();
try {
long now = System.nanoTime();
if (now < nextAllowedAt) {
TimeUnit.NANOSECONDS.sleep(nextAllowedAt - now);
}
nextAllowedAt = System.nanoTime() + intervalNanos;
} finally {
lock.unlock();
}
}
常见失败:jstack <pid> 里看不到任何请求线程,误判成"压测没生效"。原因是默认不打印虚拟线程;改用 jstack -e <pid> 或 jcmd <pid> Thread.print -l -e,或直接依赖 -Djdk.tracePinnedThreads 的警告输出。
五、验证结果与边界
- 修复有效判据:5000 并发快照中不再出现同一 monitor 被成百虚拟线程争用;吞吐回升到平台基线之上(数值需复跑验证)。
- 边界一:钉住也可能来自第三方库(旧版 JDBC 驱动、日志组件里的
synchronized)。换掉自有锁后现象依旧,就回到第二节继续抓快照。 - 边界二:高争用、极短临界区场景,
ReentrantLock不一定优于无锁结构,要按数据结构选型。 - 边界三:不要因为"能跑"就无条件开虚拟线程;JVM 层根除 monitor 钉住要到 JDK 24(JEP 491)。
思考
- 锁在无法修改的第三方库时,升级 JDK 24 与在外层做限流降级,如何按回归风险取舍?
ReentrantLock的公平模式在高争用虚拟线程场景下,吞吐损失能否换来更稳的尾延迟?