Java 21 虚拟线程踩坑实录:QPS 从 1200 掉到 300,罪魁祸首是 synchronized

🔑 关键词:Java 21,虚拟线程,Virtual Threads,synchronized pinning,JEP 491

📖 摘要:一次真实的 Java 21 虚拟线程落地翻车记录:预发环境 QPS 不升反降、CPU 飙到 78%,用 JFR 抓 jdk.VirtualThreadPinned 定位到 synchronized 造成的载体线程 pinning,附三处优化前后的实测数据对比和三条避坑建议。

上周三凌晨两点,我盯着 Grafana 上那条死活上不去的 QPS 曲线,脑子里只有一个念头:这破虚拟线程是不是被吹过头了。

图片

事情得从两个月前说起。我们有个内部的风控查询服务,Java 17 + Spring Boot 3.1,接口逻辑简单到不行——收请求,并发调三个下游 HTTP 接口,聚合结果返回。典型的 IO 密集型,CPU 常年 15% 以下,但 Tomcat 线程池 maxThreads=200 经常被打满,P99 在 1.8s 左右晃。9 月 Java 21 GA 之后我第一时间升了级,Tomcat 换成虚拟线程执行器,配置就一行:

spring:
  threads:
    virtual:
      enabled: true

本地 wrk 压 200 并发,QPS 从 4300 涨到 11000,P99 掉到 180ms。当时我差点在群里发红包,直接就推预发了。

图片

然后就是打脸时刻。预发环境同样的 200 并发,QPS 只有 900 出头,比升级前还低。更诡异的是 CPU 从 15% 飙到 78%,下游服务的调用量却没涨——说明请求全卡在我们自己进程里。没有报错,没有异常,日志干干净净,接口就是在慢慢超时。

第一反应是 jstack。敲下去才发现,虚拟线程根本不在传统的 thread dump 里以普通线程形式出现,得用:

jcmd <pid> Thread.dump_to_file -format=json /tmp/dump.json

图片

打开一看,六万多个虚拟线程,状态全是 RUNNABLE,但对应的载体线程(ForkJoinPool 的 worker)只有 8 个——正好是我们机器的核数。这就是问题的形状。

用 JFR 抓了一把,事件 jdk.VirtualThreadPinned 直接刷屏,堆栈全指向一个类:我们用 synchronized 保护的一个本地缓存刷新方法,里面还有个 Guava Cache 的 refresh 调用。

原理不复杂。虚拟线程跑在 ForkJoinPool 上,默认并行度就是 CPU 核数(jdk.virtualThreadScheduler.parallelism,8 核就是 8)。当虚拟线程在 synchronized 块里阻塞——比如等一次 HTTP 返回——它没法被从载体线程上卸载,载体线程就被“钉”住了。8 个载体全被 pin 死,剩下几万个虚拟线程在调度队列里干等,效果跟平台线程池队列爆满一模一样,还多了一层调度开销。

图片

这事 JEP 444 其实写了,synchronized 会 pin,推荐用 ReentrantLock。真正从 JVM 层面修掉是 JDK 24 的 JEP 491(2025 年 3 月 GA),我们生产还在 21.0.2,等不了。临时诊断可以用 -Djdk.tracePinnedThreads=full,它会把 pin 住的堆栈直接打到 stdout,但只能用来定位,不能长期开。

把 synchronized 换成 ReentrantLock 之后重新压测,QPS 9800,CPU 回到 22%,P99 210ms。到这里我只解决了一半问题,因为后面还有两个坑在等着。

第二个坑是 ThreadLocal。我们有个工具类用 ThreadLocal 缓存 SimpleDateFormat 和一个大约 4MB 的规则对象。平台线程池 200 个线程,最多 200 份副本,撑死 800MB。虚拟线程同时存活 6 万多个,每份 4MB——压测跑了 40 秒就 OOM 了,heap dump 里全是这个规则对象的拷贝。Java 21 里 ScopedValue 还是 preview(JEP 446),要开 --enable-preview,我们最后选了更土的办法:不可变对象直接扔静态字段,日期格式化换 DateTimeFormatter。

图片

第三个坑是连接池。HikariCP 的 maximumPoolSize 默认是 10,虚拟线程一多,全堵在 getConnection 上,等锁的时间比等 IO 还长。这个坑的本质不是虚拟线程有问题,而是并发模型变了——虚拟线程的并发度不等于后端资源的并发度。我们的做法是给 DB 调用套一层 Semaphore(32) 做限流,把真正打到 MySQL 的并发压在 32 以内。

给一组我们环境下的实测数据(8C16G,JDK 21.0.2,Spring Boot 3.2.1,wrk -t4 -c200 -d60s):

指标 平台线程池(200) 虚拟线程(未优化) 虚拟线程(优化后)
QPS 4300 900 9800
P99 1800ms 4200ms 210ms
CPU 15% 78% 22%
峰值线程数 200 60000+ 60000+

图片

还有个我觉得被严重低估的点:虚拟线程的问题几乎全是“渐进式”的。它没有拒绝策略,没有 RejectedExecutionException,队列满了不报错,请求就是静静地排队,一直排到超时。如果你的监控只看错误率和 5xx,根本发现不了。我们现在的做法是三个必看指标:JFR 的 jdk.VirtualThreadPinned 事件计数、jdk.ThreadPark 的等待时长分布、以及载体线程池的活跃度(ForkJoinPool.commonPool 那套 MBean 在虚拟线程场景下不算数,得看 jdk.virtualThreadScheduler 相关的 JMX)。

最后说点观点。虚拟线程真正的价值不是“更快”,而是把“一个请求一个线程”这种最好写的同步代码,从 200 并发撑到 10000 并发。它替代 WebFlux 的意义,远大于替代线程池。如果你的代码本来就是 CompletableFuture 满天飞,换虚拟线程不会变快,只会变好看。反过来,如果你现在正被线程池队列、拒绝策略、ThreadLocal 泄漏折磨,那它值得试,但记住三条:synchronized 换 ReentrantLock(或者直接上 JDK 24)、ThreadLocal 换 ScopedValue 或者干脆别用、下游资源的并发单独限流。

哦对,还有一个。别用 Executors.newVirtualThreadPerTaskExecutor() 去跑 CPU 密集型任务,那玩意跑满核数之后剩下的虚拟线程排队,性能比平台线程池还差——这我是在另一个压测里栽的跟头,改天再写。

🏷️ 标签: