CompletableFuture典型错误 -- 代码出自某大厂

一、错误理解

某团技术文章 《CompletableFuture原理与实践-外卖商家端API的异步化》 文中存在对于CompletableFuture错误用法,我们先来看其表述:

线程池循环引用会导致死锁

  • 如果父任务和子任务使用同一个线程池,并且线程池已满,子任务可能无法获得线程,从而导致死锁。
  • 解决方案是为父任务和子任务使用不同的线程池,避免循环依赖。
java 复制代码
public Object doGet() {
  ExecutorService threadPool1 = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(100));
  CompletableFuture cf1 = CompletableFuture.supplyAsync(() -> {
  //do sth
    return CompletableFuture.supplyAsync(() -> {
        System.out.println("child");
        return "child";
      }, threadPool1).join();//子任务
    }, threadPool1);
  return cf1.join();
}

如上代码块所示,doGet方法第三行通过supplyAsync向threadPool1请求线程,并且内部子任务又向threadPool1请求线程。threadPool1大小为10,当同一时刻有10个请求到达,则threadPool1被打满,子任务请求线程时进入阻塞队列排队,但是父任务的完成又依赖于子任务,这时由于子任务得不到线程,父任务无法完成。主线程执行cf1.join()进入阻塞状态,并且永远无法恢复。

为了修复该问题,需要将父任务与子任务做线程池隔离,两个任务请求不同的线程池,避免循环依赖导致的阻塞。

二、 正确实现

实际上,即使使用存在多个线程池,也可能存在循环引用导致死锁的问题,出现了问题依然难以监控。

文中的问题可以通过 CompletableFuture 的函数式回调功能实现:

java 复制代码
public class AsyncOperation {
    static final Executor threadPool1 = Executors.newFixedThreadPool(10);
    // 略去线程池定义
    public CompletableFuture<String> doGet() {
        return CompletableFuture.supplyAsync(() -> {
            // do sth
            return "parent";
        }, threadPool1).thenComposeAsync(parentResult -> {
            // 子任务
            return CompletableFuture.supplyAsync(() -> {
                System.out.println("child");
                return "child";
            }, threadPool1);
        }, threadPool1).thenApplyAsync(x -> {
            // ...
            return "end";
        }, threadPool1);
    }

    public static void main(String[] args) {
        AsyncOperation operation = new AsyncOperation();
        CompletableFuture<String> future = operation.doGet();
        // ...
        String result = future.orTimeout(200, TimeUnit.MILLISECONDS)
                .join();
        // ...
    }
}

代码说明:

2.1 避免同步阻塞

原代码中通过 .join() 同步阻塞父任务线程 ,导致父任务必须等待子任务完成才能释放线程资源。当线程池满时,子任务无法获取线程,父任务也无法完成,形成死锁。正确的做法是 使用 thenComposeAsync 替代嵌套的 join(),将子任务与父任务异步串联,避免阻塞父任务线程。

2.2 线程池资源释放

通过 orTimeout 添加超时控制,防止无限期阻塞。

三、进一步讨论

3.1 避免嵌套的 Future

以下是Guava文档对于嵌套Future问题的讨论, CompletableFuture 同理:

在代码调用通用接口并返回 Future 的情况下,可能会产生嵌套的 Future。例如:

java 复制代码
executorService.submit(new Callable<ListenableFuture<Foo>>() {
  @Override
  public ListenableFuture<Foo> call() {
    return otherExecutorService.submit(otherCallable);
  }
});

这段代码会返回一个 ListenableFuture<ListenableFuture<Foo>>。这种写法是错误的,因为如果外部 Future 的取消操作与外部 Future 的完成操作发生竞争,这种取消操作将无法传播到内部的 Future。此外,如果使用 get() 或监听器检查内部 Future 的失败,除非特别小心处理,否则 otherCallable 抛出的异常可能会被抑制。

为了避免这些问题,Guava 的所有 Future 处理方法(以及 JDK 中的部分方法)都提供了 *Async 版本,可以安全地解构这种嵌套。例如:

  • transform(ListenableFuture<A>, Function<A, B>, Executor)transformAsync(ListenableFuture<A>, AsyncFunction<A, B>, Executor)
  • ExecutorService.submit(Callable)submitAsync(AsyncCallable<A>, Executor) 等。

3.2 如果线程池处理不了,应该在之前就拦截掉

CompletableFuture 没有直接支持取消(有折中方法,感兴趣可请参考 Spring 中 DelegatingCompletableFuture实现),在资源不足时,会产生问题。对于时间敏感任务,通常会根据目标响应时间进行提前拒绝或者降级。

线程池虽然本身支持流量控制,但是在复杂场景中,也需要进行多维度的流量控制。

3.3 取消了的任务可能还在任务队列中

对于普通线程池,取消了的任务可能还在任务队列中,线程池支持purge等方法进行清理。

3.4 设置超时时间

目前很多公司都要求异步任务必须设置超时时间,这一规范提升了系统的容错能力。Guava 提供的超时机制支持取消传播和线程中断,遇到复杂问题时,可以考虑使用。

3.5 关注线程池监控数据

比如 CPU 使用率、任务吞吐量、任务等待时间、拒绝任务数等指标,对于线程池参数进行合理配置。

相关推荐
爱尚你199312 分钟前
Java 泛型与类型擦除:为什么解析对象时能保留泛型信息?
java
全栈派森31 分钟前
云存储最佳实践
后端·python·程序人生·flask
电商数据girl35 分钟前
酒店旅游类数据采集API接口之携程数据获取地方美食品列表 获取地方美餐馆列表 景点评论
java·大数据·开发语言·python·json·旅游
CircleMouse35 分钟前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
ktkiko1142 分钟前
顶层架构 - 消息集群推送方案
java·开发语言·架构
zybsjn1 小时前
后端系统做国际化改造,生成多语言包
java·python·c#
Unity官方开发者社区1 小时前
《Cryptical Path》开发诀窍:像玩游戏一样开发一款类Rogue游戏
java·游戏·玩游戏
_星辰大海乀1 小时前
表的设计、聚合函数
java·数据结构·数据库·sql·mysql·数据库开发
IT成长史2 小时前
deepseek梳理java高级开发工程师微服务面试题-进阶版
java·spring cloud·微服务
zkmall2 小时前
Java + 鸿蒙双引擎:ZKmall开源商城如何定义下一代B2C商城技术标准?
java·开源·harmonyos