这个博客的并发文章已经有五篇:《虚拟线程实战》讲它什么时候有用、《虚拟线程迁移实测》给出改造前后的数字、《一次锁竞争的完整定位》讲出事怎么查、《结构化并发实战》讲谁负责善后、《ScopedValue 替代 ThreadLocal》讲上下文怎么传。
它们各自回答了一个问题,却没人回答那个每天都要回答的问题:这段代码该用哪件工具
这篇就是那张对照表:请求扇出、CPU 密集计算、超时与取消、批处理限流、上下文传递,每个推荐都附「什么时候不要用」和出处;唯一新加的实验只有 4 秒。
版本基线是 JDK 25(LTS):虚拟线程(JEP 444)与 ScopedValue(JEP 506)已转正,StructuredTaskScope 仍是预览 API,编译运行都要 --enable-preview,形态是 open() + Joiner——new StructuredTaskScope.ShutdownOnFailure() 那套是 JDK 21/24 的写法,在 25 上编译不过。

一张决策表

场景 首选 理由(实测) 什么时候不要用
单请求扇出 2–10 个下游,每个结果都要 StructuredTaskScope.open()(JDK 25 预览) 失败短路:一路 100ms 失败,两个 3000ms 任务在 108ms 被中断,总耗时 137ms;出作用域 running = 0 只有一个下游就同步调用;团队还在 JDK 21/24(API 不同);任务要跨请求存活时它不属于任何作用域
扇出到多个副本,只要最快的一个 Joiner.anySuccessfulResultOrThrow() 250ms 的那路赢,总耗时 272ms,其余在 close() 时被取消 每个结果都要用时:被取消的子任务是 UNAVAILABLE,结果无人认领,下游也白打一遍
同样的扇出,但要留在 JDK 8+ CompletableFuture + 有界线程池 + 自己写取消 正式 API,没有被预览特性绑住的迁移成本 指望 allOf(...).join() 快速失败(实测 3015ms);指望 get(timeout) 取消任务(它只是放弃等待)
CPU 密集计算 固定平台线程池(≈核数)或并行流 10/100 个计算任务下与虚拟线程的耗时差在 ±10% 噪声内;此时载体数即吞吐上限(并发 100、每请求 10ms CPU:2/4/10 条载体 → 196/394/931 QPS) 不要在 CPU 任务上套虚拟线程「提速」;深递归调用链还要另算栈的账
高并发阻塞式 IO(峰值并发远超池容量) 每任务一条虚拟线程 5000 并发:50898 对 3659 QPS(13.9 倍),p50 99.19ms 对 1366.06ms 峰值并发不超过池容量时是负优化:并发 50 慢 2.8%,并发 200 慢 11%
需要超时与取消传播 作用域配置 withTimeout;或 CF 的 orTimeout 加自己收尾 200ms 超时,三个下游在 199~200ms 同时被中断,300ms 后 running = 0 下游不响应中断时,超时能抛出,但 close() 会一直等它——取消是协作式的
批处理限流 虚拟线程 + Semaphore(或下游连接池) 8 个许可:峰值并发 8,总耗时与固定池持平(869ms 对 864ms) 继续拿「池容量」当限流器:迁到虚拟线程后这个开关直接消失
上下文(租户 ID / traceId)单向向下传递 ScopedValue(JDK 25 转正) 出作用域即失效,不跨请求残留;fork() 出的子任务自动继承 需要双向传递、跨非 fork 线程传递、线程级缓存(SimpleDateFormat 这类)时不要迁
长生命周期后台任务 普通平台线程池或调度器 它不属于任何请求作用域,也不该被作用域管 不要硬塞进 StructuredTaskScope:它要求子任务在块结束前结束

为什么不直接用 CompletableFuture 扇出

需求与《结构化并发实战》一致:并发调三个下游(各 3000ms),一路在 100ms 后返回 500,要求尽快失败并停掉其它任务,超时 200ms。三种写法的实测结果:

