Java 结构化并发实战:StructuredTaskScope 新范式

结构化并发(Structured Concurrency)是 Java 21 以预览形式引入、Java 24 正式转正(JEP 499)的并发范式。它用 StructuredTaskScope 把一组并发子任务组织成一个”有作用域、可取消、异常可传播”的结构化单元,专门解决传统线程池”线程泄露、异常丢失、取消困难”三宗罪。它和我们在Java 21 虚拟线程里讲到的虚拟线程是天生一对:虚拟线程解决了”线程够不够用”,结构化并发解决”一堆并发任务怎么管好”。

一、为什么需要结构化并发:传统线程池的三宗罪

先用最朴素的线程池写法并行取两个接口。代码能跑,但埋了三个坑。

ExecutorService pool = Executors.newFixedThreadPool(2);
Future<User> u = pool.submit(() -> fetchUser(id));
Future<List<Order>> o = pool.submit(() -> fetchOrders(id));
User user = u.get();        // 如果这里抛异常
List<Order> orders = o.get(); // 后面的任务还在跑,没人取消
// pool 忘了 shutdown,线程一直挂着 -> 泄露

坑一:取消困难。u.get() 抛异常后,o 对应的任务仍在后台空跑,你没法优雅地”一个失败就全停”。坑二:异常丢失/错位。多个任务各自抛错时,你拿到的只是第一个 Future 的异常,其余被静默吞掉。坑三:生命周期脱节。子任务的生命周期不跟随调用方,方法返回了线程还在跑,最终变成泄露和 OOM。

二、核心概念:StructuredTaskScope 与作用域

StructuredTaskScope 的核心直觉是:子任务的生命周期必须嵌套在父任务的作用域内。它必须放在 try-with-resources 里,作用域一关闭,所有未完成的子任务自动取消。最常用两个子类:

子类语义典型场景
ShutdownOnFailure任一子任务失败,立刻关闭作用域、取消其余任务并行聚合多个必须都成功的调用
ShutdownOnSuccess任一子任务成功,立刻关闭作用域、取消其余任务多数据源竞速,取第一个成功结果

关键三步:fork() 派发子任务、join() 等待作用域关闭、throwIfFailed() 把失败传播出来。注意 fork 出的子任务默认跑在虚拟线程上,所以即使并行几百个任务也不会被平台线程数卡住。

三、实战一:并行聚合(ShutdownOnFailure)

最常见的需求:并行调两个服务,两个都得成功,任一个失败就整体失败。结构化并发写法如下:

public Result load(int id) throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 1. fork 两个子任务(跑在虚拟线程上)
        Callable<User> userTask = scope.fork(() -> fetchUser(id));
        Callable<List<Order>> orderTask = scope.fork(() -> fetchOrders(id));

        // 2. 等待所有子任务结束(任一失败会触发作用域关闭)
        scope.join();

        // 3. 若任一失败,把异常原样抛出
        scope.throwIfFailed();

        // 4. 都成功才到这里,安全取值
        return new Result(userTask.get(), orderTask.get());
    } // 作用域关闭:未完成的任务被取消,线程自动回收
}

对比开头那段线程池代码:这里一个失败自动取消另一个,异常通过 throwIfFailed() 原样冒泡,方法返回时作用域一定已关闭,不存在线程挂起。这就是”结构化”——并发任务的层次和代码块层次完全一致。

四、实战二:取第一个成功结果(ShutdownOnSuccess)

另一个高频场景:主库、从库、缓存三个数据源竞速,谁先返回用谁,其余取消。这正是 ShutdownOnSuccess 的用武之地。

public String readFirst(int id) throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> primarySource.fetch(id));   // 主库
        scope.fork(() -> replicaSource.fetch(id));    // 从库
        scope.fork(() -> cache.fetch(id));            // 缓存

        // 返回第一个成功的结果;全部失败则抛最后一个异常
        return scope.join();
    }
}

join() 在第一个任务成功时立即返回,并自动取消另外两个还在跑的任务。传统写法要么轮询 FutureisDone(),要么用 invokeAny()(但它吞掉其他任务的异常且不取消不了那么干净)。结构化并发把”竞速取首”变成了几行声明式代码。

五、和虚拟线程、线程池到底什么关系

很多人分不清三者的边界,一句话区分:虚拟线程是”执行单元”,线程池是”执行单元的调度容器”,结构化并发是”并发任务的组织方式”。它们不是替代关系,而是分层协作。

