Skip to content
Go back

Java 21 结构化并发:让虚拟线程如虎添翼

一句话结论(30s)

结构化并发用 try-with-resources 的代码块边界把子任务生命周期”框”在花括号内,因为传统 Thread/Future/CompletableFuture 的父任务既无法感知子任务完成状态、也无法在自身结束时自动清理子任务,导致幽灵线程泄漏。关键设计是 fork/join 两阶段生命周期 + ShutdownOnFailure(一失败全员取消);代价是 API 在 Java 21 仍属孵化状态,需显式引入 jdk.incubator.concurrent 模块。

核心原理(2min)

StructuredTaskScope 进入 join() 后禁止 fork(),scope 退出时框架保证所有未完成子任务被 interrupt 取消、线程资源回收、无泄漏;ShutdownOnFailure 任一子任务异常即触发 shutdown 打断兄弟任务,throwIfFailed() 抛异常后用 resultNow() 非阻塞取结果(区别于阻塞的 Future.get()),ShutdownOnSuccess 则任一成功即返回,配合虚拟线程实现廉价海量并发。

底层深入(5-10min)

一、从”即发即忘”到”结构化并发”

Java 并发编程史上经历了三次范式跃迁:Thread(无返回值)→ ExecutorService + Future(有返回值但阻塞 get)→ CompletableFuture(异步编排但无生命周期约束)。然而这三者共享一个致命缺陷:父任务无法感知子任务的完成状态,也无法在自身结束时自动清理子任务

来看一个典型反模式:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f1 = executor.submit(() -> fetchUser(1L));
    Future<String> f2 = executor.submit(() -> fetchOrder(1L));
    String user = f1.get();   // 阻塞等待
    String order = f2.get();  // 阻塞等待
    return user + " -> " + order;
}
// 即使 f1.get() 抛出异常,f2 仍在后台运行——幽灵线程

JEP 453(Java 21 孵化,Java 23 正式)引入了 StructuredTaskScope,核心思想与结构化编程一脉相承:代码块的入口和出口必须一一对应,所有子任务的生命周期被限定在一个花括号内

💭 思考:为什么叫「结构化并发」?因为它把结构化编程里「入口和出口一一对应」的思想搬到并发上——代码块结束时所有子任务必须结束。你想想 try-with-resources 怎么保证资源释放,StructuredTaskScope 就用同样的块边界保证线程不逃逸。

二、StructuredTaskScope 核心机制

2.1 两阶段生命周期

StructuredTaskScope 的生命周期分为两个阶段:

阶段行为触发条件
fork 阶段允许 fork() 创建子任务scope 创建后自动进入
join 阶段禁止 fork(),等待所有子任务完成或被取消join() 调用后

当 scope 退出(try-with-resources 块结束)时,框架保证:

  1. 所有未完成的子任务被取消(interrupt)
  2. 所有线程资源被回收
  3. 不存在线程泄漏

💭 思考:为什么要拆成 fork 阶段和 join 阶段、还规定 join 后禁止 fork?因为一旦允许边等边 fork,子任务就可能在 scope「已经进入收尾」时新增,生命周期边界就模糊了——两阶段设计把「创建」和「收尾」彻底隔离,才能保证退出时干净回收。

2.2 ShutdownOnFailure:一个失败全员取消

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> fetchUser(1L));
    Future<String> order = scope.fork(() -> fetchOrder(1L));
    scope.join();              // 等待所有任务完成或任一失败
    scope.throwIfFailed();     // 若任一失败则抛异常

    return new Result(user.resultNow(), order.resultNow());
}

执行时序:任一子任务抛出异常 → scope 触发 shutdown → 正在运行的其他子任务收到 interrupt → join() 返回 → 所有子任务确认终止 → 资源回收。

这里的关键细节是 throwIfFailed()resultNow() 的配合。resultNow() 只应在 throwIfFailed() 确认无异常后调用——它在子任务未完成时直接抛 IllegalStateException,而不是阻塞等待,这是区别于 Future.get() 的重要设计差异。

💭 思考:为什么 resultNow() 不阻塞、还抛 IllegalStateException?因为结构化并发里「等」和「取」被拆开了——join() 负责等(阻塞),resultNow() 负责取(必须已完成)。这样你在取结果前一定先经过了 throwIfFailed() 的异常检查,不会像 Future.get() 那样把「等待」和「异常」混在一起。

2.3 ShutdownOnSuccess:任一成功即返回

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> queryReplica1());
    scope.fork(() -> queryReplica2());
    scope.fork(() -> queryReplica3());
    scope.join();
    return scope.result();  // 返回第一个成功结果
}

适用场景:向多个副本发出相同请求,取最快响应的结果。第一个成功的子任务会触发 shutdown,其余子任务被取消。

2.4 自定义策略

通过继承 StructuredTaskScope 并重写 handleComplete(Future<T> future),可以实现任意策略——例如”至少两个成功”或”多数派决策”:

class QuorumScope<T> extends StructuredTaskScope<T> {
    private final AtomicInteger successCount = new AtomicInteger(0);
    private final int quorum;

    @Override
    protected void handleComplete(Future<T> future) {
        if (future.state() == Future.State.SUCCESS) {
            int count = successCount.incrementAndGet();
            if (count >= quorum) shutdown();
        }
    }
}

三、与传统 CompletableFuture 全面对比

