结构化并发(Structured Concurrency)从 JEP 453 进入 JDK 21 以来,一直在打磨细节。JEP 533 在 JDK 27 中进入集成状态,核心改动集中在异常处理和类型安全——具体来说,引入了新的 ExecutionException 类型,更新了 Joiner 接口,并新增了 open 方法的重载,让配置更顺手。这些变化不是大刀阔斧的重写,而是把之前模糊的边界收紧,让异常流变得可预测。
异常流为什么需要收紧
结构化并发的基本承诺是:父任务的生命周期包裹所有子任务,子任务异常不会"逃逸"到意料之外的地方。但在早期 API 中,这个承诺在异常传播上有缝隙——当多个子任务同时失败,你拿到的异常信息可能不够精确,类型也不够明确,很难在调用端做针对性处理。
JEP 533 引入了专用的 ExecutionException,替代之前依赖 InterruptedException 或笼统 Exception 来传递子任务失败信息的做法。这意味着:
- 子任务抛出的异常会被包装成
ExecutionException,里面保留了原始异常作为 cause。 Joiner在收集结果时,异常的类型路径更清晰,调用端可以按ExecutionException→ cause 的链条做精确匹配。- 多个子任务同时失败时,不再只抛第一个捕获到的异常,而是通过
ExecutionException的结构把所有失败信息都带出来。
这对写框架和中间件的开发者尤其重要——你终于可以在结构化并发里写出类似 catch (ExecutionException e) { handle(e.getCause()) } 的代码,而不是被迫用 catch (Exception e) 然后猜测里面到底是什么。
Joiner 接口的变化
Joiner 是结构化并发里把多个子任务结果"合拢"的核心接口。JEP 533 对它做了两件事:
- 类型签名收紧:
Joiner的泛型参数和返回类型更严格,减少了之前Object或 raw type 的残留,编译期就能捕获类型不匹配的问题。 - 异常处理策略显式化:
Joiner现在明确区分"全部成功才合并"和"容忍部分失败"两种策略,不再依赖隐式的异常吞没行为。
之前你可能这样写:
// 旧 API:Joiner 的异常处理隐含在调用约定里,类型不够严格
Subtask<String> task1 = scope.fork(() -> fetchUrl("https://a.com"));
Subtask<Integer> task2 = scope.fork(() -> countItems());
// Joiner 在异常时行为不够明确
现在 Joiner 的策略在接口层面就可见了,你不需要去翻文档猜"如果某个子任务抛异常,Joiner 到底是抛还是忽略"。
新的 open 重载
StructuredTaskScope.open() 之前只接受一个 Joiner 参数。实际使用中,你经常还需要配置线程名、超时、关闭策略等,这就导致要么写工厂方法包装,要么在 open 之后用不太优雅的方式设置。
JEP 533 新增了 open 的重载,允许传入配置对象,一次性把 Joiner 和其他选项都搞定:
// 新增的 open 重载,支持更灵活的配置
var scope = StructuredTaskScope.open(
myJoiner,
config -> config.withThreadNamePrefix("fetch-worker").withTimeout(Duration.ofSeconds(30))
);
这比之前"先 open 再想办法配置"的路径干净得多,也减少了配置遗漏导致的运行时意外。
实际代码示例
下面是一个完整的、可直接改造运行的结构化并发示例,展示了 JEP 533 中 ExecutionException 和新 open 重载的用法。注意:JDK 27 目前还在开发中,API 最终形态可能有微调,以下代码基于 JEP 533 已公开的变更方向编写,运行前请确认你使用的 JDK 27 构建版本已集成此 JEP。
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
import java.util.concurrent.ExecutionException;
import java.time.Duration;
import java.util.List;
public class Jep533Demo {
// 模拟远程调用
static String fetchPrice(String source) throws Exception {
if ("bad-source".equals(source)) {
throw new IllegalArgumentException("数据源不可用: " + source);
}
Thread.sleep(200); // 模拟网络延迟
return source + ": ¥128.50";
}
static String fetchInventory(String warehouse) throws Exception {
Thread.sleep(150);
return warehouse + " 库存: 42件";
}
public static void main(String[] args) {
// 使用新的 open 重载,同时传入 Joiner 和配置
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAll(),
config -> config
.withThreadNamePrefix("order-query")
.withTimeout(Duration.ofSeconds(2))
)) {
Subtask<String> priceTask = scope.fork(() -> fetchPrice("good-source"));
Subtask<String> badTask = scope.fork(() -> fetchPrice("bad-source"));
Subtask<String> invTask = scope.fork(() -> fetchInventory("华东仓"));
scope.join();
// 收集成功的结果
List<String> results = scope.joiner().results(priceTask, invTask)
.stream().filter(Subtask::isSuccess)
.map(t -> t.get()).toList();
results.forEach(System.out::println);
} catch (ExecutionException e) {
// JEP 533 核心变化:ExecutionException 明确携带子任务失败原因
Throwable cause = e.getCause();
if (cause instanceof IllegalArgumentException) {
System.err.println("业务异常: " + cause.getMessage());
} else {
System.err.println("未预期异常: " + cause);
}
} catch (InterruptedException e) {
System.err.println("被中断: " + e.getMessage());
}
}
}
运行前需要调整的地方:
Joiner.awaitAll()的确切方法名请对照你所用 JDK 27 构建的 API 文档,早期版本可能叫Collectors.awaitAll()或其他名称。scope.joiner().results(...)的调用方式在不同构建中可能有差异,核心思路是:join 之后从 Joiner 获取结果,而不是直接遍历 Subtask。- 如果你想测试超时行为,把
Thread.sleep的值调到超过 2 秒。
采用建议与边界
JEP 533 的改动方向是"收紧",不是"扩展"。如果你已经在用 JDK 21–23 的结构化并发预览 API,迁移时需要注意:
- 异常处理代码要改:之前 catch
Exception或InterruptedException来处理子任务失败的地方,现在应该优先 catchExecutionException,再从 cause 里取原始异常。不改也能跑,但你丢掉了类型安全的好处。 - Joiner 的泛型签名变了:如果你之前用 raw type 或
Joiner<Object>,编译期可能会出现新警告或错误,趁这次把泛型补上。 - open 重载是新增,不是替换:旧的
open(Joiner)调用仍然有效,不需要立刻改。但新重载让配置更集中,建议新代码优先用新重载。
边界提醒:
- 结构化并发仍然是预览特性(Preview),需要编译和运行时加
--enable-preview。JEP 533 的集成不代表已经最终定稿。 ExecutionException和java.util.concurrent.ExecutionException(CompletableFuture 用的那个)是同一个类,语义一致,但结构化并发里的传播路径不同——它是从StructuredTaskScope.join()抛出的,不是从Future.get()。- 如果你的子任务只抛
InterruptedException,传播路径和之前基本一致;ExecutionException主要包装的是非中断类型的子任务异常。
快速检查清单:
- 现有代码里有没有 catch
Exception处理结构化并发子任务失败?→ 改为 catchExecutionException。 - 有没有手动包装子任务异常的场景?→ 检查是否和新的
ExecutionException传播重复。 - 有没有在
open之后用反射或 hack 设置配置?→ 迁移到新的open重载。 - 编译参数是否包含
--enable-preview?→ JDK 27 仍然需要。
JEP 533 的变化不大,但每一步都在把结构化并发从"能用"推向"好用"。异常流可预测、类型签名收紧、配置入口统一——这些看似琐碎的打磨,正是 API 从预览走向正式发布前必须做的事。