本文摘要:虚拟线程开启后接口反而变慢,JDK 21 中被争用的 synchronized 钉住载体线程。并发坍缩到 CPU 核心数,换 ReentrantLock 可恢复。
一、问题与结论
先给结论:JDK 21 中,虚拟线程进入被争用的 synchronized 块时,会钉住(pin)当前承载它的载体线程;被钉住的载体线程既不能卸载这个虚拟线程,也不能调度其他虚拟线程。争用越激烈,可用载体线程越快被占满,"虚拟线程可以无限并发阻塞"的前提就不再成立。
一个能复现的现场:spring.threads.virtual.enabled=true 打开后,OrderService.getOrder 先读 Redis,miss 则查 MySQL,最后写回缓存------写缓存那一行套了 synchronized (this)。两种线程模型下的预期表现对比:
| 指标 | 平台线程(200 线程池) | 虚拟线程 + 争用 synchronized |
|---|---|---|
| QPS | 数百 | 收敛到 CPU 核心数量级 |
| P99 延迟 | 百毫秒级 | 秒级 |
| 载体线程 | 常规调度 | 全部被钉住、无法换出 |
机制上的解释很直白:8 核机器默认 8 个载体线程。高并发下大量虚拟线程各自做完 MySQL 阻塞 I/O 后同时抢同一个 monitor,8 个载体线程依次被钉住;几万个虚拟线程里,只有正在运行的这 8 个能跑,其余全部排队。有效并发从"虚拟线程数"退化成"CPU 核心数"。
版本边界必须说清楚:
| JDK 版本 | synchronized + 虚拟线程行为 |
|---|---|
| 19 / 20(预览) | 无争用也钉住 |
| 21 / 22 / 23 | 仅争用时钉住(本文场景) |
| 24+(JEP 491) | 不再钉住 |
ReentrantLock、ReadWriteLock、Semaphore 等 java.util.concurrent 显式锁不受影响,因为它们本来就会在争用时让虚拟线程挂起而非阻塞底层线程。
二、排查与选择依据
关键原理:钉住与诊断
JDK 21 提供了专门的诊断开关,这也是排查本问题最直接的路径:

bash
java -Djdk.tracePinnedThreads=short -jar app.jar
| 参数值 | 行为 |
|---|---|
full |
钉住时向 stderr 输出完整栈 |
short |
只输出简要钉住位置 |
当钉住发生时,stderr 中会出现 Carrying virtual thread pinned 字样及触发位置的调用栈。这是 JDK 21 的文档行为,属于确定信息;栈里指向哪一行,取决于你的代码。如果输出里指向的是你写的 synchronized 块,根因即可确认。
另外两个相关参数:-Djdk.virtualThreadScheduler.parallelism 默认等于 CPU 核心数,-Djdk.virtualThreadScheduler.maxPoolSize 默认 256。
替代方案与取舍
四种做法,代价和适用条件差别很大:
| 方案 | 选择条件 | 代价与边界 |
|---|---|---|
synchronized → ReentrantLock |
JDK 21--23,只想改代码不想动版本 | 低。语义一致(互斥、可重入),需补 try/finally |
改用 ConcurrentHashMap.computeIfAbsent |
锁内逻辑本身就是"填充本地缓存" | 中。需重新审查并发安全,锁内不要做网络 I/O |
| 升级 JDK 24+ | 允许排期升级,依赖库已兼容 | 高。Spring Boot 需 3.4+,整条依赖链要跟 |
调大 -Djdk.virtualThreadScheduler.parallelism |
紧急止血、来不及改代码 | 极低,但只是缓解:钉住行为仍在,极端争用下仍会打满,且增加上下文切换 |
什么时候不该用本文的 ReentrantLock 方案:如果你已在 JDK 24+ 上运行,升级本身就已经消除了钉住,再换锁只是无谓的改动面;如果锁内做的是耗时 I/O,无论哪种锁都会把并发压到锁的持有时间上,应该先拆分临界区而不是换锁类型。
可运行示例
环境 :JDK 21--23(JDK 24+ 中该问题已修复,看不到差异)。输入是 10000 个虚拟线程,每个做两次 1ms Thread.sleep 模拟 I/O,中间对同一个 counter 做一次递增。
java
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
public class SyncPinningTest {
private static final int TASKS = 10_000;
private static final Object syncLock = new Object();
private static final ReentrantLock reLock = new ReentrantLock();
private static int counter;
public static void main(String[] args) throws Exception {
System.out.printf("JDK: %s%n", System.getProperty("java.version"));
System.out.printf("Carrier threads: %d%n",
Runtime.getRuntime().availableProcessors());
runWithSync(1_000); // 预热
runWithLock(1_000);
counter = 0;
Instant s1 = Instant.now();
runWithSync(TASKS);
long syncMs = Duration.between(s1, Instant.now()).toMillis();
System.out.printf("synchronized : %6d ms (counter=%d)%n", syncMs, counter);
counter = 0;
Instant s2 = Instant.now();
runWithLock(TASKS);
long lockMs = Duration.between(s2, Instant.now()).toMillis();
System.out.printf("ReentrantLock: %6d ms (counter=%d)%n", lockMs, counter);
System.out.printf("Ratio: %.2f%n", (double) syncMs / lockMs);
}
private static void runWithSync(int tasks) {
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tasks; i++) {
pool.submit(() -> {
io();
synchronized (syncLock) { // 争用时钉住载体线程
counter++;
}
io();
});
}
}
}
private static void runWithLock(int tasks) {
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tasks; i++) {
pool.submit(() -> {
io();
reLock.lock(); // 争用时不钉住载体线程
try { counter++; } finally { reLock.unlock(); }
io();
});
}
}
}
private static void io() {
try { Thread.sleep(Duration.ofMillis(1)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
编译运行:
bash
javac SyncPinningTest.java
java SyncPinningTest
java -Djdk.tracePinnedThreads=short SyncPinningTest # 诊断模式
预期输出 :synchronized 一栏耗时高于 ReentrantLock 一栏,倍数随 CPU 核心数与争用程度变化;诊断模式下 stderr 出现 Carrying virtual thread pinned,位置指向 synchronized (syncLock)。实际输出:本文未在多核机器上实跑压测,具体毫秒数未经验证,请以你本地结果为准。可以确定的是诊断输出形态,因为那是 JDK 21 的文档行为。
一个常见失败:把参数写成 -Djdk.tracePinnedThreads=true,结果什么都打不出来。原因是他只接受 full / short 两个值,不是布尔开关,改回 short 即可。
三、验证结果与边界
判定是否命中的判据有三条:诊断栈里出现你的 synchronized 位置;ReentrantLock 版本在同等负载下延迟显著下降;把 parallelism 调大后有改善但没根治。三条同时成立,就是钉住问题而非锁竞争本身。
边界要说明:本文的"开虚拟线程反而更慢"只在 JDK 19--23 上成立;JDK 24 起 synchronized 与虚拟线程调度器已兼容,不需要这套排查。无争用的 synchronized 在 JDK 21 上也不会钉住,所以只有热点锁才会暴露问题------这意味着低流量环境压不出来是正常的。
参考资料
- JEP 444: Virtual Threads(JDK 21)
- JEP 491: Synchronize Virtual Threads without Pinning(JDK 24)
- java.lang.Thread API 文档(JDK 21)