Skip to content

虚拟线程迁移:适用边界与结构化并发 ​

本页与 虚拟线程模型与 Pinning(JDK 21) 配套,重点说明适用边界、结构化并发、线程池取舍和迁移示例。

版本基线:本页以 JDK 21 LTS 为主线,结构化并发与 Scoped Value 的示例需要启用 Preview API。JDK 25 的 Scoped Value 已由 JEP 506 正式定稿;Structured Concurrency 仍由 JEP 505 作为第五轮 Preview 提供,API 形态与 JDK 21 不同。

1. 何时不要用虚拟线程 ​

虚拟线程不是万能替代。以下场景需要谨慎采用虚拟线程,或继续使用平台线程。

1.1 CPU 密集任务 ​

虚拟线程解决的是"线程数受限"的问题,不是"CPU 算得慢"的问题。一段跑满 CPU 的循环,无论跑在虚拟线程还是平台线程上,占用的 Carrier / OS 时间片是一样的。

java
// ❌ 仅仅把 CPU 密集任务改为虚拟线程,通常不会获得计算吞吐方面的加速
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Image img : images) {
        pool.submit(() -> resize(img));  // 每个任务持续 CPU 运算
    }
}
// 100 万个 VT 只能在 N_CPU 条 Carrier 上排队,不如直接 ForkJoinPool
java
// ✅ CPU 密集:固定大小的平台线程池
ExecutorService pool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

判断方向:CPU 密集且持续占用处理器时间的任务适合固定大小的平台线程池;任务是否适合虚拟线程不能只看一个 CPU 占比阈值,还要比较单任务的计算时长、阻塞比例、上下文切换开销和压测结果。

1.2 需要控制下游并发或请求速率的场景 ​

传统线程池的有界队列与拒绝策略可以限制排队数量和过载,但不一定自动把压力传播给上游。虚拟线程模型下"来一个任务起一条 VT",如果下游是有并发上限的资源(数据库连接池、下游 API 的 QPS 配额),需要外挂限流。要注意并发限制与速率限制是两回事:Semaphore 控制的是同时执行的任务数,不是单位时间内的请求速率;速率限制需要令牌桶、漏桶或其他专用限流器。

java
// ❌ 虚拟线程直接调下游,可能瞬间把下游打挂
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (long i = 0; i < 100_000; i++) {
        pool.submit(() -> downstreamApi.call());   // 10 万并发调用
    }
}
java
// ✅ 用 Semaphore 显式限流
Semaphore rateLimiter = new Semaphore(100);   // 下游允许 100 并发
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (long i = 0; i < 100_000; i++) {
        pool.submit(() -> {
            rateLimiter.acquire();
            try {
                downstreamApi.call();
            } finally {
                rateLimiter.release();
            }
        });
    }
}

1.3 ThreadLocal 密集使用的路径 ​

虚拟线程完全支持 ThreadLocal(在第 3 章讨论过它的存储结构)。虚拟线程数量增加后需要提防:大量 VT × 每 VT 若干 TL 值,可能显著放大堆内存占用。

下面只按 TL value 本身估算,不包含 Entry、键、对象头和对齐开销:若一个请求链路挂 10 个 TL 值、每个约 1 KB,2 000 条平台线程约对应 20 MB,200 000 条虚拟线程约对应 2 GB。真实占用还要看对象大小、共享引用和生命周期。

应对方向:

  • 对只需沿调用链传递、无需任意修改的只读上下文值,可以评估 JDK 21 的 Preview API ScopedValue(JDK 25 起正式定稿)。它与 ThreadLocal 的生命周期和可变性语义不同,不能不加区分地替换
  • 拆分 TL:只把真正需要跨方法透传的东西放进 TL,其余通过参数传递
  • 关键路径改造完之前,用 JFR、分配分析和堆快照观察 ThreadLocal 值与相关对象的增长

1.4 依赖平台线程语义的库 ​

少量库依赖 Thread 的平台线程语义,例如:

  • 通过 Thread.currentThread().getContextClassLoader() 做类隔离的框架
  • 依赖 OS 线程亲和性(thread affinity)的高性能库
  • 用 Thread 的堆栈作为标识做 profiling 的工具

