上个月公司有个内部服务要做性能基线评估,技术栈刚好迁移到了 Spring Boot 3.2,JDK 版本也升到了 21。既然虚拟线程(Virtual Threads,Project Loom)已经正式 GA,就顺手做了一次对比压测,看看在真实的 I/O 密集型场景下,虚拟线程相比传统平台线程到底能榨出多少性能余量。
背景与动机
我们有一个典型的网关聚合服务,上游接收 App 请求,下游并行调用 4-5 个内部微服务,最后合并结果返回。逻辑不复杂,但 I/O 等待时间占整个请求生命周期的 80% 以上,是非常标准的 I/O 密集型场景。
之前的配置是传统的 Tomcat + 平台线程池,线程数配得保守(最大 200),原因是线程栈内存占用高,开多了容易把容器内存打满。但线程数保守的代价也很明显:高并发下请求排队,P99 延迟飙升。
JDK 21 的虚拟线程理论上可以解决这个矛盾:轻量级、由 JVM 调度、阻塞时自动让出载体线程,看起来非常适合我们的场景。但官方文档也提醒过,虚拟线程不是银弹,某些同步原语(比如 synchronized 块或 ReentrantLock)可能会"钉住"(pin)载体线程,导致调度优势打折扣。
所以决定不只看文档,直接上压测。
测试环境
- 应用框架:Spring Boot 3.2.0
- JDK:Eclipse Temurin 21.0.1
- 容器:Docker,限制 4C 8G
- 压测工具:JMeter,500 并发线程,持续 5 分钟
- 下游模拟:一个独立服务,固定延迟 100ms(模拟内部调用)
Spring Boot 3.2 开启虚拟线程非常简单,application.properties 里加一行:
properties复制spring.threads.virtual.enabled=true
不需要改任何业务代码,Tomcat 会自动用虚拟线程处理请求。
测试方案
做了三组对照:
- 平台线程 + 同步阻塞 :Tomcat 传统线程池,下游用
RestTemplate同步调用 - 平台线程 + 异步非阻塞 :Tomcat 传统线程池,下游用
WebClient异步调用 - 虚拟线程 + 同步阻塞 :Tomcat 虚拟线程,下游用
RestTemplate同步调用
第三组是重点观察对象------虚拟线程最大的优势就是可以让同步代码写出异步的性能,如果业务代码完全不用重构就能拿到收益,那迁移成本最低。
关键指标对比
压测过程中用 Prometheus + Grafana 抓了以下几组数据:
吞吐量(Throughput)
- 平台线程 + 同步:约 1,200 QPS,达到线程池上限后不再增长
- 平台线程 + 异步:约 3,800 QPS,事件循环发挥了作用
- 虚拟线程 + 同步:约 3,600 QPS
虚拟线程组的吞吐量非常接近异步非阻塞方案,但业务代码完全保持同步风格,可读性和调试友好度明显更好。
延迟分布(Latency)
重点关注 P99:
- 平台线程 + 同步:P99 达到 2.3s,大量请求在 Tomcat 队列里排队
- 平台线程 + 异步:P99 约 280ms
- 虚拟线程 + 同步:P99 约 310ms
虚拟线程的 P99 略逊于异步方案,但差距不大。相比传统同步方案,延迟表现提升了将近一个数量级。
内存占用(Memory)
- 平台线程 + 同步:容器内存稳定在 3.2G 左右,200 个平台线程的栈内存开销固定
- 平台线程 + 异步:约 2.8G,异步上下文对象比线程栈轻一些
- 虚拟线程 + 同步:约 2.1G,且压测过程中内存曲线非常平稳
这是虚拟线程最超预期的地方。平台线程默认栈大小 1MB,200 个线程就是 200MB 起步;虚拟线程栈是动态分配的,初始只有几百字节,500 并发下内存压力反而更小。
踩到的坑:Carrier Thread Pinning
压测过程中注意到一个细节:虚拟线程组的吞吐量在并发打到 400 以上时,出现了短暂的毛刺,不是线性增长。
用 JDK 21 新增的 JVM 参数打印钉住事件:
bash复制-Djdk.tracePinnedThreads=full
日志里发现业务代码里有一段历史债务------下游调用前加了 synchronized 块做本地缓存的互斥更新。虚拟线程执行到这段代码时,会钉住底层的载体线程(ForkJoinPool 里的平台线程),导致该载体线程无法调度其他虚拟线程,相当于退回了平台线程的调度模式。
定位后把那段 synchronized 改成了 ReentrantLock,但 ReentrantLock 在 JDK 21 里同样会钉住载体线程。最后改成了无锁的 ConcurrentHashMap::computeIfAbsent,毛刺消失,吞吐量曲线恢复平滑。
这个坑说明:虚拟线程虽然兼容旧代码,但如果代码里有大量同步块或锁竞争,性能优势会被稀释。迁移前最好全局扫一遍 synchronized 和 Lock 的使用。
另一个观察:ThreadLocal 的代价
虚拟线程是 ThreadLocal 的"天敌"。压测时发现一个请求链路里某个库用 ThreadLocal 存了上下文对象,虚拟线程频繁创建销毁时,ThreadLocal 的清理开销被放大,GC 频率明显变高。
JDK 21 提供了 ScopedValue 作为替代方案,但涉及到底层库的改造,短期没法落地。目前的权宜之计是减少虚拟线程的创建频率,比如配合连接池复用,而不是每个请求都新建。
结论与落地建议
基于这次压测,我们对虚拟线程的落地策略做了调整:
- 适用场景:I/O 密集型、阻塞操作多、业务代码以同步风格为主的服务,虚拟线程收益最大。计算密集型或锁竞争激烈的场景,优势不明显。
- 迁移成本 :Spring Boot 3.2 的开关式启用确实做到了"零代码改动",但上线前必须做两件事:全局扫描
synchronized块,以及评估ThreadLocal的使用密度。 - 性能预期:在理想的 I/O 密集型场景下,虚拟线程可以接近异步非阻塞的吞吐量,同时保持同步代码的可维护性。
目前虚拟线程方案已经在测试环境跑了两个月,计划下个季度切到生产。有在做类似评估的同学欢迎交流数据。
一点补充
这次压测让我意识到,JDK 21 的虚拟线程不是一个"开开关就能飞"的魔法选项。它的价值在于降低了高并发 I/O 场景的编程模型复杂度,让开发者可以用同步的思维方式写出接近异步的性能。但底层载体线程的调度、钉住事件、ThreadLocal 清理这些细节,仍然需要开发者对运行时行为有清晰的理解。
技术选型从来不是在"新特性"和"稳定性"之间二选一,而是看新特性在特定场景下的边际收益是否大于迁移和学习的边际成本。就我们这次评估而言,虚拟线程的收益是正的。