Spring Boot 3.2 虚拟线程实测:性能不升反降,我踩了这三个坑

🔑 关键词:Spring Boot,虚拟线程,WebFlux,性能优化,Java 21

📖 摘要:记录一次将 Spring Boot 2.7 升级到 3.2 并启用虚拟线程的真实经历,包括压测数据对比、遇到的 pinning 问题以及最终解决方案。

先说背景。我们有个订单查询服务,日均 200 万请求,高峰 QPS 大概 1200。原来跑在 Spring Boot 2.7 + Tomcat 上,线程池最大 200,P99 延迟 320ms,服务器 4C8G,跑了两年挺稳。 上个月看到 Java 21 虚拟线程吹得神乎其神,说是一个线程处理一个请求,吞吐量翻倍。我心动了,花了一个周末把服务升级到 Spring Boot 3.2.1,JDK 21,配置文件里加了一行 spring.threads.virtual.enabled=true。 结果压测直接给我干懵了。同样的 JMeter 脚本,1000 并发,QPS 从 1200 掉到 950,P99 从 320ms 涨到 850ms。CPU 使用率倒是没变,但内存从 450MB 飙到 1.2GB,GC 次数翻了三倍。 我当时就怀疑人生了,网上文章不是都说虚拟线程性能提升 3 倍吗?怎么到我这就翻车了?

图片

排查过程就不细说了,反正最后用 jcmd Thread.dump_to_file 和 -Djdk.tracePinnedThreads=full 抓到了元凶。第一个坑是 synchronized。我们代码里有个 Redis 分布式锁,为了图省事直接用的 synchronized 块。虚拟线程在 synchronized 块里会 pinning 到载体线程,相当于白瞎了虚拟线程的优势。Java 21 里 ReentrantLock 已经支持虚拟线程了,但很多第三方库还在用 synchronized,比如老版本的 MySQL Connector/J 8.0.28 之前。 第二个坑是 ThreadLocal。我们有个 UserContext 用 ThreadLocal 存用户信息,虚拟线程数量一多,每个线程都复制一份,内存直接爆炸。后来改成 ScopedValue 或者显式传参才缓解。 第三个坑是线程池。Spring Boot 3.2 默认给虚拟线程配了一个 SimpleAsyncTaskExecutor,但它没有队列,任务多了直接创建新虚拟线程,结果就是线程数失控。后来我手动配了 ThreadPoolTaskExecutor 并设置虚拟线程工厂,才把并发控制住。

图片

后来我做了个对比实验。同一个服务,用 WebFlux 重写核心接口,QPS 能到 3500,P99 120ms,内存 380MB。但代价是代码可读性直线下降,一个简单的查询要写 Mono、flatMap,调试的时候堆栈看不懂,团队新人根本接不住。 所以我的观点可能有点反直觉:虚拟线程不是用来替代 WebFlux 的,它更像是一个过渡方案。对于 IO 密集但代码简单的服务,比如报表导出、文件上传,虚拟线程确实能提升吞吐。但对于高并发、低延迟的核心接口,WebFlux 或者传统线程池 + 异步 Servlet 可能更稳。 我最后把订单查询服务回滚到 Tomcat 200 线程,只把那个报表导出模块改成了虚拟线程,QPS 反而从 1200 涨到了 1400,P99 稳定在 280ms。因为报表导出原来会阻塞 Tomcat 线程,现在不阻塞了。

图片

如果你也想在 Spring Boot 3.2 里用虚拟线程,我建议先做三件事。第一,用 jcmd Thread.dump_to_file -format=json /tmp/dump.json 分析虚拟线程的 pinning 情况,或者启动时加 -Djdk.tracePinnedThreads=full,日志里会打印 pinning 堆栈。 第二,把所有 synchronized 块换成 ReentrantLock,把 ThreadLocal 换成 ScopedValue(Java 21 预览)或者显式传参。第三,不要盲目开启全局虚拟线程,只对特定端点或任务使用,比如 @Async 指定虚拟线程执行器。 最后说句得罪人的话,Spring Boot 的一键开启虚拟线程确实方便,但也掩盖了很多细节。技术选型还是要看数据,别被营销号带偏了。

图片

🏷️ 标签: