虚拟线程迁移:收益、陷阱与验证方法
虚拟线程不会让你的代码变快,它只会让你的代码在等待时不再浪费一个操作系统线程。搞清楚这句话,才知道哪些服务该迁移、哪些不该。
虚拟线程在 JDK 21 转正之后,很多团队第一反应是「把线程池换成 newVirtualThreadPerTaskExecutor 就完事了」。真实迁移里,收益来自哪、代价在哪、怎么证明改对了,这三件事比替换 API 本身重要得多。
一、先判断你的服务该不该迁移
虚拟线程解决的是一个具体的浪费:线程阻塞在 I/O 上时,它占着的操作系统线程什么也做不了。
用一个简化模型算一下。假设接口 RT 是 200ms,其中 190ms 在等下游(RPC + 数据库),10ms 在真正算东西。传统 200 线程的池子,理论吞吐上限是:
200 线程 / 0.2s = 1000 QPS再往上就得加线程,而每个平台线程默认占 1MB 栈空间,几千个线程之后调度开销和内存都开始难看。
虚拟线程的模型不一样:阻塞时它会从载体线程(carrier thread)上卸载,载体线程立刻去跑别的虚拟线程。吞吐上限变成由 CPU 实际工作量和下游承载能力决定:
可用核数 / 单请求 CPU 时间 = 4 / 0.01s = 400 QPS/核 → 4 核约 1600 QPS结论很直接:
| 服务特征 | 迁移收益 |
|---|---|
| 请求里大部分时间在等 I/O(网关、聚合层、BFF) | 明显,这是虚拟线程的主场 |
| CPU 密集(加解密、图像处理、大量序列化) | 几乎没有,瓶颈在核数,换了也一样 |
| 已经全链路 Reactive(WebFlux + R2DBC) | 收益小,但代码可读性会大幅改善 |
| 线程数已被下游连接池死死卡住 | 没有,瓶颈不在线程,见第四节 |
最后一行是最容易被忽略的:如果你的数据库连接池只有 20 个连接,把线程放开到一万个,只是把排队从线程池挪到了连接池,端到端 RT 不会变好,还可能更糟。
1.1 实测:收益和上限都很清楚
用一段最小程序验证上面的模型:提交一批任务,每个任务 Thread.sleep(50) 模拟一次下游调用,分别交给 200 线程的平台线程池和 newVirtualThreadPerTaskExecutor() 执行(JDK 21.0.5,10 核)。
| 场景 | 耗时 | 理论值 |
|---|---|---|
| 5,000 个任务,平台线程池 200 线程 | 1,376 ms | 5000 ÷ 200 × 50ms = 1,250 ms |
| 5,000 个任务,虚拟线程 | 67 ms | 约 50 ms |
| 1,000 个任务,虚拟线程,但下游只有 20 个连接 | 2,756 ms | 1000 ÷ 20 × 50ms = 2,500 ms |
前两行说明收益:等待不再占用操作系统线程,5,000 个任务几乎同时完成。第三行说明上限:用信号量模拟一个只有 20 个连接的连接池,虚拟线程再多,耗时也由 20 个连接决定,和换虚拟线程之前的线程池没有本质区别。
实验代码的核心部分:
static long run(ExecutorService pool, int tasks, Runnable body) throws Exception {
long t0 = System.nanoTime();
try (pool) { // close() 会等待全部任务结束
for (int i = 0; i < tasks; i++) pool.submit(body);
}
return (System.nanoTime() - t0) / 1_000_000;
}
run(Executors.newFixedThreadPool(200), 5000, VT::io);
run(Executors.newVirtualThreadPerTaskExecutor(), 5000, VT::io);二、换 API:三种常见写法
2.1 Spring Boot 应用
Spring Boot 3.2+ 一个开关就够,Tomcat 会为每个请求起一个虚拟线程:
spring.threads.virtual.enabled=true这个开关同时会把 @Async、Spring MVC 的异步请求处理都切到虚拟线程上。实测默认的 applicationTaskExecutor 从 8 个核心线程的 ThreadPoolTaskExecutor 换成了 SimpleAsyncTaskExecutor,它默认不限制并发,见 线程池参数怎么定 第二节。
2.2 手动管理任务
// 每个任务一个虚拟线程,创建成本极低,不要复用、不要池化
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Order>> futures = orderIds.stream()
.map(id -> executor.submit(() -> orderClient.fetch(id)))
.toList();
// try-with-resources 会在 close() 里等所有任务结束
for (Future<Order> f : futures) {
results.add(f.get());
}
}关键心态转变:虚拟线程是廉价的、一次性的。给它做池化是反模式——池化的意义是复用昂贵资源,而虚拟线程的创建成本大致相当于一个普通对象。
2.3 结构化并发(注意:仍是预览特性)
需要「一批子任务要么全成功、要么一起取消」的语义时,StructuredTaskScope 比手写 Future 列表清楚得多:
// 需要 --enable-preview 编译和运行
// API 在 JDK 25(JEP 505)/26(JEP 525) 之间仍有调整,生产使用请锁定 JDK 版本
Order loadOrderPage(long orderId) throws Exception {
try (var scope = StructuredTaskScope.open()) {
var order = scope.fork(() -> orderClient.get(orderId));
var user = scope.fork(() -> userClient.get(orderId));
var coupons = scope.fork(() -> couponClient.list(orderId));
scope.join(); // 任一子任务抛异常,其余会被自动取消
return assemble(order.get(), user.get(), coupons.get());
}
}注意时间点:结构化并发到 JDK 26 依然是 preview API(JEP 525),截至 2026 年 9 月仍未定稿。想在生产用,要接受编译参数和 API 可能变动的成本。相比之下虚拟线程本身(JEP 444)从 JDK 21 就是正式特性,没有这个顾虑。
配套的还有 Scoped Values(JDK 25 定稿,JEP 506),它是替代 InheritableThreadLocal 传递请求上下文的正确方式,并且能自动传播进 StructuredTaskScope 的子任务。
三、陷阱清单
3.1 pinning:先确认你的 JDK 版本
早期资料里最常见的告警是「synchronized 块里阻塞会 pin 住载体线程」。这条在 JDK 24 之后已经基本不成立了:JEP 491 让在 synchronized 中阻塞的虚拟线程也能释放载体线程。
实测很直观:1,000 个虚拟线程各自进入一个独立的 synchronized 块(没有锁竞争),在块内阻塞 50ms。在同一个 6 核容器里:
| JDK | synchronized 内阻塞 | ReentrantLock 内阻塞 |
|---|---|---|
| 21.0.12 | 8,783 ms | 63 ms |
| 25.0.4 | 66 ms | 63 ms |
JDK 21 上的 8,783 ms 约等于 1000 ÷ 6 × 50ms:每个被 pin 住的虚拟线程独占一个载体线程,并发度退化成了核数。本机 10 核的 JDK 21.0.5 上是 5,426 ms,同样符合 1000 ÷ 10 × 50ms。注意这里没有任何锁竞争,问题只来自「在 synchronized 里阻塞」这一件事。
所以判断标准要按版本来:
| JDK 版本 | synchronized 中阻塞 | 结论 |
|---|---|---|
| 21 – 23 | 会 pin 住载体线程 | 需要把热点路径的 synchronized 换成 ReentrantLock |
| 24+ | 不再 pin | 不用为此重写代码 |
仍然会 pin 的情况只剩下:本地方法调用(JNI)内部阻塞,以及类初始化器里阻塞。这两种在业务代码里都不常见。
如果你还在 21/22 上,可以这样定位:
# JDK 21/22:打印被 pin 住的调用栈
java -Djdk.tracePinnedThreads=full -jar app.jar更通用的做法是开 JFR,jdk.VirtualThreadPinned 事件会直接告诉你 pin 发生在哪:
java -XX:StartFlightRecording=duration=60s,filename=vt.jfr \
-jar app.jar
jfr summary vt.jfr | grep -i pinned3.2 ThreadLocal 从「省内存」变成「费内存」
线程池时代 200 个线程复用,200 份 ThreadLocal 副本,随便用。虚拟线程时代每个请求一个线程,10 万并发就是 10 万份副本。
// 危险:每个虚拟线程一份,缓存对象越大越致命
private static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 改法一:换成本身线程安全的实现,彻底不用 ThreadLocal
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
// 改法二:需要传上下文时,用 ScopedValue(JDK 25 定稿)
private static final ScopedValue<TraceContext> TRACE = ScopedValue.newInstance();
ScopedValue.where(TRACE, ctx).run(() -> handleRequest(req));顺带一提,MDC(日志上下文)通常基于 ThreadLocal,迁移后要专门测一遍 traceId 有没有丢。
3.3 限流的位置要重新想
线程池本身就是一道天然限流:池子满了就排队或拒绝,下游不会被打穿。换成虚拟线程之后这道闸门消失了,一万个请求会变成一万个虚拟线程同时冲向下游。
必须显式补回来:
// 用信号量替代原来线程池扮演的限流角色
private final Semaphore dbGate = new Semaphore(50);
Order query(long id) throws InterruptedException {
dbGate.acquire();
try {
return orderMapper.selectById(id);
} finally {
dbGate.release();
}
}这不是可选项。迁移后压测把下游打挂,绝大多数是漏了这一步。
3.4 监控口径会变
原来看「线程池活跃线程数 / 队列长度」判断压力,迁移后这两个指标没有意义了。要换成看:
- 载体线程的实际忙碌程度。虚拟线程由一个独立的
ForkJoinPool调度,不是ForkJoinPool.commonPool;它的并行度默认等于可用核数,可以用-Djdk.virtualThreadScheduler.parallelism调整 - 各个信号量/连接池的等待时间(新的排队点在这里)
- JFR 的
jdk.VirtualThreadStart/jdk.VirtualThreadEnd事件速率
四、验证:怎么证明改对了
只看 QPS 涨了没有是不够的,容易把「下游被打爆导致快速失败」误读成吞吐提升。建议至少测三组:
# 1. 基线:迁移前,逐级加压,记录 RT 分位数与错误率
wrk -t8 -c200 -d60s --latency http://localhost:8080/api/orders/1
wrk -t8 -c1000 -d60s --latency http://localhost:8080/api/orders/1
# 2. 迁移后同样两档,对比 p99 与错误率
# 3. 极端并发,验证限流是否生效(错误率应可控,而不是雪崩)
wrk -t8 -c5000 -d60s --latency http://localhost:8080/api/orders/1判定标准建议这样定:
- p99 RT 不劣化,且高并发档位下吞吐上升 → 迁移有效
- 吞吐上升但错误率也上升 → 你把压力转移给下游了,回到 3.3
- 吞吐几乎不变 → 你的服务大概不是 I/O 密集型,或者瓶颈在连接池
另外记得测一遍慢下游场景:把某个依赖的响应时间人为拉到 3 秒,看服务会不会因为虚拟线程无上限堆积而 OOM。这是线程池时代不会遇到的新故障模式。
五、一份可执行的迁移顺序
- 确认 JDK 版本 ≥ 24(省掉大量
synchronized改造工作) - 挑一个 I/O 占比高、故障影响小的服务作为试点
- 压测拿基线数据,特别是 p99 和错误率
- 打开虚拟线程开关,先不改任何业务代码
- 审计
ThreadLocal使用点,该换ScopedValue的换掉 - 给所有下游调用补上信号量限流
- 重新压测三组场景,对比基线
- 上线后观察一个完整业务周期,重点看内存曲线和下游错误率
小结
虚拟线程是一次调度模型的升级,不是性能魔法。它把「线程」这个稀缺资源变成廉价资源,代价是原本由线程池承担的限流职责需要你显式接管。迁移的技术动作很小,风险几乎都集中在限流缺失和上下文传递上。
先算清楚你的服务是不是 I/O 密集,再决定要不要动手。
配套实验
- codesphere-labs/java/virtual-threads:平台线程池与虚拟线程的耗时、20 个连接的上限、JDK 21 与 25 在
synchronized内阻塞的 pinning 对照(验证记录)
参考资料