先说背景。我们有个订单查询服务,日均 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