跳到正文
hello world

28. CompletableFuture 入门:比 Future 好用在哪里

发布于阅读量 3

28. CompletableFuture 入门:比 Future 好用在哪里

前面已经学了 Future

Future 能解决一个很重要的问题:

任务交给线程池执行以后,我后面可以通过 Future 拿到结果。

比如:

Future<String> future = executor.submit(() -> {
    return "PDF 处理成功";
});

String result = future.get();

这套写法能用,但用着用着会发现一个问题:Future 更像是一个“结果凭证”,它本身不太擅长表达后续流程。

比如我想写这样一个逻辑:

PDF 处理完成;
根据输出路径生成下载地址;
保存数据库记录;
最后通知用户。

如果只用 Future,代码就会比较别扭。

这就是我开始看 CompletableFuture 的原因。


Future 的问题在哪里

先看一个普通 Future 的写法:

Future<String> future = executor.submit(() -> {
    Thread.sleep(3000);
    return "output/test-watermark.pdf";
});

String path = future.get();

String url = "http://localhost:8080/download?file=" + path;

System.out.println("下载地址:" + url);

这段代码当然没问题。

但是它有几个特点:

1. main 线程要主动 get;
2. get 会阻塞;
3. 拿到结果后,后续逻辑还是 main 自己继续写;
4. 如果多个任务之间有依赖,代码会越来越散。

也就是说,Future 更像是:

你先执行任务;
我后面主动来取结果。

它不太像一个完整的异步流程。


CompletableFuture 更像一条异步流水线

CompletableFuture 的写法更像这样:

CompletableFuture
        .supplyAsync(() -> {
            return "output/test-watermark.pdf";
        })
        .thenApply(path -> {
            return "http://localhost:8080/download?file=" + path;
        })
        .thenAccept(url -> {
            System.out.println("下载地址:" + url);
        });

这个代码读起来更像一条流水线:

第一步:异步处理 PDF,返回输出路径;
第二步:拿到路径,生成下载地址;
第三步:拿到下载地址,打印或通知用户。

我觉得这就是 CompletableFutureFuture 顺手的地方。

它不只是能拿结果,还能继续编排后面的步骤。


先写一个最简单的 CompletableFuture

新建类:

com.succos.completablefuture.CompletableFutureDemo

代码如下:

package com.succos.completablefuture;

import java.util.concurrent.CompletableFuture;

public class CompletableFutureDemo {

    public static void main(String[] args) {

        System.out.println("main 开始:" + Thread.currentThread().getName());

        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("开始处理 PDF:" + Thread.currentThread().getName());

            sleep(3000);

            System.out.println("PDF 处理完成:" + Thread.currentThread().getName());

            return "output/test-watermark.pdf";
        });

        future.thenAccept(result -> {
            System.out.println("拿到异步结果:" + result);
            System.out.println("thenAccept 执行线程:" + Thread.currentThread().getName());
        });

        System.out.println("main 继续往下执行:" + Thread.currentThread().getName());

        sleep(5000);

        System.out.println("main 结束:" + Thread.currentThread().getName());
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

这里先用 sleep(3000) 模拟 PDF 处理。

真正的 PDF 水印方法后面再接进去。


supplyAsync 是什么

这里用了:

CompletableFuture.supplyAsync(() -> {
    return "output/test-watermark.pdf";
});

supplyAsync() 的意思是:

异步执行一个有返回值的任务。

它里面的 Lambda 会被丢到后台线程里执行。

任务执行完以后,会返回一个结果。

这里返回的是:

"output/test-watermark.pdf"

所以整个对象的类型是:

CompletableFuture<String>

这个 String 就表示异步任务最终会返回一个字符串。


thenAccept 是什么

后面这一段:

future.thenAccept(result -> {
    System.out.println("拿到异步结果:" + result);
});

意思是:

等 future 任务完成后,拿到它的结果,然后执行这段逻辑。

这里的 result 就是前面 supplyAsync() 返回的字符串:

"output/test-watermark.pdf"

所以 thenAccept() 不是马上执行。

它是等异步任务完成以后才执行。

这点和普通同步代码不一样。


main 线程不会等 CompletableFuture

这段代码里有一句:

System.out.println("main 继续往下执行:" + Thread.currentThread().getName());

它一般会很快打印出来。

因为:

CompletableFuture.supplyAsync(...)

只是把任务提交到后台执行。

main 线程不会自动等它完成。

所以执行流程大概是:

main 线程启动;
main 提交异步任务;
后台线程开始处理 PDF;
main 继续往下执行;
PDF 处理完成后,触发 thenAccept;
main 最后结束。