CompletableFuture.allOf ExecutorCompletionService 结构化并发
超时后残留任务数 3(要自己 cancel 0(手写 cancel) 0(框架负责)
快速失败耗时 3015ms(等全部) 109ms 112ms
取消代码 调用方写 调用方写
作用域外还有孤儿任务 可能 可能 不可能

三个事实:

  1. allOf 只负责「等」和「抛」。 200ms 超时抛了 TimeoutException(耗时 209ms),但 500ms 后再看,三个下游一个不少还在跑;取消得调用方挨个 cancel(true),而且对方要响应中断。
  2. 不设超时直接 join(),语义是等全部结束。 失败发生在第 100ms,异常却在 3015ms 才交到你手上;ExecutorCompletionService 能把发现失败压到 109ms,代价是那段样板代码每个项目重写一遍,漏一处 cancel(true) 就留下残留。
  3. 结构化并发的差别在「谁负责收尾」。 同一场景 112ms 停下,没有一行取消代码:try-with-resources 的右花括号是真实的同步点,close() 会取消剩余子任务并等它们终止。

JDK 25 上的写法(在 Apple M1 Pro 上编译运行过):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;

/** 需要 JDK 25 + --enable-preview。 */
public class FanoutDemo {

static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

static String call(String name, long ms) throws InterruptedException {
Thread.sleep(ms);
return name + "@" + TRACE_ID.get();
}

public static void main(String[] args) throws Exception {
String summary = ScopedValue.where(TRACE_ID, "trace-7f3a").call(() -> {
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow(),
cfg -> cfg.withTimeout(Duration.ofMillis(200)).withName("下游"))) {
scope.fork(() -> call("价格", 150));
scope.fork(() -> call("库存", 80));
scope.fork(() -> call("促销", 120));
return String.join(" | ", scope.join().map(Subtask::get).toList());
}
});
System.out.println("汇总: " + summary);
}
}
1
2
3
4
5
$ javac --release 25 --enable-preview FanoutDemo.java
注: FanoutDemo.java 使用 Java SE 25 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ java --enable-preview -cp . FanoutDemo
汇总: 价格@trace-7f3a | 库存@trace-7f3a | 促销@trace-7f3a

两处选型要点:

  • 入口只有 open() 不传参数时默认策略是「全部成功,否则抛」(等价于旧的 ShutdownOnFailure),策略由 Joiner 表达,超时属于 ConfigurationallSuccessfulOrThrow() 在 JDK 25 上 join() 返回 Stream<Subtask<T>>(编译验证过,拿结果要 .map(Subtask::get).toList()),26、27 还在改签名。
  • fork() 返回 Subtask(只有 get()exception()state()),Subtask.StateFAILEDUNAVAILABLE 分开——「部分成功」场景靠的就是这个区分;ScopedValue 绑定又只被 fork() 的子任务继承,所以上下文注入和扇出能在同一个词法块里完成。

什么时候仍然该用 CompletableFuture

结构化并发解决的是「一组短命任务在同一段代码里 fork/join」;任务要跨方法、跨请求边界传递,要「先把 future 存起来、稍后再接回调」,或必须留在 JDK 8+ 的稳定 API 上时,CompletableFuture 依然更合适。两者不是二选一——作用域内部,子任务自己仍可以是 CF 的编排;分界线是任务之间是否共享一个明确的、词法上的生命周期。用 CF 时记住两件事:get(timeout)orTimeout 只是放弃等待、任务照跑;默认执行器是公共 ForkJoinPool(并行度「核数 − 1」),阻塞 IO 必须显式传 Executor。

线程池还该不该留

该留,但职责要重新划。

还该留的三种场合

  1. CPU 密集计算。 继续用固定平台线程池(≈核数)或并行流:10 个和 100 个计算任务各跑一遍,两种实现的耗时差落在 ±10% 噪声内、总 CPU 时间相同。
  2. 长生命周期的后台任务。 定时报表、队列消费者这类不属于任何请求作用域的工作归普通线程池或调度器;硬塞进 StructuredTaskScope 会违背「子任务必须在作用域结束前结束」的前提。
  3. 有连接池上限的下游。 JDBC、HTTP 客户端的并发天花板是连接池(HikariCP 默认 10),线程池在这里只是外壳。

不再该留的:当限流器,当虚拟线程的容器

「把并发压到 20,服务就不会被打爆」这个老习惯的前提是线程池容量等于并发上限。迁到虚拟线程后这个开关就没了,必须显式换成 Semaphore(许可数按下游承受能力设)。排队本身不省内存,一万个虚拟线程等在信号量上就是一万份堆上的栈对象。

另一件该停掉的是池化虚拟线程:newFixedThreadPool(200, Thread.ofVirtual().factory()) 在 5000 并发下只有 3456 QPS,比固定平台池还低——虚拟线程的语义就是消耗品,用完即弃比复用便宜。

容器里,「核数」这个数字会变

容器里的 JVM 看到的是另一台机器,三处默认值会静默变化(eclipse-temurin + JDK 25 实测):

  • MaxRAMPercentage 默认 25——512MB 的容器只有 123MB 堆(MaxHeapSize=134217728),想给到 75% 要显式写 -XX:MaxRAMPercentage=75
  • 核数向上取整--cpus 1.1/1.5 → 2 核,2.1/2.5 → 3 核,3.9 → 4 核。只写整数。
  • GC 会换掉:可用核数为 1 时选 Serial(内存给到 8GB 也一样),≥2 才用 G1。

这三条会连锁到并发代码:公共 ForkJoinPool 并行度(核数 − 1)、CompletableFuture 的默认执行器、parallelStream() 和不少框架的默认线程数都跟着 availableProcessors() 走——「池大小 = 核数」这个公式到容器里要重新算

