1. 同步代码的问题
设想一下:你有一个程序,需要从网络加载数据或读取一个大文件。你会写出类似这样的代码:
String data = readFromFile("bigfile.txt");
System.out.println("数据: " + data);
一切看起来都不错,但如果文件很大或者网络很慢,程序就会在读取这行上直接 卡住。用户看到一个“卡住”的界面,服务器无法处理其他请求,而程序员……只能叹气。
这种情况就叫做 阻塞:线程(例如你应用的主线程)被迫等待操作完成。而如果这样的操作很多——那就会出现卡顿和低性能问题。
这有点像你去咖啡店点了单,然后……必须一直站在吧台前等到咖啡做出来。其他客人也在你后面排队,等咖啡师先把你的搞定。效率很低,对吧?
异步如何拯救世界
异步编程是一种做法:把耗时操作(例如读文件、请求服务器、访问数据库)放到后台线程里执行,而主线程继续工作:服务用户、接收新请求、响应事件。
也就是说,你下单(启动任务)后就去忙自己的事;等咖啡好了(任务完成),只需有人告诉你:“好了!”
在 Java 中,在 CompletableFuture 出现之前,这件事并不那么顺手。我们来看看它是如何发展的。
2. 历史做法:Future 及其局限
在 Java 5 中引入了接口 Future——把异步任务变得稍微方便一些的第一次尝试。它允许把任务交给线程池,并在某个时刻取得结果。
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // 注意:在任务完成之前线程会阻塞!
想法看起来不错,但在实践中,Future 更像一个老式信箱:你把信寄出去了,可要知道有没有回信,就得不停去打开看看。
它不会在结果就绪时通知你,不支持“先做这个,然后做那个”这样的操作链,也不便于优雅地处理错误。一切最终还是落到那个会阻塞的 get() 上,异步又被拉回了“等待”。
3. CompletableFuture 的出现:异步的新风格
在 Java 8 中,真正的异步英雄取代了过时的 Future——CompletableFuture。这个类位于 java.util.concurrent 包中,成为大家写异步代码的通用利器:不再需要“手动等待”,而是可以用更优雅、简洁、易懂的方式来写。
CompletableFuture 几乎无所不能。它可以在其他线程中启动任务,把任务串成链——例如先计算结果,再处理它,接着再做点别的。它还能轻松组合多个任务:要么等待全部完成,要么只取第一个完成的。错误处理也很优雅——无需过多 try-catch。整体风格更偏函数式:不再是枯燥的调用和等待,而是使用富有表现力的方法,如 thenApply、thenAccept 等。
flowchart LR
A[异步启动任务] --> B[处理结果]
B --> C[后续操作]
C --> D[错误处理]
因此,CompletableFuture 把异步从“繁重的苦力活”变成了一个方便灵活的工具,让代码终于能自由呼吸。
4. 最简单的示例:迈入 CompletableFuture 的第一步
我们来看一个最小的异步任务示例:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// 异步启动任务
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// 获取结果(会阻塞线程!)
try {
int result = future.get();
System.out.println("结果: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
这段代码已经在单独线程中执行计算——在启动任务的那一刻主线程并不会被阻塞。但对结果调用 get() 仍然会阻塞线程,直到结果准备好。
那如何不阻塞线程?
很简单:使用回调方法,在任务完成时再触发:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("结果: " + result));
System.out.println("我没有被阻塞,还可以做其他事情!");
小结:
- thenAccept —— 就是“订阅结果”:当任务完成时,执行这段代码。
- 主线程不会等待任务完成,而是继续工作。
可视化(事件伪代码)
[主线程] --> [启动任务]
| |
v v
[做点别的] [后台线程计算 2+2]
| |
v v
[打印 "我没有被阻塞..."]
| |
v v
[计算完成时 — 触发 thenAccept]
5. 在应用中是什么样?
设想你在开发一个控制台应用,用户可以触发数据加载(比如从数据库或服务器),而在数据加载期间——程序不会“卡住”,还能继续接受命令。
示例:模拟耗时操作
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("开始加载数据...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// 模拟耗时加载
try {
Thread.sleep(2000); // 2 秒
} catch (InterruptedException e) {
return "加载出错";
}
return "数据加载成功!";
});
// 订阅结果
dataFuture.thenAccept(result -> System.out.println("结果: " + result));
// 程序继续工作
System.out.println("数据加载期间,我还可以做别的事情!");
// 为了避免程序过早结束(仅用于演示!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
控制台输出将会是:
开始加载数据...
数据加载期间,我还可以做别的事情!
[2 秒后]
结果: 数据加载成功!
6. 一些有用的细节
聊聊底层的线程
当你写 CompletableFuture.supplyAsync(...) 时,任务默认会在所谓的 ForkJoinPool 中执行——这是 Java 用于并行任务的专用线程池。如果你需要更多控制(例如自定义 ExecutorService),可以把它作为第二个参数传入:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
但对简单任务,默认线程池通常就够用了。
获取结果:get(), join(), thenAccept
- get() —— 在结果准备好之前会阻塞线程(并抛出受检异常)。
- join() —— 也会阻塞,但抛出的是非受检异常(RuntimeException)。
- thenAccept()、thenApply() 等 —— 不会阻塞,而是在结果就绪时调用你传入的函数。
在真实的异步应用中,请尽量避免 在主线程里使用 get()/join()!
7. 初学 CompletableFuture 的常见错误
错误 1:在主线程中使用 get() 或 join()。
这样会再次阻塞程序,失去异步的优势。请改用 thenAccept、thenApply 等方法来处理结果。
错误 2:忘记处理错误。
如果异步任务中抛出了异常,它不会“冒泡”到主线程。若不通过 exceptionally 或 handle 处理,你可能根本不知道哪里出了问题。
错误 3:没有等到程序结束。
在演示示例中,经常需要通过 Thread.sleep 暂停一下 main 线程——否则程序会在任务完成之前就退出。在真实应用(例如 Web 服务器)中这不是问题,但在控制台演示时要注意。
错误 4:混淆 thenAccept 和 thenApply。
thenAccept —— 用于“副作用”(不返回结果),thenApply —— 用于转换结果(返回新结果)。
错误 5:不必要地把异步与同步代码混在一起。
如果已经开始用异步,就不要再通过 get()/join() 把它拉回同步,除非是极端情况(例如在测试中)。
GO TO FULL VERSION