JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · CONCURRENCY · JEP 453 / 446

结构化并发:让任务有作用域

JDK 21 · 深钻主题 05 / 08 · 预计 50 分钟
进阶四步深钻JEP 453JEP 446
一句话结构化并发把一组并发子任务放进一个作用域:父任务等待所有子任务结束,任一失败就取消其余,不再留下孤儿线程。JDK 21 中它是预览 API,配合虚拟线程和 ScopedValue 构成新的并发编程模型。
本质:并发任务应该有明确的开始与结束。作用域结束时,所有子任务要么完成,要么被取消——错误和取消都沿任务树向上传播,而不是消失在某个线程池里。

学完你要能自己回答

  1. 结构化并发解决了 ExecutorService.invokeAll 的什么问题?
  2. ShutdownOnFailure 与 ShutdownOnSuccess 分别适合什么场景?
  3. 作用域关闭时,未完成的子任务会怎样?异常如何传播?
  4. ScopedValue 和 ThreadLocal 的核心差异是什么?为什么它适合虚拟线程?
  5. JDK 21 中这些 API 处于什么状态?生产使用要注意什么?
01 任务树:父子同生共死02 两种关闭策略

任务树:父子同生共死01

传统线程池里,提交任务像把信投进邮筒:投出去之后,任务和提交者就没有结构关系了。提交者可能已经返回,任务还在跑;任务失败了,异常可能只躺在 Future 里没人取。

结构化并发把任务组织成一棵树:fork 创建子任务,join 等待它们,作用域结束时子任务必须全部终止。父任务不会在子任务还活着时悄悄离开。

维度线程池 + Future结构化并发
生命周期提交后脱离,可能泄漏作用域内,关闭时强制收敛
错误传播每个 Future 单独检查策略统一处理,失败可取消兄弟任务
取消手动 cancel,容易遗漏作用域关闭自动取消未完成任务
可观测性线程池队列 + 零散 Future任务树清晰,父子关系明确
适用场景长期服务、任务队列请求内 fan-out/fan-in、短生命周期任务
结构化并发的「结构化」来自结构化编程:控制流有明确的进入和退出。并发也应该有。

两种关闭策略02

ShutdownOnFailure:任一失败,全部取消

适合「所有子任务都成功才有意义」的场景,例如同时查用户和订单,任一查询失败就返回错误,不必等另一个慢查询。

ShutdownOnSuccess:任一成功,其余取消

适合「多副本取最快」的场景,例如向两个镜像发起同一请求,谁先成功用谁;也适合 hedged request(延迟触发备份请求)。

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user = scope.fork(() -> loadUser(id));
    var order = scope.fork(() -> loadOrder(id));
    scope.join().throwIfFailed();
    return new Response(user.get(), order.get());
}

join() 等待所有子任务结束或作用域被关闭;throwIfFailed() 把第一个失败包装成 ExecutionException 抛出。try-with-resources 的 close() 会确保没有遗留任务。

01 API 形状与执行模型02 取消与异常传播的细节03 ScopedValue:不可变上下文

API 形状与执行模型01

public class StructuredTaskScope<T> implements AutoCloseable {
    public <U extends T> Subtask<U> fork(Callable<? extends U> task);
    public StructuredTaskScope<T> join() throws InterruptedException;
    public StructuredTaskScope<T> joinUntil(Instant deadline)
            throws InterruptedException, TimeoutException;
    public void shutdown();
    public void close();
}

public interface Subtask<T> {
    State state();        // SUCCESS / FAILED / UNAVAILABLE
    T get();              // 仅 SUCCESS 时可用,否则抛 IllegalStateException
    Throwable exception(); // 仅 FAILED 时可用
}
  • 默认情况下,fork 在虚拟线程上执行子任务;可以通过构造器传入自定义 ThreadFactory。
  • join 只能由创建作用域的所有者线程调用;在子任务里调用会抛 IllegalStateException。
  • close 会先 shutdown,再等待所有子任务终止;如果所有者线程被中断,close 仍会等待。
  • 子任务的异常不会自动打印;必须通过 throwIfFailed()、result() 或 Subtask.exception() 显式获取。

取消与异常传播的细节02

  1. 任一子任务失败,ShutdownOnFailure 调用 shutdown(),中断其他子任务。
  2. 被中断的子任务如果响应中断(如阻塞 I/O、Thread.sleep),会尽快结束;不响应中断的任务可能让 close() 等待较久。
  3. 所有者调用 throwIfFailed() 时,抛出第一个失败的 ExecutionException,原异常在 cause 中。
  4. 如果所有子任务都成功,throwIfFailed() 什么也不做,可以安全读取 get()。
取消是协作式的:作用域只能中断子任务,不能强制杀死。子任务必须正确响应 InterruptedException,否则结构化并发也救不了它。