这和前面 Thread.start() 很像。

提交异步任务,不代表任务已经执行完。


为什么示例里用了 sleep(5000)

代码最后我写了:

sleep(5000);

这个不是正式写法。

只是为了让 main 线程先别太快结束,方便观察异步任务和 thenAccept() 的执行。

如果没有这句,可能会出现这种情况:

main 提交异步任务后很快执行完;
程序结束;
后台任务还没来得及完整输出日志。

学习阶段用 sleep() 观察现象可以。

但正式代码里不能靠 sleep() 等异步任务。

后面会用:

future.join();

或者:

CompletableFuture.allOf(...).join();

来等待任务完成。

所以这里先记住:

sleep 只是为了演示,不是可靠的等待方式。

默认用的是公共线程池

上面的代码没有传线程池:

CompletableFuture.supplyAsync(() -> {
    return "output/test-watermark.pdf";
});

这种情况下,CompletableFuture 默认会使用公共线程池。

控制台线程名可能类似:

ForkJoinPool.commonPool-worker-1

也就是说,它不是用我前面自己创建的 PDF 线程池。

学习时这样写没问题。

但真实 PDF 处理任务,我不太建议直接用默认公共线程池。

因为 PDF 水印属于比较重的任务,可能涉及:

读取 PDF;
解析页面;
生成水印;
写出新文件;
占用 CPU、内存和磁盘 IO。

如果直接占用公共线程池,可能会影响项目里其他也使用公共线程池的异步任务。

更稳的做法是:给 PDF 处理单独创建一个线程池。

后面会写:

CompletableFuture.supplyAsync(() -> {
    return addWatermark(file);
}, pdfExecutor);

这样 PDF 任务就会进入自己的线程池。


CompletableFuture 和 Future 的直观区别

我现在会这样理解它们。

Future 更像这样:

提交任务;
拿到 Future;
我主动 get;
拿到结果后,我自己继续写后续逻辑。

CompletableFuture 更像这样:

提交第一个异步任务;
任务完成后自动进入下一步;
下一步完成后再继续下一步;
中间还能处理异常、组合多个任务。

所以 CompletableFuture 不只是“能拿结果”。

它更重要的是:

可以把异步任务的后续流程串起来。

这个对真实业务很有用。


用 PDF 处理来理解

假设我有一个 PDF 处理流程:

1. 给 PDF 添加水印;
2. 生成下载地址;
3. 打印结果或者通知用户。

CompletableFuture 可以这样表达:

CompletableFuture
        .supplyAsync(() -> {
            System.out.println("处理 PDF");
            sleep(3000);
            return "output/test-watermark.pdf";
        })
        .thenApply(path -> {
            return "http://localhost:8080/download?file=" + path;
        })
        .thenAccept(url -> {
            System.out.println("最终下载地址:" + url);
        });

这个代码读起来就是一个流程。

每一步都依赖上一步的结果。

这比 Future.get() 后再一行一行写同步逻辑更自然。


先不要急着学所有方法

CompletableFuture 方法很多。

比如:

runAsync;
supplyAsync;
thenApply;
thenAccept;
thenRun;
thenCompose;
thenCombine;
allOf;
anyOf;
exceptionally;
handle;
whenComplete;
orTimeout。

一开始看会有点多。

但我觉得不用急着背。

可以先按业务场景记:

我要异步执行一个没返回值的任务:runAsync;
我要异步执行一个有返回值的任务:supplyAsync;
我要把结果转换成另一个结果:thenApply;
我要拿结果做点事但不返回:thenAccept;
我要等一批任务全部完成:allOf;
我要处理异常:exceptionally / handle / whenComplete。

后面每一节慢慢拆开。

先把 CompletableFuture 的基本感觉建立起来。


这一节先记住什么

这一节我主要记住几点:

1. Future 能拿异步任务结果,但流程编排不太方便;
2. CompletableFuture 不只是拿结果,还能继续串后续步骤;
3. supplyAsync 用来异步执行有返回值的任务;
4. thenAccept 会在任务完成后消费结果;
5. main 线程不会自动等待 CompletableFuture;
6. 学习阶段可以用 sleep 观察,正式等待要用 join 或 allOf;
7. PDF 这种重任务,后面应该指定自己的线程池,不要一直用默认公共线程池。

用一句话总结:

Future 更像“结果凭证”,CompletableFuture 更像“异步流程编排”。

下一节继续看 runAsyncsupplyAsync

这两个方法是 CompletableFuture 的入口,一个适合无返回值任务,一个适合有返回值任务。