Spring Boot 的 server.tomcat.threads.max 默认 200(与那篇的固定池 200 是同一个位置上的参数,这一段是类比、未在本机跑 Tomcat 验证)——迁到虚拟线程后它就该从调优清单上退场(迁移实测里 200 池在 1000、5000 并发下都停在 3660 QPS 上下,多出来的并发只变成排队延迟)。

载体线程数不是并发上限

虚拟线程的调度器 parallelism 默认等于可用核数,但只有 CPU 占比上去之后它才等于吞吐:

负载 parallelism 2 4 10(默认)
纯阻塞(5000 并发,每请求 50ms sleep) 57318 QPS 57219 QPS 50963 QPS
每请求烧 10ms CPU(100 并发) 196 QPS 394 QPS 931 QPS

纯阻塞负载里 2 条载体就能把 5000 并发跑到 57318 QPS(阻塞时虚拟线程卸载),那条服务每请求只花 6977µs CPU、墙钟 5457ms——需要载体的并发数约等于「并发 × CPU 占比」≈ 6.5:不确定服务在烧 CPU 就别调它。另注意平台线程数会一直停在几十条(实测峰值 22 条),线程 dump 要用 jcmd <pid> Thread.dump_to_file -format=json

上下文传递:ThreadLocal 还是 ScopedValue

单向、请求级、要能被 fork() 出的子任务读到的上下文(租户 ID、traceId、用户)用 ScopedValue线程级缓存和双向传递继续用 ThreadLocal

实测决定了这条线(细节在《ScopedValue 替代 ThreadLocal》):

  • 生命周期跟着线程走。 往固定池线程塞 32MB、任务结束并丢掉引用,池存活期间占用仍是 34MB(基线 1MB):持有者就是那条池线程,remove() 是唯一解药;换成 ScopedValue,出作用域那一刻回到 1MB。
  • 残留会跨请求,继承也不可靠。 上一个任务 set 的 “alice” 没清理,下一个任务在同一线程上就读到 alice(同位置 ScopedValue.isBound()false);InheritableThreadLocal线程创建那一刻继承,同一次 set,早创建的池线程读到 null、晚创建的读到 bob:在生产里就是随机故障。

版本状态:JEP 429 孵化、446/464/481/487 四次预览,JEP 506 在 JDK 25 转正——要 --enable-preview 的是 StructuredTaskScope 而不是它;两者写进同一个文件时整个文件仍要加开关。

绑定只被 fork() 的子任务继承。七种创建线程的方式逐一试过,只有 fork(...) 能读到父作用域的绑定,Thread.ofVirtual().startstartVirtualThread、任何 ExecutorServiceForkJoinPool.commonPool() 都读不到。所以「只换 ScopedValue、任务还是往普通池里提交」等于白迁——值用 ScopedValue 传,任务用 fork() 发,两件事要成对做。

该不该迁,核心三行:请求上下文单向向下传递 → 迁;跨线程传递 → 迁 + 改造成结构化并发;线程级缓存(SimpleDateFormat 这类)、需要双向传递、需要作用域外长期保留值 → 不迁。很多 SimpleDateFormat 缓存直接换成 static finalDateTimeFormatter 即可。

一段可复现的对照

前面引用的都是几百行服务加压测客户端的实验,这里换一个 4 秒跑完的最小对照:60 个任务各 Thread.sleep(100ms),三种执行方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

/**
* 同一份阻塞任务(每个任务 Thread.sleep(100ms)),三种执行方式:
* pool 固定池:8 条平台线程
* virtual 每任务一条虚拟线程,不设上限
* vsem 每任务一条虚拟线程 + Semaphore(8) 显式限流
* 用法:java ExecSelectDemo <pool|virtual|vsem> [repeats]
*/
public class ExecSelectDemo {

static final int TASKS = 60;
static final long BLOCK_MS = 100;
static final int LIMIT = 8;

public static void main(String[] args) throws Exception {
String mode = args[0];
int repeats = args.length > 1 ? Integer.parseInt(args[1]) : 1;
for (int r = 0; r < repeats; r++) {
run(mode);
}
System.exit(0);
}

static void run(String mode) throws Exception {
Semaphore permits = new Semaphore(LIMIT);
AtomicInteger inFlight = new AtomicInteger();
AtomicInteger peak = new AtomicInteger();

long t0 = System.nanoTime();
try (ExecutorService executor = switch (mode) {
case "pool" -> Executors.newFixedThreadPool(LIMIT);
case "virtual", "vsem" -> Executors.newVirtualThreadPerTaskExecutor();
default -> throw new IllegalArgumentException(mode);
}) {
for (int i = 0; i < TASKS; i++) {
executor.submit(() -> {
boolean acquired = false;
try {
if (mode.equals("vsem")) {
permits.acquire();
acquired = true;
}
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
try {
Thread.sleep(BLOCK_MS);
} finally {
inFlight.decrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
permits.release();
}
}
});
}
} // try-with-resources 的 close():等所有任务结束

long wallMs = (System.nanoTime() - t0) / 1_000_000;
System.out.printf(Locale.ROOT,
"MODE=%-8s tasks=%d sleepMs=%d wallMs=%d peakInFlight=%d%n",
mode, TASKS, BLOCK_MS, wallMs, peak.get());
}
}