能力普通线程池虚拟线程(#297)结构化并发
解决什么复用平台线程海量轻量执行单元管理一组并发任务的生命周期
取消需手动 future.cancel继承中断机制作用域关闭即全员取消
异常易丢失/错位不负责聚合throwIfFailed 统一传播
典型用法后台异步任务替代每个请求一线程try-with-resources 包裹 fork/join

实践中推荐组合:用结构化并发组织任务,用虚拟线程承载执行。结构化并发的默认作用域就把 fork 的任务跑在虚拟线程上,所以你几乎不用关心底层线程数。关于虚拟线程的底层机制,可以回看Java 21 虚拟线程:高并发编程范式变革

六、取消与异常传播:作用域关闭即全员取消

结构化并发的取消依托虚拟线程的中断。当作用域因失败/成功而关闭时,所有仍在运行的子任务会收到中断信号,Thread.currentThread().isInterrupted() 变 true,阻塞的 I/O(如 SocketInterruptibleChannel)会抛 InterruptedException。所以你的子任务代码只要尊重中断,就能及时退出,不会空耗资源。

scope.fork(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 周期性检查中断,避免使用不响应中断的阻塞调用
        doWork();
    }
    return partialResult;
});

反过来,如果你的子任务里用了不响应中断的阻塞(比如某些老的 synchronizedReentrantLock.lock() 不带超时),取消信号就可能”卡住”。这正是我们在Java 线程池调优里强调”所有阻塞都要有超时”的延续——结构化并发让取消更自动,但前提是任务本身是可中断的。

七、在 Spring Boot 里落地

Spring Boot 3.2+ 一行配置就能让 Web 请求跑在虚拟线程上,给结构化并发打底:

# application.properties
spring.threads.virtual.enabled=true

然后在 Service 里,把原本串行或手写线程池的聚合逻辑,替换成 StructuredTaskScope。注意作用域一定用 try-with-resources 包住,不要做成字段或单例长期持有。

@Service
public class AggregationService {
    public ProfileDTO buildProfile(int userId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var base = scope.fork(() -> userService.base(userId));
            var pref = scope.fork(() -> prefService.preference(userId));
            var feed = scope.fork(() -> feedService.recent(userId));
            scope.join();
            scope.throwIfFailed();
            return new ProfileDTO(base.get(), pref.get(), feed.get());
        }
    }
}

关于 Spring Boot 3 的线程模型与升级注意事项,可参考Spring Boot 3 升级踩坑实录。结构化并发和虚拟线程配合,能把”同时调 N 个下游”的延迟从 N 次串行变成 1 次最慢,是网关、聚合层、BFF 的天然利器。

八、生产注意与踩坑清单

反模式后果正解
把 Scope 存成类字段/单例跨请求复用导致任务串台、取消混乱每次调用 new 一个,用 try-with-resources
fork 后忘了 join子任务可能根本没执行完就取值join 后再 get / throwIfFailed
子任务用不可中断阻塞取消信号卡住,资源空耗所有阻塞加超时、尊重中断
在作用域外持有 fork 的返回值作用域关闭后线程已回收仅在作用域内取值并组装

另外两点:监控上,结构化并发的任务仍是虚拟线程,可用 JFR(Java Flight Recorder)录制 jdk.VirtualThread* 事件观察 fork/join 与取消;超时上join() 本身不带超时,需要整体超时请用 joinUntil(Instant) 或在子任务里自行加 future 超时,避免某个下游永远不返回把作用域拖死。

九、和已有线程池代码怎么共存

不必推倒重来。老代码里的 ExecutorService 继续用,只在”需要聚合/竞速一组并发调用”的新逻辑里引入结构化并发。两者可混用:把结构化并发的 scope 跑在虚拟线程上,内部个别重计算任务再 fork 到自定义线程池也行。迁移节奏建议从 BFF 聚合层、网关编排、批量查询这类”N 个下游并行”的场景切入,收益最直观,也最容易量化延迟下降。

需要把这类并发改造推进 CI 时,建议配合GitHub Actions 实战:从零搭建 CI/CD 流水线做并发单测与压测门禁,确保重构不引入回归。

十、总结

结构化并发把”并发任务的层次”和”代码的层次”对齐:fork 在作用域里,join 在作用域里,作用域关了任务全清。它用 StructuredTaskScope 的两行 join() / throwIfFailed(),替你管好了取消与异常,再叠加虚拟线程的轻量执行,让”并行调一堆下游”这件事从易错变成声明式。下一次你准备写 newFixedThreadPool + 一堆 Future.get() 时,不妨先想想:这组任务,是不是该用结构化并发装进一个作用域里?

上一篇 asdf 版本管理实战:多语言运行时一键切换
下一篇 工作流引擎选型实战:Airflow/Dagster/Prefect 对比