Java 并发编程进阶:CompletableFuture 与虚拟线程的取舍
学完这篇,你能分清 CompletableFuture 和虚拟线程各自该用在什么场景,并写出不踩坑的取舍代码。
第一步:先认清两者解决的不是同一个问题
CompletableFuture 是 JDK 8 引入的异步编排工具,管的是"多个任务怎么串行、怎么并行、结果怎么合并、超时和异常怎么兜底"。
虚拟线程是 JDK 21 正式落地的(JEP 444),管的是执行成本——让你用同步阻塞的写法,承载几十万个并发任务,而不用去写回调地狱。
一句话记住:CompletableFuture 负责任务之间的依赖关系,虚拟线程负责任务本身的承载开销。两者不是二选一,而是能叠在一起用。
第二步:CompletableFuture 的三个必改默认行为
1. 别裸用 supplyAsync
// 隐患:跑在 ForkJoinPool.commonPool 上
CompletableFuture.supplyAsync(() -> rpc.call());
commonPool 并行度默认是 CPU 核数 - 1,塞满阻塞调用会把整个 JVM 的并行流一起拖死。必须显式传线程池:
ExecutorService pool = new ThreadPoolExecutor(
16, 64, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
r -> new Thread(r, "biz-" + r.hashCode()),
new ThreadPoolExecutor.CallerRunsPolicy());
CompletableFuture<User> uf = CompletableFuture.supplyAsync(() -> userService.get(id), pool);
2. 必须加超时(JDK 9+)
UserVO vo = uf.thenCombine(of, UserVO::new)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(e -> UserVO.empty())
.join();
注意:`orTimeout` 只把 future 标记成超时异常,底层任务还在跑,不会中断线程。真要取消,得自己持有 Future 引用调 `cancel(true)`,并且你的 HTTP/RPC 客户端要响应中断。
3. 异常出口用 handle 或 whenComplete 统一收口,别到处散落 exceptionally,否则一个 `thenApply` 抛异常,后面整条链都会带着异常往下传。
第三步:虚拟线程的两种起法
// 一次性任务
Thread.startVirtualThread(() -> httpGet(url));
// 推荐:有名字、每任务一线程的 Executor(try-with-resources 会自动关闭)
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f = ex.submit(() -> httpGet(url));
String r = f.get();
}
Spring Boot 3.2+ 只需在 `application.yml` 加一行,Tomcat 请求线程就换成虚拟线程,业务代码不用改:
spring:
threads:
virtual:
enabled: true
第四步:虚拟线程的四个坑
1. 被 synchronized 钉住(pinning)
JDK 21/22 里,在 `synchronized` 块内做阻塞操作会把载体线程一起占住。改用 `ReentrantLock`,并用 `-Djdk.tracePinnedThreads=full` 排查(JDK 24 的 JEP 491 已解决该问题)。
2. 不要池化
用 `Executors.newFixedThreadPool` 去包装虚拟线程等于白用——虚拟线程本来就该随用随建。
3. ThreadLocal 慎用
虚拟线程数量可能上万甚至几十万,每个都塞大对象会迅速撑爆堆内存。能用参数传递就别用 ThreadLocal。
4. 必须自己限流
虚拟线程默认没有上限,不加以约束的话,先崩的是下游的数据库连接池。用 `Semaphore` 兜底:
Semaphore permits = new Semaphore(200);
permits.acquire();
try { doWork(); } finally { permits.release(); }
第五步:混用才是常态
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<User> uf = CompletableFuture.supplyAsync(() -> userService.get(id), ex);
CompletableFuture<Order> of = CompletableFuture.supplyAsync(() -> orderService.list(id), ex);
return uf.thenCombine(of, UserVO::new)
.orTimeout(800, TimeUnit.MILLISECONDS)
.join();
}
虚拟线程做执行器,让阻塞调用变廉价;CompletableFuture 做编排,负责合并、超时、降级。
第六步:选型清单
| 场景 | 选择 |
|---|---|
| 单次调用、下游是阻塞 IO | 虚拟线程 + 同步写法 |
| 3~5 个下游聚合、要超时降级 | CompletableFuture(执行器传虚拟线程池) |
| CPU 密集型计算 | 两者都不用,用固定线程池或 ForkJoinPool |
| 已有大量 thenApply 链路 | 保留 CompletableFuture,只换执行器 |
| JDK 17 及以下 | 只能用 CompletableFuture |
注意:判断标准其实很简单——**任务之间的关系复杂,用 CompletableFuture;任务数量大但彼此独立,用虚拟线程;两者都占,就叠起来用。**
小结
- CompletableFuture 解决编排,虚拟线程解决承载,不是替代关系。
- CompletableFuture 三件事必做:显式传线程池、加 orTimeout、统一异常出口。
- 虚拟线程四件事必查:synchronized pin、不要池化、慎用 ThreadLocal、自己限流。
- CPU 密集型任务两个都不适合,老老实实用固定大小线程池。
- 升级到 JDK 21 后,把 CompletableFuture 的执行器换成 `newVirtualThreadPerTaskExecutor()` 是最小改动、最大收益的一步。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