本机 JDK 25(java version "25" 2025-09-16 LTS),每种模式各跑两轮:

1
2
3
4
5
6
7
8
9
10
$ javac ExecSelectDemo.java
$ java -cp . ExecSelectDemo pool 2
MODE=pool tasks=60 sleepMs=100 wallMs=864 peakInFlight=8
MODE=pool tasks=60 sleepMs=100 wallMs=864 peakInFlight=8
$ java -cp . ExecSelectDemo virtual 2
MODE=virtual tasks=60 sleepMs=100 wallMs=119 peakInFlight=60
MODE=virtual tasks=60 sleepMs=100 wallMs=110 peakInFlight=60
$ java -cp . ExecSelectDemo vsem 2
MODE=vsem tasks=60 sleepMs=100 wallMs=891 peakInFlight=8
MODE=vsem tasks=60 sleepMs=100 wallMs=869 peakInFlight=8

输出的读法:

  • 固定池 864ms。 8 个槽位、60 个任务就是 7.5 轮,每轮 100ms:池大小在这里同时是资源池和并发上限。
  • 虚拟线程 110~119ms。 60 个任务一起开始,墙钟约等于一次 sleeppeakInFlight = 60;而加回 Semaphore(8) 的 869~891ms 把峰值并发显式压回 8,总耗时与固定池持平:限流没消失,只是从隐含的池容量变成了代码里的一行。

口径说明:单机、小样本,看形状而不是取数;真实服务的量级在《虚拟线程迁移实测》——同一个阻塞型服务,固定池 200 在并发 1000/5000 时停在 3661/3659 QPS,虚拟线程是 16379/50898 QPS。

常见错误清单

  1. 把 CPU 密集任务丢进虚拟线程执行器「提速」:没有收益,只有调度开销(虚拟线程实战迁移实测)。
  2. 池化虚拟线程(newFixedThreadPool(n, Thread.ofVirtual().factory()))在 5000 并发下只有 3456 QPS,比固定平台池还低(迁移实测)。
  3. 迁到虚拟线程后仍靠池容量限流:开关已经消失,补 Semaphore 或下游连接池(虚拟线程实战)。
  4. 期望 allOf(...).join() 快速失败:它等全部结束(实测 3015ms)(结构化并发实战)。
  5. 以为 get(timeout)orTimeout 会停掉任务:它只是放弃等待(CompletableFuture 异步编程实战)。
  6. synchronized 块里做下游 IO:QPS 41.6、p50 1536.46ms 的经典形状(一次锁竞争的完整定位)。
  7. ThreadLocalremove(),或在里面缓存昂贵对象:池线程会替你按住它们(ScopedValue 替代 ThreadLocal)。
  8. 照抄 JDK 21/24 的 StructuredTaskScope 示例:25 起入口是 open(),旧示例编译不过(结构化并发实战)。
  9. 用普通线程池或公共 ForkJoinPool 去读 ScopedValue:只有 fork() 的子任务能继承(ScopedValue 替代 ThreadLocal)。
  10. 在容器里沿用笔记本上的核数与池大小:--cpus 2.5 会让 JVM 认为有 3 核,1 核时 GC 换 Serial,堆只有限额的 25%(容器里的 JVM)。

总结

  • 扇出要结果、失败要短路StructuredTaskScope.open()(JDK 25 预览,--enable-preview):失败短路 137ms 收尾、超时后 0 残留、零取消代码;它的价值是「谁负责善后」。
  • 只要最快的一个Joiner.anySuccessfulResultOrThrow()API 要紧跟 JDK 8CompletableFuture 加自己写的取消,但要接受 allOf 不短路、超时不打断。
  • CPU 密集 → 平台线程池(≈核数);高并发阻塞 IO → 每任务一条虚拟线程;低并发(峰值并发不超过池容量)时迁虚拟线程是负收益。
  • 限流 → 显式 Semaphore 或下游连接池,不要靠池容量,更不要池化虚拟线程。
  • 上下文ScopedValue 单向传递且必须与 fork() 成对使用;线程级缓存与双向传递仍归 ThreadLocal
  • 容器里重算一遍:核数向上取整、堆默认 25%、单核换 Serial。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集