上周把公司一个订单查询服务从 Spring Boot 3.1.5 + JDK 17 升级到 Spring Boot 3.2.2 + JDK 21,看到 spring.threads.virtual.enabled=true 这个配置,心里一阵激动,心想终于能干掉那些讨厌的线程池了。
结果压测一跑,QPS 从 1200 涨到 3800,但 P99 从 85ms 飙到 290ms。团队里有人拍手叫好,有人骂街。我花了两个晚上定位,发现虚拟线程不是银弹,甚至在某些场景下是负优化。
这篇文章不吹不黑,只讲我踩过的坑和最后怎么调回去的。如果你也在考虑上虚拟线程,建议先看完再动手。
先交代背景:这个服务大概 15 个接口,平均响应 30ms,每天调用量 2000 万。之前用 Tomcat 默认 200 线程,经常在高峰期线程池打满,导致请求排队。
压测环境:4C8G 容器,JDK 21.0.2+9-LTS,Spring Boot 3.2.2,Tomcat 10.1.19,HikariCP 5.1.0,MySQL 8.0.36。
接口逻辑:查订单 + 调两个下游 HTTP 接口(用 RestClient),本地缓存无。
压测工具 wrk,命令:wrk -t4 -c500 -d60s --latency http://localhost:8080/order/123。
对照组:虚拟线程关闭(platform threads, max-threads=200),实验组:虚拟线程开启。
结果:QPS 1213 vs 3847,P50 32ms vs 28ms,P99 85ms vs 291ms,P999 120ms vs 680ms。
原因:虚拟线程遇到 synchronized 代码块会 pin 住载体线程,而 HikariCP 的 getConnection 内部有 synchronized,导致大量虚拟线程阻塞在载体线程上,反而增加了调度开销。
具体到代码:com.zaxxer.hikari.pool.HikariPool.getConnection 第 787 行有个 synchronized (this)。这个在 JDK 21 里还没被优化掉。我试过把 HikariCP 换成 5.1.0 也没用,后来看 JDK 21.0.2 的 release notes 说 JEP 444 对 synchronized 的 pinning 做了部分改进,但没完全消除。
解决方案与步骤:
第一步:用 -Djdk.tracePinnedThreads=full 启动,打印被 pin 的堆栈。你会看到一堆 at java.base/java.lang.VirtualThread.park 之类的。
第二步:把 HikariCP 的 maximumPoolSize 从 10 调到 50,因为虚拟线程下连接池竞争更激烈,但注意 MySQL max_connections 别爆。我设了 50,MySQL 那边调到了 500。
第三步:把接口里的 synchronized 换成 ReentrantLock,或者用 Redis 分布式锁替代本地锁。这个改动最大,我花了半天改代码。
第四步:如果下游 HTTP 调用用了 Apache HttpClient 5,它内部有 synchronized,换成 JDK 的 HttpClient 或者 OkHttp。我换成了 JDK HttpClient,P99 降了 40ms。
第五步:Spring Boot 配置:spring.threads.virtual.enabled=true 只是开启,但 Tomcat 的 maxConnections 默认 8192,需要根据内存调整,我设成 4096。还有,spring.mvc.async.request-timeout 要设,否则虚拟线程下超时控制会乱。
最后我的调优结果:QPS 3500,P99 110ms,P999 200ms。虽然 P99 还是比平台线程高,但吞吐上去了,综合成本更低。
别盲目跟风。虚拟线程适合 IO 密集型、且代码里没有 synchronized 的微服务。如果你的应用是 CPU 密集型,或者用了大量老旧的同步库(比如某些 JDBC 驱动、日志框架),虚拟线程反而会降低性能。
另外,Spring Boot 3.2 的虚拟线程支持还比较粗糙,比如 @Async 默认还是用 SimpleAsyncTaskExecutor,要手动配 TaskExecutor 为 VirtualThreadTaskExecutor。
我建议:先用 jdk.tracePinnedThreads 跑一遍,看 pin 的比例,如果超过 5%,就别开。还有,别在生产环境直接开,先在预发压测一周。
最后,别信那些“虚拟线程能让你的应用起飞”的营销号,我踩过的坑,希望你别再踩。如果你非要上,记得先看 JDK 版本,21.0.2 比 21.0.1 好一些,但 22 才真正修复了大部分 pinning 问题。