对这些场景,如果切到虚拟线程后行为异常,最保险的做法是:入口保持平台线程,把 IO 密集部分显式提交到 newVirtualThreadPerTaskExecutor。

2. 结构化并发:StructuredTaskScope ​

2.1 传统 fire-and-forget 的问题 ​

有了廉价的虚拟线程,人们开始一次派生成百上千的子任务。此时"父子任务生命周期"变成了新问题:

java
// ❌ 派生子任务后失控
Future<User> fUser = executor.submit(() -> userApi.get(id));
Future<Order> fOrder = executor.submit(() -> orderApi.get(id));

try {
    User user = fUser.get();
    Order order = fOrder.get();
    return new Profile(user, order);
} catch (ExecutionException e) {
    // 一个失败了,另一个仍在跑!
    // 需要手动 cancel(true),还要处理各种异常路径
    fUser.cancel(true);
    fOrder.cancel(true);
    throw e;
}

传统 ExecutorService 的问题:

  • 父任务失败/超时时,子任务不会自动取消,容易泄漏
  • 子任务的异常传播路径复杂,必须手写 try/finally 骨架
  • 从 thread dump 看不出"这几条 VT 属于同一个父任务"

2.2 结构化并发的核心约束 ​

结构化并发(Structured Concurrency) 用一个语法块把父子任务生命周期绑在一起:作用域内派生的所有任务必须在作用域退出前完成。

java
// ✅ JDK 21 Preview API
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>  user  = scope.fork(() -> userApi.get(id));
    Subtask<Order> order = scope.fork(() -> orderApi.get(id));

    scope.join();              // 等所有子任务结束或被取消
    scope.throwIfFailed();     // 任一失败则抛出

    return new Profile(user.get(), order.get());
}   // 作用域退出:残留子任务自动取消

三条关键性质:

性质意义
作用域内 fork 的子任务,正常退出前必须结束作用域统一管理子任务生命周期
任一子任务失败,ShutdownOnFailure 请求取消其余未完成任务快速失败
Thread dump 能看到父子层级排查友好

取消通常通过中断实现,是协作式机制。如果子任务忽略中断,或卡在不响应中断的操作中,作用域关闭可能一直等待它结束,因此"不泄漏"并非强制终止的保证。

2.3 两种收敛策略 ​

策略语义典型场景
ShutdownOnFailure任一子任务失败则取消其余并行下游都必须成功(聚合 A、B、C 三个 API)
ShutdownOnSuccess任一子任务成功则取消其余从多副本读取,取最先成功的结果
java
// 场景 A:三个下游都要成功
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var a = scope.fork(this::callA);
    var b = scope.fork(this::callB);
    var c = scope.fork(this::callC);
    scope.join().throwIfFailed();
    return merge(a.get(), b.get(), c.get());
}

// 场景 B:多个副本取最快
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Result>()) {
    scope.fork(() -> replica1.query());
    scope.fork(() -> replica2.query());
    scope.fork(() -> replica3.query());
    return scope.join().result();
}

2.4 超时与取消 ​

StructuredTaskScope 天然支持超时和外部取消:

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user  = scope.fork(() -> userApi.get(id));
    var order = scope.fork(() -> orderApi.get(id));

    scope.joinUntil(Instant.now().plusSeconds(2));   // 等待截止时间
    scope.throwIfFailed();

    return new Profile(user.get(), order.get());
} catch (TimeoutException e) {
    // joinUntil 超时后,异常退出 try 块会关闭作用域。
    // 关闭过程会请求取消未完成的子任务,并等待它们结束。
    // 如果下游调用不响应中断,关闭过程仍可能继续等待。
    throw new ServiceUnavailableException(e);
}

joinUntil 设定的是等待截止时间,不是保证整个方法在该时间内返回的硬性时限。若下游 HTTP 客户端不响应线程中断,或底层 I/O 没有自己的请求超时,业务期限并不意味着到期后一定能把方法返回给调用者。生产环境应同时设置下游客户端自身的连接、读取或整体请求超时。比起手工写 Future.get(timeout, unit) 加上一堆 cancel,代码更短且更难写错。

2.5 API 稳定性提示 ​

