Skip to content

虚拟线程迁移:收益、陷阱与验证方法 ​

虚拟线程不会让你的代码变快,它只会让你的代码在等待时不再浪费一个操作系统线程。搞清楚这句话,才知道哪些服务该迁移、哪些不该。

虚拟线程在 JDK 21 转正之后,很多团队第一反应是「把线程池换成 newVirtualThreadPerTaskExecutor 就完事了」。真实迁移里,收益来自哪、代价在哪、怎么证明改对了,这三件事比替换 API 本身重要得多。

一、先判断你的服务该不该迁移 ​

虚拟线程解决的是一个具体的浪费:线程阻塞在 I/O 上时,它占着的操作系统线程什么也做不了。

用一个简化模型算一下。假设接口 RT 是 200ms,其中 190ms 在等下游(RPC + 数据库),10ms 在真正算东西。传统 200 线程的池子,理论吞吐上限是:

text
200 线程 / 0.2s = 1000 QPS

再往上就得加线程,而每个平台线程默认占 1MB 栈空间,几千个线程之后调度开销和内存都开始难看。

虚拟线程的模型不一样:阻塞时它会从载体线程(carrier thread)上卸载,载体线程立刻去跑别的虚拟线程。吞吐上限变成由 CPU 实际工作量和下游承载能力决定:

text
可用核数 / 单请求 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 个任务,每个阻塞 50ms(JDK 21,10 核)平台线程池 200 线程5000 ÷ 200 × 50ms1,376 ms虚拟线程一个任务一个线程67 ms1,000 个任务,虚拟线程下游只有 20 个连接1000 ÷ 20 × 50ms2,756 ms1,000 个任务在各自的 synchronized 块内阻塞(同一容器,6 核)JDK 21被 pin 住,并发度 = 核数8,783 msJDK 25JEP 491 之后不再 pin66 ms
图 1 · 阻塞型任务换成虚拟线程后耗时从 1,376ms 降到 67ms;但下游只有 20 个连接时,耗时由连接数决定;JDK 21 上 synchronized 内阻塞会把并发度压到核数(横轴为对数刻度)
场景耗时理论值
5,000 个任务,平台线程池 200 线程1,376 ms5000 ÷ 200 × 50ms = 1,250 ms
5,000 个任务,虚拟线程67 ms约 50 ms
1,000 个任务,虚拟线程,但下游只有 20 个连接2,756 ms1000 ÷ 20 × 50ms = 2,500 ms

前两行说明收益:等待不再占用操作系统线程,5,000 个任务几乎同时完成。第三行说明上限:用信号量模拟一个只有 20 个连接的连接池,虚拟线程再多,耗时也由 20 个连接决定,和换虚拟线程之前的线程池没有本质区别。

实验代码的核心部分:

java
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 会为每个请求起一个虚拟线程:

properties
spring.threads.virtual.enabled=true

这个开关同时会把 @Async、Spring MVC 的异步请求处理都切到虚拟线程上。实测默认的 applicationTaskExecutor 从 8 个核心线程的 ThreadPoolTaskExecutor 换成了 SimpleAsyncTaskExecutor,它默认不限制并发,见 线程池参数怎么定 第二节。

2.2 手动管理任务 ​

java
// 每个任务一个虚拟线程,创建成本极低,不要复用、不要池化
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 列表清楚得多:

java
// 需要 --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 核容器里:

JDKsynchronized 内阻塞ReentrantLock 内阻塞
21.0.128,783 ms63 ms
25.0.466 ms63 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 上,可以这样定位:

bash
# JDK 21/22:打印被 pin 住的调用栈
java -Djdk.tracePinnedThreads=full -jar app.jar

更通用的做法是开 JFR,jdk.VirtualThreadPinned 事件会直接告诉你 pin 发生在哪:

bash
java -XX:StartFlightRecording=duration=60s,filename=vt.jfr \
     -jar app.jar
jfr summary vt.jfr | grep -i pinned

3.2 ThreadLocal 从「省内存」变成「费内存」 ​

线程池时代 200 个线程复用,200 份 ThreadLocal 副本,随便用。虚拟线程时代每个请求一个线程,10 万并发就是 10 万份副本。

java
// 危险:每个虚拟线程一份,缓存对象越大越致命
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 限流的位置要重新想 ​

线程池本身就是一道天然限流:池子满了就排队或拒绝,下游不会被打穿。换成虚拟线程之后这道闸门消失了,一万个请求会变成一万个虚拟线程同时冲向下游。

必须显式补回来:

java
// 用信号量替代原来线程池扮演的限流角色
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 涨了没有是不够的,容易把「下游被打爆导致快速失败」误读成吞吐提升。建议至少测三组:

bash
# 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。这是线程池时代不会遇到的新故障模式。

五、一份可执行的迁移顺序 ​

  1. 确认 JDK 版本 ≥ 24(省掉大量 synchronized 改造工作)
  2. 挑一个 I/O 占比高、故障影响小的服务作为试点
  3. 压测拿基线数据,特别是 p99 和错误率
  4. 打开虚拟线程开关,先不改任何业务代码
  5. 审计 ThreadLocal 使用点,该换 ScopedValue 的换掉
  6. 给所有下游调用补上信号量限流
  7. 重新压测三组场景,对比基线
  8. 上线后观察一个完整业务周期,重点看内存曲线和下游错误率

小结 ​

虚拟线程是一次调度模型的升级,不是性能魔法。它把「线程」这个稀缺资源变成廉价资源,代价是原本由线程池承担的限流职责需要你显式接管。迁移的技术动作很小,风险几乎都集中在限流缺失和上下文传递上。

先算清楚你的服务是不是 I/O 密集,再决定要不要动手。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权