说实话,看到“Java 25”这个版本号的时候,我的第一反应不是又多了哪些语法糖,而是:并发编程这块用了十几年的老骨头,终于被官方正式推到台面上了。Java 25的主线特性里,结构化并发(Structured Concurrency)差不多已经到了可以认真入手的阶段。如果你还在用CompletableFuture组合并发请求,或者靠ExecutorService + CountDownLatch手动凑一批线程然后干等结果,那这个特性值得你花一个下午重新理解一遍。
简单说,结构化并发让“一组并发子任务”的生命周期和代码作用域绑定在一起:要么一起成功,要么被统一取消、统一处理异常,不再出现“主线程早就返回了,后台线程还在偷偷跑”的失控局面。这篇内容主要面向日常写业务代码的Java开发者,下面从原理、API、实战场景到踩坑记录,完整拆一遍。
1. 为什么Java 25要引入结构化并发
1.1 传统并发模型的三个老大难
先回顾一下过去十年我们怎么写并发。最常见的是ExecutorService提交一批Callable,拿回一堆Future,然后逐个get()。代码本身不复杂,但真正维护过的人都知道问题在哪:
第一,线程逃逸。你向线程池提交了一个任务,然后主线程因为某个分支提前return了,这个任务并不会被取消,它继续在池子里跑,直到自然结束。小项目没什么感觉,但线上系统里这种“没人管的线程”一多,CPU、数据库连接池、日志文件句柄全都被白白占着。
第二,异常处理太散。每个Future.get()都可能抛ExecutionException,你要在多个地方写catch逻辑。如果十个任务里有三个失败,到底算整体失败还是部分成功?自己定义一套错误聚合逻辑,代码写起来非常啰嗦,而且每个人写的都不一样。
第三,取消几乎靠手动。一个聚合操作,本来需要同时调三个接口,其中两个已经返回了,第三个卡了20秒。你明知道整体已经没戏了,但前两个线程还在继续跑——因为它们根本不知道彼此的存在。
这三个问题背后其实是一件事:并发任务的边界不清晰。线程池只是一个“资源池”,它管理的是执行资源,不是任务之间的依赖关系,更不是任务的生命周期。就像你把一批快递员派出去,但没有一个站长负责说“这一单整体不送了,所有人立刻回来”。
1.2 结构化并发的核心思想:让并发像作用域一样有序
结构化并发并不是Java原创的概念,这个思想在其它语言生态里已经实践了很久。它的核心主张是:并发任务必须跟语法作用域绑定。你在一个代码块里fork出去的子任务,必须在代码块结束前全部分支合并回来,就像方法调用一样——方法进入时开启子任务,方法退出前等待或取消它们。
在Java里的落地形态就是StructuredTaskScope。这个类没有引入新语法,它就是一个强化版的ExecutorService,但责任完全不同:普通的线程池只负责“跑”,StructuredTaskScope负责“跑 + 收 + 取消 + 异常汇总”。当主线程离开synchronized块(准确说是try-with-resources块)时,scope会自动等待所有子任务结束,或者强制取消还没结束的任务,然后才真正释放资源。
用一句话概括:它把并发的“过程”变成了并发的“事务”。要么全部提交,要么全部回滚。这个转变背后的价值,在故障排查时体会最明显——你再也不用从几万条线程转储里捞人,因为你一眼就能看到哪些线程是某个具体作用域的子任务,它们是怎么被组织起来的。
2. 结构化并发的API骨架:StructuredTaskScope拆解
2.1 四个核心方法的协作逻辑
StructuredTaskScope的用法比你想的还简单,核心方法只有四五个。直接看一个基础结构:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<OrderInfo> orderFuture = scope.fork(() -> fetchOrder(orderId)); Future<UserInfo> userFuture = scope.fork(() -> fetchUser(userId)); scope.join(); // 1. 等待所有任务结束,或遇到失败提前结束 scope.throwIfFailed(); // 2. 如果有任务失败,统一抛出 OrderInfo order = orderFuture.resultNow(); UserInfo user = userFuture.resultNow(); return new OrderDetail(order, user); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("并发任务被中断", e); } catch (ExecutionException e) { throw new BizException("聚合查询失败", e.getCause()); }这代码读完,你应该能感觉到它和其它模式的本质区别:整个并发的开启、等待、收尾在代码结构上是完全嵌套的,没有任务能逃出这个try块。
逐个方法拆一下:
fork(Callable):提交一个子任务,返回Future。跟线程池的submit长得一样,但它绑定在scope的生命周期内。join():等待所有子任务完成,或者被某个子任务的失败/取消打断。注意这个join不是Thread.join,是scope自己语义上的“汇合”。throwIfFailed():只有ShutdownOnFailure策略才有。如果有任何一个子任务抛异常,这里会抛出第一个异常。这样你不需要自己遍历所有Future去检查,异常汇总逻辑由框架代劳。resultNow():JDK 19之后Future新增的终结方法,直接取已完成任务的结果。如果任务还没完成,会抛IllegalStateException;所以在join()之后再调用是安全的。它比get()更值得养造成习惯。
2.2 策略类ShutdownOnFailure与ShutdownOnSuccess怎么选
StructuredTaskScope提供几个现成的关闭策略子类,这是设计上很精妙的一点。你要做的不是自己写状态判断,而是选一个策略,声明“什么样的结果出现后,整个scope可以结束”。
ShutdownOnFailure:任何一个子任务失败,立即关闭scope并取消其他还在运行的子任务。适合“所有调用必须全部成功”的聚合场景,比如下单前的校验、订单金额计算。ShutdownOnSuccess<T>:第一个子任务成功,就关闭scope,取消其它还在跑的任务。适合“多个服务提供同种能力,谁先返回就信谁”的场景,比如多路查询最优价格、故障转移。ShutdownOnTimeout:整体运行超过指定时间就关闭scope,适合需要硬超时的批量任务。
这里有个容易误用的点:ShutdownOnSuccess的关闭条件是“成功一个”,但剩下已经开始跑的任务直接取消。它不会保证“返回最好的那个结果”,只是“返回第一个成功的结果”。如果你需要比较所有结果后取最优,那应该用ShutdownOnFailure配合全部完成后再自己排序,或者等待所有结果后再统一处理。我在实际项目里见过有人拿ShutdownOnSuccess去做了“查多个数据源然后合并”,这语义是不对的,因为其它数据源的结果你根本拿不到。
2.3 子任务结果的读取时机与异常传播
很多从CompletableFuture转过来的人,刚开始会对“什么时候能读结果”很困惑。在CompletableFuture的模式里,thenApply链式读取是非常自然的;但在结构化并发里,读结果之前必须保证scope已经汇合。
为什么这么严格?因为scope的任务生命周期是跟线程栈绑定的。join()之前,可能有的任务还在执行,你贸然读数据,要么读到不完整状态,要么阻塞等待。而join()之后读resultNow()则是一个纯内存操作,不会阻塞,这让代码的时序关系变得非常确定。
异常传播的链路是这样的:子任务内部抛出的异常,会被捕获并存储到scope内部,join()返回之后,throwIfFailed()把你第一个遇到的异常重新抛出来,包装成ExecutionException。注意异常不是吞掉,而是顺序保留,scope.exceptions()可以拿到所有候选异常,在需要完整错误清单的场景下很有用。
3. 实战:四个典型并发场景的完整实现
3.1 场景一:聚合多个RPC结果并快速失败
这是最典型的业务场景。比如一个订单详情页,要同时查订单主表、用户信息、商品快照,三个查询之间没有依赖。传统写法是开三个线程回到等地,稍微好一点的会用CompletableFuture.allOf。现在用结构化并发:
OrderDetail loadOrderDetail(long orderId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<Order> orderFuture = scope.fork(() -> orderDao.queryById(orderId)); Future<User> userFuture = scope.fork(() -> userDao.queryLoginName(orderId)); Future<Item> itemFuture = scope.fork(() -> itemDao.querySnapshot(orderId)); scope.join(); scope.throwIfFailed(); return new OrderDetail( orderFuture.resultNow(), userFuture.resultNow(), itemFuture.resultNow() ); } }这里我不建议在fork里直接做日志打印、埋点上报等无关操作——子任务的职责应该单一,任何额外的副作用都会拖慢fork返回并干扰主流程的取消语义。三个查询中任何一个失败,scope会立刻取消另外两个还没有跑完的任务,throwIfFailed把异常抛给上层。你不需要像以前那样写allOf().exceptionally()或者手动判断每个future的isDone。
3.2 场景二:并发抢最优结果
再来看ShutdownOnSuccess的用法。假设要做价格聚合时同时访问三个供应商的报价接口,响应时间差异很大,但整体只需要一个能用的价格就够:
Price getFastestPrice(long skuId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Price>()) { scope.fork(() -> supplierA.queryPrice(skuId)); scope.fork(() -> supplierB.queryPrice(skuId)); scope.fork(() -> supplierC.queryPrice(skuId)); scope.join(); return scope.result(); // 返回第一个成功子任务的返回值 } }注意,ShutdownOnSuccess调用join()之后,如果所有任务都失败了,scope.result()会抛出ExecutionException,你需要在外层捕获并决定是抛错还是降级。如果遇到“第一个成功但结果超时/过期”这种业务校验需求,最好还是在子任务里把结果和校验逻辑封装在一起,否则你拿到一个成功结果但发现不能用,这时候scope已经关闭,其它任务已经被取消了,你只能重新发起一轮调用。
3.3 场景三:超时控制与错误信息汇总
真实系统里“聚合调用整体不能超过800ms”这类约束很常见,ShutdownOnTimeout就是为此设计的:
try (var scope = new StructuredTaskScope.ShutdownOnTimeout(800, TimeUnit.MILLISECONDS)) { Future<RiskResult> riskFuture = scope.fork(() -> riskControl.check(userId, orderId)); Future<CreditResult> creditFuture = scope.fork(() -> creditService.evaluate(userId)); scope.join(); scope.throwIfFailed(); if (riskFuture.state() == Future.State.SUCCESS && creditFuture.state() == Future.State.SUCCESS) { return buildDecision(riskFuture.resultNow(), creditFuture.resultNow()); } return buildPartialDecision(...); // 有些结果没拿到,按降级逻辑走 }对比传统写法里自己维护超时计数、写循环检查是否超时的代码,这里就是一个构造参数的事。而且超时触发后,scope会尝试取消所有还没有完成的任务,这比你自己决定“超时了,但那两个线程继续跑”要可靠得多。另外,如果某个子任务被取消,Future.state()会返回CANCELLED,处理时要像处理null一样定义为业务降级分支。
3.4 场景四:与虚拟线程组合做大批量任务
结构化并发和虚拟线程是天然一对。虚拟线程解决了“线程数不够”的问题,但没解决“线程之间协调”的问题;结构化并发正好补上后者。大批量并发处理每个元素时,可以这样写:
List<ReportRow> buildReport(List<String> itemIds) throws Exception { try (var executor = Executors.newVirtualThreadPerTaskExecutor(); var scope = new StructuredTaskScope.ShutdownOnFailure(executor)) { List<Future<ReportRow>> futures = itemIds.stream() .map(id -> scope.fork(() -> buildRow(id))) .toList(); scope.join(); scope.throwIfFailed(); return futures.stream() .map(Future::resultNow) .toList(); } }因为虚拟线程很轻量,你可以为每个元素开一个子任务,不需要像以前那样担心线程池耗尽。StructuredTaskScope的多参构造器可以接收一个ExecutorService用于调度任务,JDK 21开始完全兼容虚拟线程。不过要注意:如果你用默认的无参构造器,底层是平台线程池;在大批量任务的场景下最好显式传入newVirtualThreadPerTaskExecutor(),否则线程数量可能成为瓶颈。
4. 和CompletableFuture、线程池相比,优势到底在哪
4.1 语义层面的差距:取消与异常传播
很多人会问:CompletableFuture.allOf()不是也能等所有任务完成吗?为什么要换。
我的答案是:CompletableFuture解决的是“异步编排”,结构化并发解决的是“生命周期管理”。前者是流水线,后者是组织架构。
allOf()有两个核心短板。第一,它不能取消已经从线程池里分发出去的任务。比如你有三个请求已经发出,其中一个失败了,你希望剩下两个取消,allOf().exceptionally()做不到——你拿不到正在执行的线程的上下文。第二,异常信息是塌缩的,CompletableFuture的异常堆栈告诉你“某个环节失败了”,但很难从顶层看到到底哪个分支出的问题,因为链式调用的栈已经被异步边界切断。
结构化并发则不同。scope知道所有子任务是谁,当它需要取消时,能逐个向子任务发起中断。取消逻辑不依赖你在业务代码里加“合作变量”,而是语言层面的标准行为。
4.2 超时统一处理与资源回收
线程池模式还有个隐蔽的资源泄漏点:主线程get(timeout)超时了,但后台任务并不知道,继续执行几分钟后才返回。这时候如果你用Future.cancel(true)去中断,任务内部没有响应中断的逻辑,线程也不会立刻释放。
结构化并发用“作用域退出 = 任务结束”来根治这个问题。scope.close()的语义会保证:等待所有子任务结束,或者强制取消还在运行的子任务,然后才返回。即使某个子任务不响应中断,scope也会在close时记录状态,至少不会再让任务“逃逸”。这个特性对排查故障非常有价值——你终于可以在一个明确的位置回答“这个任务到底结束了没有”。
4.3 一张表格看清三种方案的取舍
| 维度 | ExecutorService + Future | CompletableFuture | StructuredTaskScope |
|---|---|---|---|
| 子任务生命周期管理 | 无,任务可能逃逸 | 无,无法反向取消已提交任务 | 强绑定,close时统一收尾 |
| 异常汇总策略 | 手动遍历Future逐个get | 链式回调,异常扁平化 | 策略类统一声明,实现自动聚合 |
| 超时控制 | get(超时),但任务继续运行 | 没有整体超时概念 | ShutdownOnTimeout整体超时并取消 |
| 代码可读性 | 大量等待和协调逻辑 | 回调链或thenCompose嵌套,代码分散 | 顺序书写,线程栈完全嵌套 |
| 适合场景 | 大量独立无依赖任务 | 异步流水线、跨模块异步事件 | 单次调用内部的扇入扇出聚合 |
| 调试与线程转储 | 无法关联任务父子关系 | 关联关系弱 | 有父子层级,一个线程转储就能看懂 |
注意这个表格的最后一列非常重要:CompletableFuture并不是要被替代的,它适合完全不同的上下文。如果你是做一个异步消息消费者,收到消息后转成CompletableFuture一路往下传,让后面的框架去编排,那结构化并发帮不上忙。但如果你是在某个具体方法内部发起一组并发调用,然后在方法返回前必须拿到结果,那结构化并发是更对味的选择。
5. 实操中容易踩的几个坑
5.1 任务逃逸问题:千万别把Task泄漏到作用域外
这是结构化并发里最隐蔽的一个坑。假设你写:
Future<Foo> leaked; try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { leaked = scope.fork(() -> expensiveTask()); scope.join(); } // 方法退出,scope关闭 leaked.resultNow(); // 这里会怎样?这段代码看起来没问题,但如果你把fork出来的Future存放在一个成员变量里,或者某个内部缓存里,然后外层代码在scope关闭之后继续访问它,就会遇到IllegalStateException。Future.resultNow()明确要求任务已经完成且scope已关闭。这是设计上的有意为之——防止跨作用域访问任务状态,避免任务逃逸带来的不确定性。
我实测遇到过一种更隐蔽的情况:在fork的子任务里,又创建了一个新的StructuredTaskScope,然后把这个内层scope的Future存到外层容器里。内层scope的try块结束之后,外层代码再访问这个Future,报错信息非常不直观,排查很久才发现是跨作用域引用了。规则很简单:fork和join必须在同一个线程、同一个scope块内,不允许跨作用域移动Future。
5.2 fork与join必须在同一线程
StructuredTaskScope还有一个强约束:fork()只能由拥有scope的线程调用。如果你在子任务内部调用外层scope.fork(),会直接抛IllegalStateException。理由很直接——如果子任务也能派生出孙任务,那scope就没法保证在作用域退出前收齐所有任务,异常传播和取消逻辑都会失控。
这在实际编码中提醒我们:并行任务的结构应该是一棵扁平树。如果你的某个子任务内部还需要并行处理更多内容,那应该把内层的并行拆分封装成一个独立方法,让方法内部自己创建、自行关闭scope。嵌套scope是被允许的,但内层scope必须是内层方法自有的局部变量,而不是外层的共享引用。
5.3 使用预览API时的编译运行参数
截至Java 25这个时间点,结构化并发已经历了多轮孵化与预览,主接口已经比较稳定,但如果你在用非转正的预览版本,编译和运行都需要显式开启。我遇到过prefix忘了加参数,编译直接报“不支持预览特性”的情况。
# 编译时 javac --release 25 --enable-preview Main.java # 运行时 java --enable-preview Main如果你的构建工具是Maven或Gradle,需要在编译器插件和exec插件里都加--enable-preview。还要注意--release和--enable-preview的版本必须匹配,比如Java 25编译就必须--release 25,不能写--release 21然后硬开预览。这个问题在CI流水线上最容易出岔子,因为本地JDK和目标环境的JDK版本不一致。
5.4 关闭scope时的等待语义
try-with-resources在代码块结束时会自动调用scope.close()。这里的close()并不是立刻释放资源,它会先向所有未完成任务发出取消信号,然后等待它们真正结束。所以如果你在close()之前忘了join(),那这个scope块会阻塞到所有任务完成,表现起来就像程序突然卡住了一样。
我在调试一个内网服务时遇到过类似的诡异现象:明明接口响应很快,但线程池里的线程数一直在累积。后来发现是子任务在做一个外部调用的重试逻辑,主线程已经判断失败进入finally,但close()在等待这个还在重试的任务结束,从而导致线程一直被占用。如果你不需要等待任何结果,纯粹是想取消这批任务,那最好在进入close之前先手动调一下scope.shutdown()触发主动取消,让close的等待时间尽可能短。这个细节在一些低版本JDK里行为略有差异,建议以你当前JDK的API文档为准。
6. 我从这个特性里拿到的最实际收益
光讲理论没意思,说点实际的。我上一个负责的数据同步任务,需要每天凌晨从四个外部数据源各拉一批数据,然后做聚合、去重、落库。用老的写法大概是:一个线程池、四个Future、手动判断每个Future是否正常、失败后还得逐个cancel。整个方法两百多行,光错误处理分支就占了一大半。
用结构化并发重写之后,核心逻辑缩到了六十行。ShutdownOnFailure解决了“一个数据源挂了,另外三个不用白等”的问题;join()之后的resultNow()读取保证时序确定性,不再需要写while(!future.isDone())这样的轮询代码。最让我舒服的是排查问题时的体验:一份线程转储下来,StructuredTaskScope创建的子线程名字天然带着scope的名字和fork的顺序,你能立刻看到“这个操作下面挂了哪些并行请求”,不用再从一堆ForkJoinPool.commonPool-worker-3里猜是谁的线程。
再分享一个小技巧:StructuredTaskScope的构造器可以传入自定义的任务名称前缀,我习惯把业务场景写进去,比如new StructuredTaskScope.ShutdownOnFailure("order-detail-query")。这样一旦线上出问题,线程转储里直接能看到是哪个场景的哪个scope,比翻日志对时间戳高效得多。
如果你正在构建一个新的Java服务,而且JDK版本已经到了25,我强烈建议你在“单个请求内部的并行聚合”这类场景里,优先考虑结构化并发。它不是银弹,但它是Java在并发模型上最像样的一次纠偏——把线程还给业务,把生命周期交给结构。