ScopedValue:不可变上下文03

ThreadLocal 是「每个线程一个可变副本」,在虚拟线程数量巨大时内存开销明显,而且容易忘记清理。ScopedValue 是「在某个作用域内绑定一个不可变值」,子任务自动继承,作用域结束即失效。

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

void handle() {
    ScopedValue.where(USER, "alice").run(() -> {
        System.out.println(USER.get()); // alice
        // fork 出的子任务也能读到 USER
    });
    // 作用域外 USER.isBound() == false
}
ThreadLocalScopedValue
可变性可变,可 set/remove绑定后不可变
生命周期跟随线程,需手动清理跟随作用域,自动失效
虚拟线程开销每个虚拟线程一份共享绑定,按作用域继承
继承InheritableThreadLocal 有坑子任务结构化继承
状态正式 APIJDK 21 预览
01 从 invokeAll 迁移到作用域02 可运行示例:并发加载与快速失败

从 invokeAll 迁移到作用域01

// Before:超时与取消要自己拼
List<Callable<Result>> tasks = List.of(this::loadA, this::loadB);
List<Future<Result>> futures = executor.invokeAll(tasks, 1, TimeUnit.SECONDS);
for (Future<Result> f : futures) {
    if (f.isCancelled()) throw new TimeoutException();
    results.add(f.get());
}

// After:作用域负责生命周期,超时用 joinUntil
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var a = scope.fork(this::loadA);
    var b = scope.fork(this::loadB);
    scope.joinUntil(Instant.now().plusSeconds(1)).throwIfFailed();
    return new Pair(a.get(), b.get());
}
  • joinUntil 超时会抛 TimeoutException,随后 close() 取消未完成任务。
  • 如果某个子任务失败,ShutdownOnFailure 会取消另一个,避免继续做无用功。
  • 不要在外层再套一个线程池等待;作用域自己管理子任务线程。

可运行示例:并发加载与快速失败02

import java.util.concurrent.*;

public class StructuredDemo {
    record User(String id) {}
    record Order(String id) {}

    static User loadUser() throws Exception {
        Thread.sleep(100);
        return new User("u-1");
    }

    static Order loadOrder() throws Exception {
        Thread.sleep(150);
        return new Order("o-1");
    }

    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var user = scope.fork(StructuredDemo::loadUser);
            var order = scope.fork(StructuredDemo::loadOrder);
            scope.join().throwIfFailed();
            System.out.println(user.get() + " / " + order.get());
        }

        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(() -> { Thread.sleep(300); return "slow"; });
            scope.fork(() -> { Thread.sleep(100); return "fast"; });
            scope.join();
            System.out.println("winner=" + scope.result());
        }
    }
}
javac --enable-preview --release 21 StructuredDemo.java
java --enable-preview StructuredDemo
User[id=u-1] / Order[id=o-1]
winner=fast

第二个作用域的慢任务会被取消/中断,但示例中的 Thread.sleep 会响应中断;如果子任务捕获并吞掉中断,close() 可能等待它自然结束。

01 先猜再看02 费曼自检:生命周期与传播

先猜再看01

先猜再看作用域里一个子任务失败后,另一个子任务还在阻塞,会发生什么?
ShutdownOnFailure 会调用 shutdown 并中断另一个子任务。若它响应中断,会尽快结束;若吞掉中断,close 仍要等它结束,结构化并发无法强制终止。
先猜再看ScopedValue 的值能被 sub-task 修改吗?
不能。ScopedValue 绑定后不可变;子任务只能读取当前绑定。需要不同值就开一个新的 where 作用域。
先猜再看结构化并发能替代线程池吗?
不能完全替代。它适合请求内短生命周期的 fan-out/fan-in;长期服务、任务队列、定时调度仍然需要线程池或调度器。

费曼自检:生命周期与传播02

01. 结构化并发最重要的不变量是什么?
作用域关闭时,所有子任务都已终止;父任务不会在子任务仍运行时返回或抛出。
02. join 与 close 的分工?
join 等待任务结束或策略触发 shutdown;close 负责最终收敛,确保没有遗留任务,并释放资源。
03. 为什么子任务抛出的异常不会自动打印?
异常被保存在 Subtask 中,由策略决定如何聚合和传播;必须显式调用 throwIfFailed/result/exception 才能观察到。
04. ScopedValue 为什么比 ThreadLocal 更适合虚拟线程?
它按作用域共享不可变绑定,不需要每个虚拟线程复制一份可变状态,也不会因忘记 remove 而泄漏。
05. JDK 21 使用结构化并发需要注意什么?
它是预览 API,需要 --enable-preview;API 在后续版本可能调整,生产使用要评估升级成本。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。