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,返回输出路径;
第二步:拿到路径,生成下载地址;
第三步:拿到下载地址,打印或通知用户。
我觉得这就是 CompletableFuture 比 Future 顺手的地方。
它不只是能拿结果,还能继续编排后面的步骤。
先写一个最简单的 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 更像“异步流程编排”。
下一节继续看 runAsync 和 supplyAsync。
这两个方法是 CompletableFuture 的入口,一个适合无返回值任务,一个适合有返回值任务。