Java 8 效率精进指南(7)更精细的并发控制:CompletableFuture

237 阅读5分钟

最近这些年,两种趋势不断地推动我们反思我们设计软件的方式。第一种趋势和应用运行的硬件平台相关,第二种趋势与应用程序的架构相关,尤其是它们之间如何交互。

image.png

Future 接口的语义与基本用法

Future 接口是在 Java 5 版本问世的,其设计初衷是 对将来某个时刻会发生的结果 进行建模,它建模得到一个异步计算,而这个计算会在将来某个时刻返回结果。想象一下,你下单点了一份外卖,订单详情提示你30min以后送到,在这30min时间里你可以不阻塞在等外卖小哥送餐,而是去做其它重要的事情。等到30min计时结束后,到前台取你所点的外卖。

Future 比更底层的 Thread 要容易使用,使用方法是将耗时操作封装在 Callable 对象,并将其提交给 ExecutorService。如下例所示,future.get() 是阻塞的,除非给它设置明确的超时时间,否则会一直阻塞直到返回。

ExecutorService executor = Executors.newCachedThreadPool();
Future<Double> future = executor.submit(new Callable<Double>() {
    public Double call() {
        return doSomeLongComputation();
    }});
    
doSomethingElse(); // ===> 提交任务后,就可以去做其他计算了
try {
    Double result = future.get(1, TimeUnit.SECONDS); // ===> 阻塞等待任务返回,超时1s
} catch (ExecutionException ee) {
    // 计算抛出一个异常
} catch (InterruptedException ie) {
    // 当前线程在等待过程中被中断
} catch (TimeoutException te) {
    // 在Future对象完成之前超过已过期
}

Future 接口的局限性

image.png

Future 机制实现了最基本的异步任务提交框架,并提供接口(isDone)用于检测异步计算是否已经结束,以及获取计算结果。但这些特性还不足以应对日益增多的并发需求,例如很难用简洁的代码控制两个任务并发执行,在获取到它们各自返回值后进行运算。典型的场景还有以下这些:

  • 将两个异步计算合并为一个 —— 这两个异步计算之间相互独立,同时第二个又依赖于第一个的结果。
  • 等待 Future 集合中的所有任务都完成。
  • 等待 Future 集合中最快结束的任务完成。
  • 通过编程方式完成一个 Future 任务的执行(即以手工设定异步操作结果的方式)。
  • 应对 Future 的完成事件(即当 Future 的完成事件发生时会收到通知,并能使用 Future 计算的结果进行下一步的操作,不只是简单地阻塞等待操作的结果)。

CompletableFuture 出现的背景

Java 8 的 CompletableFuture 彻底改变了我们处理异步任务的方式。它不仅解决了传统 Future 的痛点,更引入了一套强大的 函数式异步编程模型

image.png

单任务

假设查询商品价格 calculatePrice 是一个耗时操作,那么,可以得到如下计算价格的代码,它返回一个 Future<Double> ,表示计算价格的任务。

public Future<Double> getPriceAsync(String product) {
    CompletableFuture<Double> futurePrice = new CompletableFuture<>();
    new Thread( () -> {
        double price = calculatePrice(product);
        futurePrice.complete(price);
    }).start();
    return futurePrice; // ===> 无需等待计算任务,直接返回 Future 对象
}

以下是如何使用这段计算价格逻辑的代码,在调用 futurePrice.get() 获取计算后的价格时,线程是阻塞的。

Shop shop = new Shop("BestShop");
Future<Double> futurePrice = shop.getPriceAsync("my favorite product");

// 执行更多任务,比如查询其他商店
doSomethingElse();

// 在计算商品价格的同时
try {
    double price = futurePrice.get(); // ===> 阻塞式获取价格
    System.out.printf("Price is %.2f%n", price);
} catch (Exception e) {
    throw new RuntimeException(e);
}

使用工厂方法 supplyAsync 创建 CompletableFuture

可以简化掉手动创建 Thread 的过程,使代码更加简洁:

CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")    // 同步转换
    .thenApplyAsync(String::toUpperCase) // 异步转换
    .thenAccept(System.out::println); // 消费结果

回调

thenAccept 函数可用于 CompletableFuture 回调,当耗时任务返回时立刻触发。

findPricesStream("myPhone").map(f -> f.thenAccept(System.out::println));

超时控制

在超时后返回预设值。

future.completeOnTimeout(defaultValue, 2, TimeUnit.SECONDS);
future.orTimeout(1, TimeUnit.SECONDS); // Java 9+

多任务

并行执行

对同一商品,在不同商店里查询其价格,这是一个典型的并行调用场景。

public List<String> findPrices(String product) {
    List<CompletableFuture<String>> priceFutures = shops.stream()
        .map(shop -> CompletableFuture.supplyAsync(
            () -> shop.getName() + " price is " + shop.getPrice(product)))
        .collect(toList());
        
    return priceFutures.stream()
        .map(CompletableFuture::join)
        .collect(toList());
}

可以注意到,代码里将原本一条链上的函数调用拆分成了两部分,分别调用 collect(toList()) 进行终结操作。这样做的原因是,Stream 的中间操作具有 惰性 特性,除非显式调用终端操作 collect,否则不会触发 map 中的 supplyAsync 来生成任务。因此需要先创建异步任务 CompletableFuture 列表,然后对列表执行 join 操作以触发。

如果需要依赖两个耗时操作的结果进行下一步运算,可以使用 thenCombile 函数。

// 合并两个独立的 CompletableFuture 对象
Future<Double> futurePriceInUSD =
    CompletableFuture.supplyAsync(() -> shop.getPrice(product))
    .thenCombine( // ===> 启动下一个耗时任务
        CompletableFuture.supplyAsync(
            () -> exchangeService.getRate(Money.EUR, Money.USD)),
        (price, rate) -> price * rate // ===> 取上文两个耗时任务结果进行运算
);

依赖执行

已知“获取价格”、“获取优惠”分别都是耗时操作,且后者依赖于前者的结果。可以用 thenCompose 函数实现上述需求。

public List<String> findPrices(String product) {
    List<CompletableFuture<String>> priceFutures =
        shops.stream()
            .map(shop -> CompletableFuture.supplyAsync(
                () -> shop.getPrice(product), executor)) // 创建 CompletableFuture
            .map(future -> future.thenApply(Quote::parse)) // 应用 parse
            .map(future -> future.thenCompose(quote -> // 使用 thenCompose 继续组装存在依赖关系的耗时操作
                CompletableFuture.supplyAsync(
                    () -> Discount.applyDiscount(quote), executor))) // 依赖前一步算出的 quote
            .collect(toList()); // ===> 先启动收集,以生成任务
            
    return priceFutures.stream()
        .map(CompletableFuture::join) // ===> 等待任务完成
        .collect(toList());
}

任务组合策略

可以设置任何一个任务完成后就触发,或者所有任务完成才触发。

// 合并两个独立任务
future1.thenCombine(future2, (res1, res2) -> res1 + res2);

// 顺序组合(前一个结果作为下一个输入)
future1.thenCompose(res -> createNewFuture(res));

// 等待所有任务完成
CompletableFuture.allOf(futures)
    .thenRun(() -> /* 全部完成后的操作 */);

// 任一任务完成即触发
CompletableFuture.anyOf(futures)
    .thenAccept(firstResult -> {});

异常传播

可以在任务当中统一处理异常,并且在发生异常时降级返回。

CompletableFuture.supplyAsync(() -> {
        if (error) throw new RuntimeException();
        return "OK";
    })
    .exceptionally(ex -> "Fallback") // 异常恢复
    .handle((res, ex) -> {          // 统一处理结果和异常
        if (ex != null) return "ERROR";
        return res.toUpperCase();
    });

指定线程池

默认的线程池是 ForkJoinPool,可以根据任务具体情况,设置线程池的线程数。

// 指定专用线程池执行
Executor customPool = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> intensiveTask(), customPool);

参考资料

  • Java 8 in Action
  • DeepSeek