Java——并发工具选型:从线程池到结构化并发
这个博客的并发文章已经有五篇:《虚拟线程实战》讲它什么时候有用、《虚拟线程迁移实测》给出改造前后的数字、《一次锁竞争的完整定位》讲出事怎么查、《结构化并发实战》讲谁负责善后、《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 |
| 取消代码 | 调用方写 | 调用方写 | 无 |
| 作用域外还有孤儿任务 | 可能 | 可能 | 不可能 |
三个事实:
allOf只负责「等」和「抛」。 200ms 超时抛了TimeoutException(耗时 209ms),但 500ms 后再看,三个下游一个不少还在跑;取消得调用方挨个cancel(true),而且对方要响应中断。- 不设超时直接
join(),语义是等全部结束。 失败发生在第 100ms,异常却在 3015ms 才交到你手上;ExecutorCompletionService能把发现失败压到 109ms,代价是那段样板代码每个项目重写一遍,漏一处cancel(true)就留下残留。 - 结构化并发的差别在「谁负责收尾」。 同一场景 112ms 停下,没有一行取消代码:
try-with-resources的右花括号是真实的同步点,close()会取消剩余子任务并等它们终止。
JDK 25 上的写法(在 Apple M1 Pro 上编译运行过):
1 | import java.time.Duration; |
1 | $ javac --release 25 --enable-preview FanoutDemo.java |
两处选型要点:
- 入口只有
open()。 不传参数时默认策略是「全部成功,否则抛」(等价于旧的ShutdownOnFailure),策略由Joiner表达,超时属于Configuration。allSuccessfulOrThrow()在 JDK 25 上join()返回Stream<Subtask<T>>(编译验证过,拿结果要.map(Subtask::get).toList()),26、27 还在改签名。 fork()返回Subtask(只有get()、exception()、state()),Subtask.State把FAILED与UNAVAILABLE分开——「部分成功」场景靠的就是这个区分;ScopedValue绑定又只被fork()的子任务继承,所以上下文注入和扇出能在同一个词法块里完成。
什么时候仍然该用 CompletableFuture
结构化并发解决的是「一组短命任务在同一段代码里 fork/join」;任务要跨方法、跨请求边界传递,要「先把 future 存起来、稍后再接回调」,或必须留在 JDK 8+ 的稳定 API 上时,CompletableFuture 依然更合适。两者不是二选一——作用域内部,子任务自己仍可以是 CF 的编排;分界线是任务之间是否共享一个明确的、词法上的生命周期。用 CF 时记住两件事:get(timeout)/orTimeout 只是放弃等待、任务照跑;默认执行器是公共 ForkJoinPool(并行度「核数 − 1」),阻塞 IO 必须显式传 Executor。
线程池还该不该留
该留,但职责要重新划。
还该留的三种场合
- CPU 密集计算。 继续用固定平台线程池(≈核数)或并行流:10 个和 100 个计算任务各跑一遍,两种实现的耗时差落在 ±10% 噪声内、总 CPU 时间相同。
- 长生命周期的后台任务。 定时报表、队列消费者这类不属于任何请求作用域的工作归普通线程池或调度器;硬塞进
StructuredTaskScope会违背「子任务必须在作用域结束前结束」的前提。 - 有连接池上限的下游。 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().start、startVirtualThread、任何 ExecutorService、ForkJoinPool.commonPool() 都读不到。所以「只换 ScopedValue、任务还是往普通池里提交」等于白迁——值用 ScopedValue 传,任务用 fork() 发,两件事要成对做。
该不该迁,核心三行:请求上下文单向向下传递 → 迁;跨线程传递 → 迁 + 改造成结构化并发;线程级缓存(SimpleDateFormat 这类)、需要双向传递、需要作用域外长期保留值 → 不迁。很多 SimpleDateFormat 缓存直接换成 static final 的 DateTimeFormatter 即可。
一段可复现的对照
前面引用的都是几百行服务加压测客户端的实验,这里换一个 4 秒跑完的最小对照:60 个任务各 Thread.sleep(100ms),三种执行方式。
1 | import java.util.Locale; |
本机 JDK 25(java version "25" 2025-09-16 LTS),每种模式各跑两轮:
1 | $ javac ExecSelectDemo.java |
输出的读法:
- 固定池 864ms。 8 个槽位、60 个任务就是 7.5 轮,每轮 100ms:池大小在这里同时是资源池和并发上限。
- 虚拟线程 110~119ms。 60 个任务一起开始,墙钟约等于一次
sleep,peakInFlight = 60;而加回Semaphore(8)的 869~891ms 把峰值并发显式压回 8,总耗时与固定池持平:限流没消失,只是从隐含的池容量变成了代码里的一行。
口径说明:单机、小样本,看形状而不是取数;真实服务的量级在《虚拟线程迁移实测》——同一个阻塞型服务,固定池 200 在并发 1000/5000 时停在 3661/3659 QPS,虚拟线程是 16379/50898 QPS。
常见错误清单
- 把 CPU 密集任务丢进虚拟线程执行器「提速」:没有收益,只有调度开销(虚拟线程实战、迁移实测)。
- 池化虚拟线程(
newFixedThreadPool(n, Thread.ofVirtual().factory()))在 5000 并发下只有 3456 QPS,比固定平台池还低(迁移实测)。 - 迁到虚拟线程后仍靠池容量限流:开关已经消失,补
Semaphore或下游连接池(虚拟线程实战)。 - 期望
allOf(...).join()快速失败:它等全部结束(实测 3015ms)(结构化并发实战)。 - 以为
get(timeout)/orTimeout会停掉任务:它只是放弃等待(CompletableFuture 异步编程实战)。 - 在
synchronized块里做下游 IO:QPS 41.6、p50 1536.46ms 的经典形状(一次锁竞争的完整定位)。 ThreadLocal不remove(),或在里面缓存昂贵对象:池线程会替你按住它们(ScopedValue 替代 ThreadLocal)。- 照抄 JDK 21/24 的
StructuredTaskScope示例:25 起入口是open(),旧示例编译不过(结构化并发实战)。 - 用普通线程池或公共
ForkJoinPool去读ScopedValue:只有fork()的子任务能继承(ScopedValue 替代 ThreadLocal)。 - 在容器里沿用笔记本上的核数与池大小:
--cpus 2.5会让 JVM 认为有 3 核,1 核时 GC 换 Serial,堆只有限额的 25%(容器里的 JVM)。
总结
- 扇出要结果、失败要短路 →
StructuredTaskScope.open()(JDK 25 预览,--enable-preview):失败短路 137ms 收尾、超时后 0 残留、零取消代码;它的价值是「谁负责善后」。 - 只要最快的一个 →
Joiner.anySuccessfulResultOrThrow();API 要紧跟 JDK 8 →CompletableFuture加自己写的取消,但要接受allOf不短路、超时不打断。 - CPU 密集 → 平台线程池(≈核数);高并发阻塞 IO → 每任务一条虚拟线程;低并发(峰值并发不超过池容量)时迁虚拟线程是负收益。
- 限流 → 显式
Semaphore或下游连接池,不要靠池容量,更不要池化虚拟线程。 - 上下文 →
ScopedValue单向传递且必须与fork()成对使用;线程级缓存与双向传递仍归ThreadLocal。 - 容器里重算一遍:核数向上取整、堆默认 25%、单核换 Serial。
参考资料
- JEP 444: Virtual Threads — Status: Closed / Delivered,Release: 21
- JEP 491: Synchronize Virtual Threads without Pinning — Status: Closed / Delivered,Release: 24
- JEP 505: Structured Concurrency (Fifth Preview) — 预览 API,
open()+Joiner形态,Release: 25 - JEP 525 / JEP 533 — Structured Concurrency 第六 / 第七次预览(Release 26 / 27):
Joiner增加第三个类型参数、移除awaitAll()、onTimeout()改timeout() - JEP 506: Scoped Values — Status: Closed / Delivered,Release: 25
StructuredTaskScope(Java SE 25 API)
系列索引:Java 系列,语言特性与运行时的长文集