StructuredTaskScope 在 JDK 21 是第一轮 Preview;JDK 22–24 持续调整;JDK 25 仍按 JEP 505 作为第五轮 Preview 提供,并改用 StructuredTaskScope.open(...)、Joiner、Subtask 等 API。JDK 21 示例不能直接视为 JDK 25 GA 示例,迁移时应以目标 JDK 的 JEP 和 API 文档为准。

3. 虚拟线程时代重新评估线程池 ​

用一张表总结虚拟线程 GA 之后传统线程池经验哪些还成立、哪些需要重估:

维度平台线程池的经验虚拟线程时代的调整
IO 密集处理以 2 × N_CPU 等经验值作为压测起点,配置队列与拒绝策略通常从 newVirtualThreadPerTaskExecutor 开始,并显式限流
CPU 密集计算poolSize = N_CPU + 1 可作起点,仍需按任务粒度调整通常继续使用固定大小的平台线程池
定时调度ScheduledThreadPoolExecutor保持不变(VT 无 scheduled 变体)
天然限流依赖 maxPoolSize + BoundedQueue显式 Semaphore 或专用限流器
上下文传递TransmittableThreadLocalJDK 21:ScopedValue(Preview);JDK 25:ScopedValue(正式)
请求超时Future.get(timeout)StructuredTaskScope.joinUntil
命名与排查ThreadFactory + 命名规范Thread.ofVirtual().name(prefix, seq)

判断决策:

4. 一段完整示例:从传统 API 迁移到虚拟线程 ​

假设一个订单详情接口:聚合 UserService、OrderService、InventoryService 三处数据,任一失败即失败,总超时 500 ms。

java
// 迁移前:CompletableFuture 版本
public Profile loadProfile(String id) {
    CompletableFuture<User>      fUser  = CompletableFuture.supplyAsync(() -> userApi.get(id), pool);
    CompletableFuture<Order>     fOrder = CompletableFuture.supplyAsync(() -> orderApi.get(id), pool);
    CompletableFuture<Inventory> fInv   = CompletableFuture.supplyAsync(() -> invApi.get(id),   pool);

    try {
        return CompletableFuture.allOf(fUser, fOrder, fInv)
            .orTimeout(500, TimeUnit.MILLISECONDS)
            .thenApply(v -> new Profile(fUser.join(), fOrder.join(), fInv.join()))
            .join();
    } catch (CompletionException e) {
        // 尝试取消 Future,但不保证中断底层 HTTP / 数据库调用。
        // 真正停止下游工作还需依赖底层 API 支持的取消机制。
        fUser.cancel(true); fOrder.cancel(true); fInv.cancel(true);
        throw unwrap(e);
    }
}
java
// 迁移后:虚拟线程 + 结构化并发
public Profile loadProfile(String id) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<User>      user  = scope.fork(() -> userApi.get(id));
        Subtask<Order>     order = scope.fork(() -> orderApi.get(id));
        Subtask<Inventory> inv   = scope.fork(() -> invApi.get(id));

        scope.joinUntil(Instant.now().plusMillis(500));
        scope.throwIfFailed();

        return new Profile(user.get(), order.get(), inv.get());
    }
}

结构化并发将子任务创建、等待、失败处理和取消组织在同一个作用域内,减少了手动维护多个 Future 的工作,异常传播路径与取消路径更集中、更易推理。但它不能强制终止忽略中断的任务,也不能替代下游客户端的请求超时。迁移时应同时设计两层超时:业务层定义整个操作的截止时间,下游客户端限制单次 I/O 的最长等待时间。

5. 本页小结 ​

问题根源解决方案
平台线程数量受硬限制OS 1:1 模型 + 上下文切换成本虚拟线程的 M:N 调度
Reactor 代码断裂、调试困难异步链式编程范式同步风格的虚拟线程
synchronized 阻塞 IO 让 Carrier 被钉死JDK 21–23 的实现限制迁移到 ReentrantLock 或升级 JDK 24+
CPU 密集任务用虚拟线程无收益Carrier 数量仍受 CPU 核数限制CPU 密集仍用平台线程池
无天然背压导致下游被打挂newVirtualThreadPerTaskExecutor 无并发上限外挂 Semaphore 或专用限流器
子任务派生后失控、异常传播复杂平面式的 Future 组合StructuredTaskScope 结构化并发

Released under the MIT License.