本文摘要虚拟线程开启后接口反而变慢JDK 21 中被争用的 synchronized 钉住载体线程。并发坍缩到 CPU 核心数换 ReentrantLock 可恢复。一、问题与结论先给结论JDK 21 中虚拟线程进入被争用的synchronized块时会钉住pin当前承载它的载体线程被钉住的载体线程既不能卸载这个虚拟线程也不能调度其他虚拟线程。争用越激烈可用载体线程越快被占满虚拟线程可以无限并发阻塞的前提就不再成立。一个能复现的现场spring.threads.virtual.enabledtrue打开后OrderService.getOrder先读 Redismiss 则查 MySQL最后写回缓存——写缓存那一行套了synchronized (this)。两种线程模型下的预期表现对比指标平台线程200 线程池虚拟线程 争用synchronizedQPS数百收敛到 CPU 核心数量级P99 延迟百毫秒级秒级载体线程常规调度全部被钉住、无法换出机制上的解释很直白8 核机器默认 8 个载体线程。高并发下大量虚拟线程各自做完 MySQL 阻塞 I/O 后同时抢同一个 monitor8 个载体线程依次被钉住几万个虚拟线程里只有正在运行的这 8 个能跑其余全部排队。有效并发从虚拟线程数退化成CPU 核心数。版本边界必须说清楚JDK 版本synchronized 虚拟线程行为19 / 20预览无争用也钉住21 / 22 / 23仅争用时钉住本文场景24JEP 491不再钉住ReentrantLock、ReadWriteLock、Semaphore等java.util.concurrent显式锁不受影响因为它们本来就会在争用时让虚拟线程挂起而非阻塞底层线程。二、排查与选择依据关键原理钉住与诊断JDK 21 提供了专门的诊断开关这也是排查本问题最直接的路径java-Djdk.tracePinnedThreadsshort-jarapp.jar参数值行为full钉住时向 stderr 输出完整栈short只输出简要钉住位置当钉住发生时stderr 中会出现Carrying virtual thread pinned字样及触发位置的调用栈。这是 JDK 21 的文档行为属于确定信息栈里指向哪一行取决于你的代码。如果输出里指向的是你写的synchronized块根因即可确认。另外两个相关参数-Djdk.virtualThreadScheduler.parallelism默认等于 CPU 核心数-Djdk.virtualThreadScheduler.maxPoolSize默认 256。替代方案与取舍四种做法代价和适用条件差别很大方案选择条件代价与边界synchronized→ReentrantLockJDK 21–23只想改代码不想动版本低。语义一致互斥、可重入需补try/finally改用ConcurrentHashMap.computeIfAbsent锁内逻辑本身就是填充本地缓存中。需重新审查并发安全锁内不要做网络 I/O升级 JDK 24允许排期升级依赖库已兼容高。Spring Boot 需 3.4整条依赖链要跟调大-Djdk.virtualThreadScheduler.parallelism紧急止血、来不及改代码极低但只是缓解钉住行为仍在极端争用下仍会打满且增加上下文切换什么时候不该用本文的ReentrantLock方案如果你已在 JDK 24 上运行升级本身就已经消除了钉住再换锁只是无谓的改动面如果锁内做的是耗时 I/O无论哪种锁都会把并发压到锁的持有时间上应该先拆分临界区而不是换锁类型。可运行示例环境JDK 21–23JDK 24 中该问题已修复看不到差异。输入是 10000 个虚拟线程每个做两次 1msThread.sleep模拟 I/O中间对同一个counter做一次递增。importjava.time.Duration;importjava.time.Instant;importjava.util.concurrent.Executors;importjava.util.concurrent.locks.ReentrantLock;publicclassSyncPinningTest{privatestaticfinalintTASKS10_000;privatestaticfinalObjectsyncLocknewObject();privatestaticfinalReentrantLockreLocknewReentrantLock();privatestaticintcounter;publicstaticvoidmain(String[]args)throwsException{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);counter0;Instants1Instant.now();runWithSync(TASKS);longsyncMsDuration.between(s1,Instant.now()).toMillis();System.out.printf(synchronized : %6d ms (counter%d)%n,syncMs,counter);counter0;Instants2Instant.now();runWithLock(TASKS);longlockMsDuration.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);}privatestaticvoidrunWithSync(inttasks){try(varpoolExecutors.newVirtualThreadPerTaskExecutor()){for(inti0;itasks;i){pool.submit(()-{io();synchronized(syncLock){// 争用时钉住载体线程counter;}io();});}}}privatestaticvoidrunWithLock(inttasks){try(varpoolExecutors.newVirtualThreadPerTaskExecutor()){for(inti0;itasks;i){pool.submit(()-{io();reLock.lock();// 争用时不钉住载体线程try{counter;}finally{reLock.unlock();}io();});}}}privatestaticvoidio(){try{Thread.sleep(Duration.ofMillis(1));}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}编译运行javac SyncPinningTest.javajavaSyncPinningTestjava-Djdk.tracePinnedThreadsshort SyncPinningTest# 诊断模式预期输出synchronized一栏耗时高于ReentrantLock一栏倍数随 CPU 核心数与争用程度变化诊断模式下 stderr 出现Carrying virtual thread pinned位置指向synchronized (syncLock)。实际输出本文未在多核机器上实跑压测具体毫秒数未经验证请以你本地结果为准。可以确定的是诊断输出形态因为那是 JDK 21 的文档行为。一个常见失败把参数写成-Djdk.tracePinnedThreadstrue结果什么都打不出来。原因是他只接受full/short两个值不是布尔开关改回short即可。三、验证结果与边界判定是否命中的判据有三条诊断栈里出现你的synchronized位置ReentrantLock版本在同等负载下延迟显著下降把parallelism调大后有改善但没根治。三条同时成立就是钉住问题而非锁竞争本身。边界要说明本文的开虚拟线程反而更慢只在 JDK 19–23 上成立JDK 24 起synchronized与虚拟线程调度器已兼容不需要这套排查。无争用的synchronized在 JDK 21 上也不会钉住所以只有热点锁才会暴露问题——这意味着低流量环境压不出来是正常的。参考资料JEP 444: Virtual ThreadsJDK 21JEP 491: Synchronize Virtual Threads without PinningJDK 24java.lang.Thread API 文档JDK 21