上下文、CompletableFuture、ForkJoin 与虚拟线程
入口日志有 traceId 和租户,进入 CompletableFuture.supplyAsync() 后却都变成空;偶尔更糟,线程池复用同一个 worker,新请求读到了上一个租户。团队随后把更多字段塞进 InheritableThreadLocal,又发现早已创建的池线程根本不会按每次提交继承。异步不是简单地“换个线程执行”,它会切断隐式上下文、异常、取消和生命周期。
虚拟线程降低了大量阻塞任务的线程成本,但没有取消这些边界。每任务一个虚拟线程让同步代码重新可扩展,数据库连接、下游 QPS、超时和业务幂等仍是硬约束。我们先把平台线程池上的上下文与完成图讲清,再判断虚拟线程和 JDK 25 ScopedValue 能改善什么。
ThreadLocal 的值属于线程,不属于请求
final class RequestContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
static void setTenant(String tenant) { TENANT.set(tenant); }
static String tenant() { return TENANT.get(); }
static void clear() { TENANT.remove(); }
}Web 请求恰好由一个线程处理时,很容易误以为这个值属于请求。实际映射是 Thread -> ThreadLocalMap -> value。平台线程池会长期复用线程,如果异常路径没有 remove,值随 worker 留存,下一个任务可能读到旧租户,大对象也可能长期不能回收。
入口必须在 finally 清理:
try {
RequestContext.setTenant(headerTenant);
chain.doFilter(request, response);
} finally {
RequestContext.clear();
}ThreadLocal key 使用弱引用不代表 value 会及时自动消失。key 不再有强引用后,陈旧 value 仍可能留在线程内部表中,直到后续访问触发清理或线程结束。对长期 worker,明确 remove 才是生命周期协议。
弱 key 为什么仍可能留下强 value
ThreadLocalMap 的 entry 以弱引用保存 ThreadLocal key,却强引用 value。key 被回收后,槽位不会凭空从数组消失,只是变成 key 为 null 的陈旧项。后续 get/set/remove 在探测路径上遇到它时会做替换、清扫或重新散列;如果长期 worker 此后很少再触碰相关区域,value 仍可随线程存活很久。
这解释了为什么“ThreadLocal 对象已经是局部变量”不能证明没有泄漏。真正的所有权是长寿命线程 → ThreadLocalMap → Entry → value。开放寻址还意味着哈希冲突时会向后探测,清理一个陈旧项需要修复受影响的连续槽区;它不是普通 HashMap 那种删掉链表节点即可。排查堆转储时应从线程对象反向查到 value,而不只是搜索 ThreadLocal key 是否还活着。
业务上有两个不同故障:陈旧 value 导致内存滞留,未陈旧但未清理的 entry 导致请求串值。前者通常在堆占用、类加载器无法卸载或长寿命 worker 路径中暴露;后者要用同一 worker 连续跑两个租户稳定复现。统一的修复仍是入口保存旧值、安装新值、在 finally 恢复或 remove,而不是等待 GC 或偶然清扫。
跨线程时要复制、安装、恢复,而不是只 set
任务包装器要保存提交时上下文,并在 worker 上恢复旧值:
Runnable wrap(String tenant, Runnable task) {
return () -> {
String previous = RequestContext.tenant();
try {
RequestContext.setTenant(tenant);
task.run();
} finally {
if (previous == null) {
RequestContext.clear();
} else {
RequestContext.setTenant(previous);
}
}
};
}只在任务结束时 remove 可能破坏 worker 原本嵌套调用的上下文,保存并恢复才支持组合。复制时还要决定哪些字段允许跨边界:traceId、租户、只读身份可以传,数据库事务连接和可变请求对象通常不能随意跨线程。
InheritableThreadLocal 在线程创建时从父线程复制值,不是在每次提交任务时复制。线程池 worker 早已存在,因此它不能解决普通任务传播;对虚拟线程虽然每任务新建线程更常见,仍需警惕大对象复制、值可变和安全边界。
CompletableFuture 是完成图,不是线程池
CompletableFuture 把异步阶段按正常与异常完成关系连接起来。某个 stage 在哪个线程执行,取决于它是否带 Async、是否显式传 Executor、前一阶段完成时机和实现策略。
CompletableFuture<User> user = CompletableFuture
.supplyAsync(() -> userClient.query(id), userPool)
.orTimeout(700, TimeUnit.MILLISECONDS);
CompletableFuture<Orders> orders = CompletableFuture
.supplyAsync(() -> orderClient.query(id), orderPool)
.completeOnTimeout(Orders.empty(), 600, TimeUnit.MILLISECONDS);
CompletableFuture<View> view = user.thenCombine(orders, View::of);不带 Async 的 continuation 可能由完成前一阶段的线程直接执行;带 Async 且不传 executor 的方法通常使用默认异步执行设施。昂贵 continuation 若意外跑在 IO 回调、入口线程或某个窄线程池上,会把该线程拖住。架构评审要标出每个阶段的 executor,而不是只看链式语法。
完成动作由谁触发,后续阶段就可能由谁执行
CompletableFuture 的节点记录的是“前置完成后执行什么”,不是提前绑定的一条固定线程。非 Async 依赖阶段在前置完成时可能由触发完成的线程直接执行;若注册时前置已经完成,也可能由注册者执行。Async 变体才把动作提交给执行器,未指定时通常落到公共执行器。因此同一个 thenApply 不能假定自己总在请求线程、生产者线程或 commonPool。
cd examples/backend-development/concurrency/context-future-virtual
javac --release 17 -Xlint:all -Werror CompletionThreadDemo.java
java CompletionThreadDemo
# inline=value@main
# async=value@owned-executor这里由 main 调用 complete,非异步阶段就在 main 推进;异步阶段进入显式执行器。把 complete 移到另一线程,第一行线程名也会改变。这会产生两个工程后果:非 Async 回调不能偷偷放长阻塞,完成数据库 Future 的 IO 线程可能被后续业务拖住;上下文也不能靠“看起来还在同一条链”自动继承。
内部实现把依赖动作挂在完成栈上,前置结果发布后由完成线程触发遍历;注册与完成竞争时,任一方都可能帮助推进。应用不应依赖节点类型,却必须理解完成图的所有权:每个阻塞阶段在哪个执行器运行、异常由哪个节点消费、取消是否只改变外层结果、哪条边负责恢复上下文。没有这些答案,链式调用只是把线程切换藏进方法名。
还要区分 join/get 的等待者与计算本身。等待者超时或被中断,不会自动从完成图删除所有下游动作;对组合结果调用 cancel,也不等于外部 HTTP 或数据库查询已经收到取消。异步链同样需要截止时间向下传递,以及真实资源的关闭或取消入口。
异常必须在正确层保留语义
exceptionally 把异常转换为正常结果,适合明确降级;whenComplete 观察结果但不应随意吞掉异常;handle 同时转换正常与异常。每个分支都 exceptionally(ex -> null) 会让下游无法区分“确实没有数据”和“查询失败”。
future.handle((value, error) -> {
if (error == null) return Result.success(value);
Throwable cause = unwrap(error);
if (cause instanceof TimeoutException) return Result.timeout();
return Result.failed(cause);
});组合异常经常被 CompletionException/ExecutionException 包裹,日志要保留原始 cause。多个并行分支失败时,最终异常不一定自动包含全部失败,关键分支应分别埋点,再由汇总层决定主要错误和降级结果。
等待超时不等于任务停止
future.get(1, SECONDS) 超时只让等待线程返回;orTimeout 让 CompletableFuture 异常完成,也不自动保证底层阻塞调用被终止。已经发出的 HTTP、SQL 或 RPC 需要自己的超时和取消能力。
取消一个 CompletableFuture 的传播也依赖图关系和任务实现。不要假设对汇总 future 调 cancel(true) 就能中断每个供应任务。每个外部调用必须受统一截止时间约束,超时后还要观察在途请求是否继续占用连接。
这也是异步扇出最危险的容量放大:一个入口并行发 10 个下游请求,入口 QPS 1000 时下游看到的可能是 10000 个在途调用。并行减少单请求墙上时间,却提高瞬时并发和失败相关性。
commonPool 是全进程共享资源
裸写 supplyAsync()、runAsync(),以及并行流等路径可能共享 ForkJoinPool.commonPool()。ForkJoinPool 通过 work-stealing 适合可拆分的计算任务;大量阻塞 IO、锁等待或互相 join 会占住 worker,拖慢完全无关的模块。
CompletableFuture<String> wrong =
CompletableFuture.supplyAsync(() -> blockingHttpCall());核心阻塞任务显式传隔离 executor:
CompletableFuture<String> guarded =
CompletableFuture.supplyAsync(this::blockingHttpCall, downstreamPool);ForkJoin 任务最好通过 fork/join 形成有界递归分解,避免在 worker 内做无限阻塞。若必须执行可管理阻塞,框架层可研究 ManagedBlocker,业务代码更优先使用合适执行模型和隔离资源。
线程 dump 出现 ForkJoinPool.commonPool-worker-* 热点时,追调用者、阻塞点和共享使用者。仅调整 common parallelism 会改变全进程行为,不能替某个业务池做局部治理。
虚拟线程改变线程成本,不改变任务容量
JDK 21 起,虚拟线程是正式 API。它仍是 Thread,但不在整个生命周期独占一个操作系统线程。遇到支持的阻塞操作时,虚拟线程可暂停,载体线程继续运行其他虚拟线程,因此适合大量以等待 IO 为主、希望保留同步代码风格的任务。
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> result = executor.submit(this::blockingHttpCall);
System.out.println(result.get(800, TimeUnit.MILLISECONDS));
}虚拟线程不是更快的线程:CPU 密集代码仍受核心数约束,单次调用延迟不会凭空下降。也不要为了限制并发而把虚拟线程重新池化;每任务创建虚拟线程,把数据库/下游并发限制放在 Semaphore、连接池、限流器和业务队列上,边界更清晰。
JDK 21 时代常见的 synchronized pinning 结论需要加版本边界:JDK 24 交付 JEP 491 后,虚拟线程在 monitor 内阻塞的实现已得到改进,不能把旧现象直接套到 JDK 25/26。native 调用、外部函数、驱动与工具兼容仍要通过 JFR 和线程转储验证。
ScopedValue 让只读上下文具有词法生命周期
JDK 25 正式交付 Scoped Values。它适合调用者向当前调用链及受控子任务共享不可变数据,生命周期由代码块限定,比可随处 set/remove 的 ThreadLocal 更容易推理,尤其适合大量虚拟线程。
private static final ScopedValue<String> TENANT = ScopedValue.newInstance();
ScopedValue.where(TENANT, tenantId).run(() -> {
service.handle();
});这段示例需要 JDK 25 编译,不属于 JDK 17 公共实验。ScopedValue 不是可变上下文容器,也不会自动跨任意线程池任务传播;它鼓励把只读绑定限制在词法作用域。日志 MDC、框架兼容和旧代码迁移仍可能继续使用 ThreadLocal,需要分阶段收口。
Structured Concurrency 能把一组相关子任务放进共同生命周期和取消范围,但在 JDK 26 仍是 Preview。生产基线未启用 Preview 时,不应让核心接口依赖它;可以先采用同样思想:明确父任务、子任务集合、总截止时间、失败传播和统一清理,再等 API 稳定性满足团队门禁。
虚拟线程迁移要做对照实验
选一个主要耗时为阻塞 IO、下游容量清楚、超时完整的读接口。平台线程与虚拟线程使用同样负载,比较:吞吐、P50/P99、CPU、堆、数据库连接等待、HTTP 连接、错误率、超时后在途任务、JFR 事件。
jcmd "$PID" Thread.dump_to_file -format=json virtual-threads.json
jcmd "$PID" JFR.start name=virtual settings=profile duration=120s filename=virtual.jfr大量虚拟线程不适合只用传统平台线程计数判断并发。结构化线程转储能观察虚拟线程关系;JFR 能看到启动、结束、阻塞等事件。线程 dump 和 JFR 都有成本,压测环境先验证采集影响。
如果迁移后吞吐上升、数据库等待和超时也同时上升,说明瓶颈只是从平台线程挪到了连接池。正确动作是限制下游并发、扩容真正瓶颈或降低扇出,不是继续增加虚拟线程。
统一异步入口比每段代码自救更可靠
团队应收口以下能力:
executor 或虚拟线程执行策略,以及任务/故障域命名。trace、租户、身份等只读上下文的捕获、安装、恢复与清理。绝对截止时间、每分支超时、取消和下游中止。
异常分类、降级结果、未观察异常与完成状态指标。队列/连接/许可等容量限制,禁止裸用 commonPool。关闭时父子任务怎样收敛,未完成副作用怎样补偿。
把上下文清理与完成线程做成双重证据
完整源码位于 examples/backend-development/concurrency/context-future-virtual/。AsyncContextDemo.java 验证上下文复制、恢复与清理,并探测当前 JDK 是否存在虚拟线程 API;CompletionThreadDemo.java 对比非 Async 阶段与显式 executor 阶段的执行线程。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out AsyncContextDemo.java CompletionThreadDemo.java
java -cp out AsyncContextDemo
java -cp out CompletionThreadDemoJDK 17 上稳定输出为:
context=propagated-and-cleaned virtualThreadApi=false
inline=value@main
async=value@owned-executor在 JDK 21+ 上 virtualThreadApi 应为 true,这只是 API 存在性证据,不代表下游容量已经适配。工程门禁要分别统计上下文缺失/串扰次数、未观察异常、各 executor 队列等待与执行耗时、超时后仍存活任务、虚拟线程等待资源分布。迁移前后必须保持租户串扰为零、异常都有所有者、截止时间不被分支重置;吞吐提升若伴随连接等待和超时上升,说明瓶颈已经迁移而非消失。
异步的收益是同时等待独立工作;它的代价是把一条顺序调用变成完成图。图上的线程、上下文、错误、截止时间和容量都标清楚后,选择平台池、ForkJoin 还是虚拟线程才有意义。
可继续查看 CompletableFuture、Virtual Threads 指南、JEP 444、JEP 491、JEP 506 Scoped Values 与 JEP 525 Structured Concurrency(JDK 26 第六次 Preview)。
