Java 21结构化并发实战:再也不用手动管理线程池了

2026-08-31 0 617

上个星期我用Java 21重构了一个老项目里的并发逻辑。原来是用CompletableFuture + 手动线程池去同时调用三个第三方接口,再汇总结果。写的时候头大,试了很多姿势,finally里加future.cancel(),还要处理各种异常,代码绕来绕去。后来看到JDK 21正式加入了StructuredTaskScope,也就是结构化并发。花了一下午把那段逻辑重写了一遍,代码量直接少了三分之一,关键是出问题的时候好排查了。

这篇文章拿一个实际场景来演示:APP首页需要同时获取用户信息、今日推荐列表、系统通知,三个接口可能只是一个个RPC调用,但处理逻辑类似。我会先写上古老的写法,再写结构化并发的写法,顺便说说踩坑记录。

场景设定

假设我们有一个HomeService,需要给客户端返回一个聚合对象,里面包含以下内容:

  • 用户基本信息(UserInfo)
  • 根据用户兴趣推荐的商品列表(List<Product>)
  • 系统公告(Notice)

三个数据源互相独立,所以应该并行去取。以前用ExecutorService的话,大概会这么写:

ExecutorService executor = Executors.newFixedThreadPool(3);

Future<UserInfo> userFuture = executor.submit(() -> fetchUserInfo());
Future<List<Product>> productFuture = executor.submit(() -> fetchProducts());
Future<Notice> noticeFuture = executor.submit(() -> fetchNotice());

try {
    UserInfo user = userFuture.get(2, TimeUnit.SECONDS);
    List<Product> products = productFuture.get(2, TimeUnit.SECONDS);
    Notice notice = noticeFuture.get(2, TimeUnit.SECONDS);
    return new HomeData(user, products, notice);
} catch (Exception e) {
    // 其中一个超时或失败,要么全部取消,要么记录日志
    userFuture.cancel(true);
    productFuture.cancel(true);
    noticeFuture.cancel(true);
    throw new RuntimeException("聚合查询失败", e);
} finally {
    executor.shutdown();
}

这个写法最大的问题是:如果三个任务都成功,那没问题。但如果第一个future超时了,你会取消另外两个,这个逻辑没问题。可是如果第一个future抛异常,第二个future还在正常运行,你虽然取消了,但可能已经造成了线程资源浪费。还有一个更麻烦的事:线程池是手动创建的,如果忘记shutdown,整个应用可能直接崩掉。

再一个痛点是错误信息不直观。哪一步失败,怎么失败的,你都得靠断点和日志去猜。特别是并发任务多的时候,查找问题就像大海捞针。

结构化并发:把并发任务装进一个“代码块”

结构化并发的核心思想是:任务的生命周期与代码块一致。你创建一个StructuredTaskScope,在作用域里提交子任务,最后在作用域结束的地方统一join。如果其中某个子任务失败,scope会取消其他未完成的任务。就像写顺序代码一样,结构清晰,自动管理生命周期。

用StructuredTaskScope重写上面的逻辑,代码变成这样:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

    Subtask<UserInfo> userSubtask = scope.fork(() -> fetchUserInfo());
    Subtask<List<Product>> productSubtask = scope.fork(() -> fetchProducts());
    Subtask<Notice> noticeSubtask = scope.fork(() -> fetchNotice());

    scope.join();
    scope.throwIfFailed(e -> new RuntimeException("聚合查询失败", e));

    UserInfo user = userSubtask.get();
    List<Product> products = productSubtask.get();
    Notice notice = noticeSubtask.get();

    return new HomeData(user, products, notice);
}

ShutdownOnFailure 这个策略的意思是:只要有一个子任务失败,就取消其他未完成的子任务,然后抛出异常。你也可以用 ShutdownOnSuccess,意思是只要一个成功就返回,适合“谁先返回用谁”的场景。

你自己看,这个写法是不是比Future清爽很多?最关键的是不需要再手动管理线程池了。因为StructuredTaskScope内部会创建一个虚拟线程池,并且保证所有子任务在scope关闭前都结束。用完scope自动关闭,不会留下游离线程。

更细一点:Subtask的获取细节

fork返回的Subtask对象,你可以理解成一个“有结果的句柄”。在scope.join()之前不能调用get(),否则会报错。只有join之后才安全。

而且join()不是无脑等待,它有两个变体:join()等待所有任务完成(不管是成功还是失败),join(Duration)只等待一段时间,超时后如果还有任务没结束,那么scope的状态就会变成“未完成”。这时候如果你用ShutdownOnFailure,它会自动cancel掉还在跑的任务。

