如果你写过 Java 并发代码,大概率经历过这种场景:用 ExecutorService 提交一堆任务,然后主线程等着 Future.get() 拿结果。代码能跑,但总有种说不出的别扭——子任务失败了,主线程往往要等到最后才察觉;主方法返回了,后台线程却还在偷偷运行。这种松散的结构让错误处理、超时控制和资源清理都变得很棘手。JDK 28 里转正的 JEP 543(结构化并发)正是冲着这个痛点来的,它把一组相关子任务当成一个整体来管理:要么全部成功,要么一个失败就取消其余所有,而且父作用域必须等所有子线程结束才能退出。这个 API 从 JDK 19 的孵化器版本一路走来,经历了整整七轮预览,终于修成正果,成为 java.util.concurrent 包里的正式功能。
结构化并发的核心思路其实很朴素:把并发任务的边界画清楚。传统写法里,你 fork 出去一个任务,它就像断了线的风筝,跟主线程之间只有 Future 这一根细线连着。父方法可以提前 return,子任务还在后台跑;子任务抛异常了,只有当你去调 Future.get() 时才会炸出来。结构化并发则强制要求:所有子任务的生命周期必须被包含在创建它们的那个作用域里。用 try-with-resources 包住一个 StructuredTaskScope,fork 出去的任务默认跑在虚拟线程上,join() 是唯一的汇合点,等所有子任务结束或者第一个失败触发取消;作用域关闭时,没跑完的子任务会被自动 cancel 并等待线程终止。这种结构让并发代码的阅读和推理变得跟同步代码一样直观——你看到的作用域边界,就是任务真正存活的边界。
这次转正的 API 形态跟 JDK 27 的第七轮预览完全一致,没有签名变化,所以已经在 JDK 27 上跑预览版的团队,迁移成本几乎为零:把 javac、测试运行器和启动脚本里的 --enable-preview 去掉,重新编译一遍就行。但对还在 JDK 26 或更早版本上的团队,改动就明显了。最核心的变化是异常模型:之前预览版里那个专用的 FailedException 被干掉了,join() 现在抛的是 ExecutionException——就是 Future.get() 用了十几年的那个老包装类,语义上更统一。同时,StructuredTaskScope.TimeoutException 也被移除,超时通过 CancelledByTimeoutException 作为 cause 来报告;Joiner.awaitAll() 方法没了,onTimeout() 回调换成了抽象的 timeout() 方法。
API 的设计围绕两个核心接口展开:StructuredTaskScope<T, R, R_X> 和 Joiner<T, R, R_X>。前者是个 sealed 接口,不提供公开构造器,只能通过静态工厂方法创建。零参数的 open() 返回一个默认作用域,join() 会等待所有子任务成功,一旦有失败就取消其余并抛出 ExecutionException。后者 Joiner 是完成策略的抽象,内置了几种:awaitAllSuccessfulOrThrow() 对应默认策略;allSuccessfulOrThrow() 适合同构子任务,直接从 join() 返回 List<T>;anySuccessfulOrThrow() 拿到第一个成功结果就取消其他;allUntil(Predicate) 则允许你自定义一个终止条件。
第三个类型参数 R_X 是 JDK 27 引入的,它的价值在自定义 Joiner 时体现得最充分。它允许你声明 join() 具体会抛什么受检异常,而不是一律包成 ExecutionException。文章里给了一个很漂亮的例子:一个 QuorumJoiner,等待多个副本读取中的多数派返回。它实现了 Joiner<T, List<T>, QuorumException>,把应用自定义的 QuorumException 作为 R_X,这样 join() 就能直接抛出这个业务异常,调用方不用再层层解包。onComplete() 回调里,每成功一个子任务就把结果塞进队列,一旦数量达到 quorum 就返回 true,触发作用域取消剩余读取。timeout() 方法则遵循 JDK 27 的约定,抛出同样的异常并带上 CancelledByTimeoutException 作为 cause。
超时和配置语义也原样保留。open() 可以接收一个 UnaryOperator<Configuration>,用来配置作用域名称(方便观测)、自定义 ThreadFactory,或者一个 deadline——如果超时发生在 join() 之前或期间,作用域会被取消。API 在结构约束上非常严格:只有打开作用域的线程才能调用 fork() 和 join();结果必须等 join() 返回后才能通过 Subtask.get() 读取;如果作用域关闭顺序不对,会立刻抛 StructureViolationException,而不是悄悄泄漏线程。子任务会继承 ScopedValue 绑定(JEP 506),在 JSON 线程转储里也会显示在父任务之下,调试时能看清调用关系。
有个细节很有意思:如果多个子任务同时失败,API 并不承诺 join() 会报告哪一个。这个问题在代码评审时被 Viktor Klang 提出来过,JEP 的负责人 Alan Bateman 特意改了 Javadoc,只承诺异常来自某个失败子任务,具体是哪个不做保证。这种刻意的留白,说明设计者不想在规范层面过度承诺,给实现留出余地。
对库和框架的维护者来说,这次转正的意义更大。之前要在自己的库里用结构化并发,等于逼着用户开预览标志,这对生产环境是很大的负担。现在 API 正式了,可以放心地把它作为底层依赖。社区里已经有人在做这件事:velo/structured-concurrency 项目把 JEP 543 的 API 反向移植到了 JDK 25,还提供了迁移脚本,帮用户在升级 JDK 后平滑切换到官方类。
这套思路能直接用到哪些场景?凡是需要并发聚合多个独立结果的地方都适用:并行调用多个下游服务再合并响应、对多个数据副本做多数派读取、并发跑多个模型推理取最快结果。它最大的价值不是性能提升,而是把并发代码的错误处理和生命周期管理从"靠自觉"变成"靠结构"。要注意的坑是:如果你还在 JDK 26 或更早,迁移时要仔细处理异常类型的变化,特别是 FailedException 到 ExecutionException 的替换,以及超时回调的签名改动。另外,结构化并发强制要求子任务必须在作用域内结束,如果你有"发出去就不管"的后台任务需求,它并不适合——那种场景应该用更松散的机制。JDK 28 预计 2027 年 3 月 GA,现在就可以在 early-access 版本上开始验证迁移了。

内容与图片版权归原作者所有 · 原文: https://www.infoq.com/news/2026/10/jep-543-jdk-28/?utm_campaign=infoq_content&utm_source=infoq&utm_medium=feed&utm_term=global