☰
虚拟线程上线后吞吐反而掉了:Spring Boot 3 里 synchronized 锁住载体线程的定位与修复
2026/11/5 1:54:47 网站建设 项目流程

本文摘要:开启虚拟线程后吞吐不升反降,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后,同一压测下钉住消失、吞吐回升(数字见第四节记录表,需在你环境复跑)。

二、排查与选择依据

步骤如下,每步先说目的再给动作。

  1. 开启钉住跟踪:JVM 参数加-Djdk.tracePinnedThreads=full(或any),发生钉住时把堆栈打到 stderr。
  2. 压测打满后抓两份快照:
jcmd<pid>Thread.print-l-e>snap-5000.txt jstack-e<pid>>jstack-5000.txt

-e用于打印虚拟线程;不加它,快照里只剩载体线程,极易误判成"压测没生效"。

  1. 在快照里认两种形态(简化示意,字段名随小版本略有差异):
"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.VirtualThreadPinningJFR 事件,可抽样统计钉住次数。

四、可运行示例

环境:JDK 21.0.x、Spring Boot 3.2.x、spring-boot-starter-web。

importjava.util.Map;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.locks.ReentrantLock;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;// 问题版本:锁内执行 sleep,钉住载体线程classRateLimiter{privatefinallongintervalNanos=TimeUnit.MILLISECONDS.toNanos(1);privatelongnextAllowedAt;publicsynchronizedvoidacquire()throwsInterruptedException{longnow=System.nanoTime();if(now<nextAllowedAt){TimeUnit.NANOSECONDS.sleep(nextAllowedAt-now);}nextAllowedAt=System.nanoTime()+intervalNanos;}}@RestController@RequestMapping("/api")publicclassOrderController{privatefinalRateLimiterlimiter=newRateLimiter();@GetMapping("/order")publicMap<String,Object>order()throwsInterruptedException{limiter.acquire();returnMap.of("ok",true,"ts",System.currentTimeMillis());}}

配置分两组,压测时分别启动:

# 虚拟线程组spring:threads:virtual:enabled:true# 平台线程基线组(另跑一份)# spring.threads.virtual.enabled=false# server.tomcat.threads.max=200

操作步骤:

mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Djdk.tracePinnedThreads=full"# JMeter:HTTP 请求打 /api/order;并发 500、Ramp-Up 10s 跑一轮,再跑 5000jcmd<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待填待填待填

修复版本只改锁,其余不变:

privatefinalReentrantLocklock=newReentrantLock();publicvoidacquire()throwsInterruptedException{lock.lockInterruptibly();try{longnow=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)。

思考

  1. 锁在无法修改的第三方库时,升级 JDK 24 与在外层做限流降级,如何按回归风险取舍?
  2. ReentrantLock的公平模式在高争用虚拟线程场景下,吞吐损失能否换来更稳的尾延迟?

参考资料

  • JEP 444: Virtual Threads(JDK 21)
  • JEP 491: Synchronize Virtual Threads without Pinning(JDK 24)
  • jstack — JDK 21 Man Page
  • jcmd — JDK 21 Man Page

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询