虚拟线程迁移:适用边界与结构化并发
本页与 虚拟线程模型与 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 时间片是一样的。
// ❌ 仅仅把 CPU 密集任务改为虚拟线程,通常不会获得计算吞吐方面的加速
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (Image img : images) {
pool.submit(() -> resize(img)); // 每个任务持续 CPU 运算
}
}
// 100 万个 VT 只能在 N_CPU 条 Carrier 上排队,不如直接 ForkJoinPool// ✅ CPU 密集:固定大小的平台线程池
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);判断方向:CPU 密集且持续占用处理器时间的任务适合固定大小的平台线程池;任务是否适合虚拟线程不能只看一个 CPU 占比阈值,还要比较单任务的计算时长、阻塞比例、上下文切换开销和压测结果。
1.2 需要控制下游并发或请求速率的场景
传统线程池的有界队列与拒绝策略可以限制排队数量和过载,但不一定自动把压力传播给上游。虚拟线程模型下"来一个任务起一条 VT",如果下游是有并发上限的资源(数据库连接池、下游 API 的 QPS 配额),需要外挂限流。要注意并发限制与速率限制是两回事:Semaphore 控制的是同时执行的任务数,不是单位时间内的请求速率;速率限制需要令牌桶、漏桶或其他专用限流器。
// ❌ 虚拟线程直接调下游,可能瞬间把下游打挂
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (long i = 0; i < 100_000; i++) {
pool.submit(() -> downstreamApi.call()); // 10 万并发调用
}
}// ✅ 用 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 的问题
有了廉价的虚拟线程,人们开始一次派生成百上千的子任务。此时"父子任务生命周期"变成了新问题:
// ❌ 派生子任务后失控
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) 用一个语法块把父子任务生命周期绑在一起:作用域内派生的所有任务必须在作用域退出前完成。
// ✅ 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 | 任一子任务成功则取消其余 | 从多副本读取,取最先成功的结果 |
// 场景 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 天然支持超时和外部取消:
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 或专用限流器 |
| 上下文传递 | TransmittableThreadLocal | JDK 21:ScopedValue(Preview);JDK 25:ScopedValue(正式) |
| 请求超时 | Future.get(timeout) | StructuredTaskScope.joinUntil |
| 命名与排查 | ThreadFactory + 命名规范 | Thread.ofVirtual().name(prefix, seq) |
判断决策:
4. 一段完整示例:从传统 API 迁移到虚拟线程
假设一个订单详情接口:聚合 UserService、OrderService、InventoryService 三处数据,任一失败即失败,总超时 500 ms。
// 迁移前: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);
}
}// 迁移后:虚拟线程 + 结构化并发
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 结构化并发 |