开虚拟线程后接口反而更慢:JDK 21 synchronized 锁住载体线程的排查

本文摘要:虚拟线程开启后接口反而变慢,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)
相关推荐
csdn_aspnet1 小时前
.NETCore EF Core 根据 mysql 数据表按顺序生成简单编号
mysql·.net core·efcore·1024程序员节·sequence·编号
啊哈一半醒1 小时前
Redis 三大缓存问题:缓存穿透、缓存击穿、缓存雪崩
运维·redis·1024程序员节
RuoyiOffice2 小时前
Flowable 状态通知设计:单体用本地事件,微服务如何回写业务状态?
spring boot·微服务·vue3·flowable·ruoyi office·redis stream·流程状态通知
Sirens.2 小时前
网络通信基础:IP、端口、协议分层与数据封装
java·网络·网络协议·tcp/ip·java-ee·1024程序员节
CRMEB系统商城2 小时前
服装连锁的导购分销与企微私域
java·大数据·开发语言·人工智能·小程序·php·1024程序员节
若亦_2 小时前
Java泛型入门:类型安全与上界约束
java·1024程序员节
编程的一拳超人2 小时前
【元宝搜索开发者征文大赛】四语实战:Java/Python/Go/Node.js 构建 AI 搜索应用全攻略
java·python·golang·node.js·agent
wuminyu2 小时前
JVM规范学习总结-Java源码经过javac编译后产物
java·linux·c语言·jvm·c++
ly76892 小时前
Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序
java·spring boot·spring·spring security·csrf·cors·过滤器链