有一次我没加超时,然后其中一个RPC服务卡死了,整个接口也卡住不动,直到上游timeout才返回。后来我改成这样:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    // fork...
    scope.join(Duration.ofSeconds(2));
    scope.throwIfFailed(e -> new RuntimeException("聚合查询失败", e));

    if (!userSubtask.isCompleted() || !productSubtask.isCompleted() || !noticeSubtask.isCompleted()) {
        throw new RuntimeException("查询超时");
    }
}

这样就能控制整体的超时时间,而不用分别给每个future设置超时。而且写法比多个future individually try-catch舒服多了。

搭配虚拟线程才是最佳拍档

StructuredTaskScope底层默认使用虚拟线程执行fork的任务。虚拟线程的创建成本极低,所以即使同时fork几百个任务也没多大压力。在我这个场景里只有三个任务,感受不明显。但如果你要循环遍历一个列表,为每一个item并发去调用远程接口,那虚拟线程的价值就体现出来了。

举个例子,以前用CompletableFuture + fixedThreadPool(16)要小心队列积压,现在直接:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    List<Subtask<ItemData>> subtasks = itemList.stream()
            .map(item -> scope.fork(() -> fetchItemDetail(item)))
            .toList();

    scope.join();
    scope.throwIfFailed(ex -> new RuntimeException("拉取明细失败", ex));

    return subtasks.stream()
            .map(Subtask::get)
            .toList();
}

这代码是不是很惊艳?没有线程池、没有countDownLatch、没有future list。而且由于虚拟线程很轻,你甚至不需要限制并发数量,完全可以把整个列表全部丢进去。不过还是要小心下游服务的承受能力。

异常处理的一个坑:运行时异常会被包装

我的子任务里如果抛的业务异常是自定义的CheckException,fork的时候不会立即抛出来,而是会用scope.throwIfFailed转一圈。throwIfFailed里接收一个Function,参数是异常对象,你可以在这里重新包装或者直接返回原异常。但如果你直接不调用throwIfFailed,异常就会被吞掉,Subtask.get()也不会抛出,而是返回null或者卡住。所以记住:join之后必须调用throwIfFailed。

另外,如果子任务里抛出的异常本身就是Error(比如OutOfMemoryError),那是别的情况。反正普通异常没问题。

结构化并发不适用什么场景

它不适合那种“发给后台就不管了”的异步任务。比如你提交一个日志批量写入任务,不期望等它结果。这种场景用StructuredTaskScope就不合适,因为scope退出时会等待所有任务完成。所以它适合需要聚合多个结果、且能清晰定义任务生命周期的场景。

还有就是不要在fork的任务里再去创建新的StructuredTaskScope,这会导致结构嵌套过度,而且外层scope不会等待内层scope的子任务。最好保持平铺结构。

最终的HomeService长这样

public HomeData getHomeData(Long userId) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        Subtask<UserInfo> userSubtask = scope.fork(() -> userClient.fetchInfo(userId));
        Subtask<List<Product>> productSubtask = scope.fork(() -> recommendClient.fetchProducts(userId));
        Subtask<Notice> noticeSubtask = scope.fork(() -> noticeClient.fetchLatest());

        scope.join(Duration.ofSeconds(2));
        scope.throwIfFailed(e -> new RuntimeException("首页聚合接口异常", e));

        if (!userSubtask.isCompleted() || !productSubtask.isCompleted() || !noticeSubtask.isCompleted()) {
            throw new RuntimeException("首页聚合接口超时");
        }

        return new HomeData(
                userSubtask.get(),
                productSubtask.get(),
                noticeSubtask.get()
        );
    }
}

代码就这么干净。没有一长串future.cancel(),也没有线程池优雅关闭的问题。最让我喜欢的一点是,如果你在调试时看到某个任务一直没返回,在IDE里展开scope,能清楚看到还有几个子任务没结束,都是什么状态,比之前查Future好看多了。

说几句大实话

结构化并发并不是什么黑魔法,它就是把并发任务的作用域变得更明确,让代码读起来像顺序执行一样。如果你手下的项目还是JDK17,那暂时用不了,得升到JDK21。可能会担心升级成本,但我自己用下来,真正影响代码兼容的其实很少。新项目我建议直接上JDK21,老旧项目还是老老实实用CompletableFuture吧,别乱折腾。

最后,如果你也打算在项目里用StructuredTaskScope,建议把超时和异常处理提前设计好。这玩意儿比Future好用,但也好用到容易让人忘记边界。

Java 21结构化并发实战:再也不用手动管理线程池了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 21结构化并发实战:再也不用手动管理线程池了 https://www.taomawang.com/server/java/2676.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务