维度CompletableFutureStructuredTaskScope
生命周期无约束,泄漏风险try-with-resources 边界保证
错误传播手动 exceptionally/handleShutdownOnFailure 自动传播
取消失败任务手动 cancel(true),可能遗漏shutdown 机制自动取消所有兄弟任务
线程关系父子关系不透明fork 建立明确的父子树
可观测性手动埋点内置 JFR 事件
可读性thenApply/thenCombine 链式地狱顺序代码块,线性阅读
调试栈帧跨线程,难以追踪线程转储体现父子关系

💭 思考:CompletableFuture 也能实现类似功能,为什么要新造一个 API?因为它缺的是「生命周期约束」——Future 可以随手丢、逃逸、泄漏。StructuredTaskScope 用 try-with-resources 强制把子任务圈起来,把「不会泄漏」从程序员自觉变成框架保证。

3.1 父子关系差异

CompletableFuture.supplyAsync() 在默认 ForkJoinPool.commonPool() 中运行,这条线程与调用者线程没有任何关联。调试时线程转储中两根线程完全独立。

StructuredTaskScope.fork() 创建的是虚拟线程,线程名称自动继承父线程前缀。在线程转储(thread dump)中,父子关系以缩进形式可视化呈现,这是结构化并发可观测性的核心优势之一。

四、JFR 事件与可观测性

JDK 21 为结构化并发内置了三类 JFR(JDK Flight Recorder)事件:

事件含义关键字段
jdk.StructuredTaskScopeForkscope 内 fork 子任务scopeId, forkTime
jdk.StructuredTaskScopeJoinscope 进入 join 阶段scopeId, joinTime, threadCount
jdk.StructuredTaskScopeShutdownscope 触发 shutdownscopeId, reason(FAILURE/SUCCESS/MANUAL)

通过 JFR 录制,可以在生产环境中还原 某次请求到底 fork 了多少子任务、每个子任务耗时、shutdown 触发原因——这在 CompletableFuture 时代需要大量手动埋点才能做到。

五、虚拟线程 + 结构化并发 = 最佳实践

结构化并发与虚拟线程(JEP 444)是天然搭档:

try (var scope = new StructuredTaskScope.ShutdownOnFailure("order-scope",
         Thread.ofVirtual().factory())) {
    // 所有 fork 出的子任务都在虚拟线程上运行
    scope.fork(() -> callPaymentService());
    scope.fork(() -> callInventoryService());
    scope.join();
    scope.throwIfFailed();
}

二者的协同效应:

六、注意事项与局限

  1. 孵化 API 的模块限制:Java 21 中 jdk.incubator.concurrent 模块需要通过 --add-modules jdk.incubator.concurrent 显式引入。编译时需要额外参数。
  2. 不可跨 scope 引用:子任务的 Future 对象不应逃逸到 scope 外部——这正是”结构化”的含义。逃逸会导致 resultNow() 抛异常。

💭 思考:为什么子任务的 Future 不能逃逸到 scope 外?因为 scope 关闭后子任务已被取消,逃逸出去的 Future 再取结果只会得到「已取消/未完成」,这就是把生命周期和取值范围绑定的代价——也是「结构化」三个字的本质。

  1. 嵌套 scope 的层级管理:内层 scope 的 shutdown 不会自动传播到外层 scope——子 scope 是独立的生命周期单元。
  2. 异常优先级:ShutdownOnFailure 中如果多个子任务都异常,throwIfFailed() 抛出第一个触发的异常,其余异常作为 suppressed exceptions 附加。

章末提问

追问 1:结构化并发解决了 CompletableFuture 的什么根本问题?

结论先行:解决的是「父任务无法感知子任务完成状态、无法在自身结束时自动清理子任务」的生命周期失控问题。

因为:CompletableFuture 可以随处创建、随手丢弃,父任务退出后子任务仍在后台跑(幽灵线程),且父子关系在 JVM 中不透明;StructuredTaskScope 用 try-with-resources 的块边界约束子任务生命周期,块退出即取消所有未完成子任务并回收线程,把「不泄漏」从约定变成框架保证。

追问 2:resultNow()Future.get() 的核心区别是什么?为什么不能乱用 resultNow?

结论先行Future.get() 阻塞等待结果,resultNow() 非阻塞、子任务未完成时直接抛 IllegalStateException

因为:结构化并发把「等待」和「取结果」分离——join() 负责阻塞等待,resultNow() 只应在 throwIfFailed() 确认无异常、任务已完成后调用。乱用 resultNow()(任务还在跑)会直接抛异常而非等待,这是刻意设计,迫使你按「join → throwIfFailed → resultNow」的顺序处理,避免把等待和异常处理混在一起。

追问 3:ShutdownOnFailure 中多个子任务都失败时,异常怎么传播?

结论先行throwIfFailed() 抛出第一个触发的异常,其余异常作为 suppressed exceptions 附加在它上面。

因为:scope 在第一个异常触发 shutdown 后打断兄弟任务,但可能有多个任务「同时」失败,框架需要保留全部信息又不产生多个主异常,于是采用「一个主异常 + suppressed 列表」的标准模式,和 try-with-resources 处理 close() 异常的 addSuppressed 一脉相承。


Share this post on:

Previous Post
Lambda的invokedynamic与匿名内部类
Next Post
Java Record与Sealed类——代数数据类型(ADT)的语言级支持