JEP 533:结构化并发(第七次预览)
引言
我们早就习惯了结构化的控制流:if、for、try 这些代码块层层嵌套,进入和退出的时机清清楚楚。可一旦进入并发领域,这种清晰感就消失了——任务被抛向线程池,返回一堆彼此无关的 Future,谁也管不住谁。JEP 533 的结构化并发,就是要把控制流那种井然有序的"块结构",带回到并发世界里来。
非结构化并发会「漏」
想想我们平时是怎么写并发的:把任务提交给一个 ExecutorService,拿回一堆 Future。这种模型看似灵活,实则暗藏隐患。
- **
ExecutorService返回的Future与调用方之间,没有任何生命周期关系。**方法返回了,Future 可能还在后台跑着;Future 的存活时间完全脱离了创建它的代码块。 - **一个子任务失败了,它的兄弟任务还在继续跑,继续烧资源。**没有人告诉它们"其中一个已经失败了,你们可以停了"。
- 取消要靠人工层层传递。你得手动把取消信号一级一级传下去,而这种手工传递常常会传丢——某个环节忘了处理,取消就静默失效了。
- **栈轨迹和线程转储里,看不出调用方与子任务的关系。**出了问题去看线程转储,你只看到一堆孤立的线程,根本无从得知谁 fork 了谁、谁在等谁。父子关系彻底丢失了。
一句话概括:非结构化并发会"漏"——任务泄漏、资源泄漏、上下文泄漏。
结构化的核心想法
结构化并发的思路直白得令人安心:让并发任务的生命周期像代码块一样嵌套。
- 任务在一个作用域(scope)里被拆成子任务,而这个作用域拥有它们。
join之后离开作用域时,任何还没完成的子任务都会被自动收束、带回可控状态,不会有谁偷偷溜出去继续跑。- **子任务的生命周期,像代码块一样嵌套在调用它的代码块之内。**父块没结束,子任务就不可能还活着。
- **这层父子关系对运行时和调试工具都是可见的。**它不只是程序员脑子里的一个心智模型,而是被运行时真正理解、并能反映在诊断工具里的结构。
API 的样子
具体到 API,结构化并发的写法非常符合 Java 的直觉:
- 在
try-with-resources块里用StructuredTaskScope.open(...)打开一个作用域。 - 用
scope.fork(...)在各自的虚拟线程上启动子任务。 - 用
scope.join()按策略等待结果。 - 策略由
Joiner表达:你可以要求全部成功、任一成功,或者定义你自己的规则。
try (var scope = StructuredTaskScope.open(
Joiner.allSuccessfulOrThrow())) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Order> order = scope.fork(() -> fetchOrder());
scope.join();
return new Response(user.get(), order.get());
}
这段代码打开一个作用域,fork 出两个子任务——一个查用户、一个取订单——然后 join。这里用的 Joiner.allSuccessfulOrThrow() 策略意味着:**如果任何一个子任务失败,另一个会被自动取消,并抛出异常。**整段并发逻辑被 try 块牢牢框住,任何子任务都不可能泄漏到块外。
前后对比
ExecutorService(传统方式):
Future的存活时间超出创建它的方法。- 错误要等到
get()被调用时才浮现,往往已经晚了。 - 取消与清理都得手工完成,也就随时可能被遗漏。
StructuredTaskScope(结构化并发):
- **子任务不可能活得比作用域更久。**它们被
try块的边界物理地约束着。 - 失败、成功和短路行为,全由
Joiner策略决定。 - 短路与清理由
Joiner统一负责,不再依赖你手工去写。
核心区别在于:传统模型里子任务能逃出去,而结构化并发里它们根本没法逃出去。
预览状态
这已经是第七次预览了——一个相当高的数字,它告诉你这套 API 经历了大量的打磨和反复重塑。
- 它在 JDK 27 中作为第七次预览发布,API 一路上被反复重新设计。
- 使用时需要
--enable-preview标志。 - 它与 Project Loom 的虚拟线程是配套设计的:虚拟线程让你可以毫无心理负担地 fork 成千上万个子任务,而结构化并发确保这些子任务全都被妥善管理、不会失控。两者相辅相成。
结论
结构化并发让并发任务的生命周期,变得和代码块一样清晰可见。子任务被作用域牢牢框住——它们不会泄漏,也不会被遗忘。这不仅让并发代码更好写、更好读,也让它在出问题时更好调试:线程转储里终于能看清谁是谁的父任务了。