主题
异步编程演进:Future -> CompletableFuture -> 虚拟线程
本文是 Java 并发系统学习系列的 L2 核心篇。前置:线程基础与生命周期、线程池实战。 学完可以配合面试题食用:CompletableFuture 异步编排、虚拟线程 Project Loom 原理与对比
Future 的天花板:get() 一站到底
ExecutorService.submit() 返回 Future,它只回答一个问题:结果好了吗。想拿结果就得 get(),线程原地阻塞等。更难受的是它没有"好了之后干什么"的能力:调三个下游接口再聚合,你得自己写三个 Future、逐个 get、自己算还剩多少超时预算。异常也被包成 ExecutionException,逐层拆开才知道哪一步炸了。
java
ExecutorService pool = Executors.newFixedThreadPool(3);
Future<User> f1 = pool.submit(() -> userClient.get(uid));
Future<Coupon> f2 = pool.submit(() -> couponClient.list(uid));
// 三个 get 串行等:第一个卡 2s,后面两个早算完了也得排队等
User u = f1.get(); // 阻塞
List<Coupon> cs = f2.get(); // 再阻塞Future 是"结果的占位符",不是"流程的描述"。流程编排这层,Java 8 之前基本靠手写。
CompletableFuture:把"然后"写进 API
CompletableFuture(下称 CF)补上了编排能力。thenApply 做转换,thenCombine 合并两个阶段,allOf 等齐一组,thenCompose 串接下一个异步调用。Java 9 又加了 orTimeout(到点抛异常)和 completeOnTimeout(到点给默认值),超时终于是 API 而不是手艺活了。
java
CompletableFuture<Price> p = CompletableFuture
.supplyAsync(() -> fetchPrice(id), bizPool) // 起步
.thenApply(Price::withTax) // 转换:跑在上游完成的线程
.thenCombine(promotionFuture, Price::applyDiscount) // 合并另一条链
.completeOnTimeout(Price.FALLBACK, 300, TimeUnit.MILLISECONDS);
p.whenComplete((v, ex) -> log.info("done v={} ex={}", v, ex)); // 观察结果,不改变结果异常处理有三件工具,语义不同:exceptionally 只在异常时介入并给兜底值;handle 无论成败都介入,可以改写结果;whenComplete 只旁观,日志打点用它,别拿它做恢复。另一个高频误会:thenApply(fn) 不切线程,fn 跑在触发完成的那个线程上;要换线程得用 thenApplyAsync(fn, executor)。
commonPool 陷阱:不传池,等于传了个全局池
supplyAsync(fn) 不带第二个参数时,任务进 ForkJoinPool.commonPool()——全 JVM 共享一个。它有两个坑:
- 并行度约等于 CPU 核数减一。容器里配额只有 1 核时并行度算成 0,"异步"任务直接退化成在调用者线程里执行;反过来,老版本 JDK 看到宿主机 64 核,池子撑到 63 个线程,资源又被高估。CPU 配额感知(cgroup)在 JDK 8u191 之后才逐步修好。
- 池子小而全局共享。你在这里面跑一次 500ms 的 HTTP 调用,占住一个线程,别处的
parallelStream和别人家的 CF 一起陪你等。阻塞 IO 放进 commonPool,等于把人行道当停车场。
结论很硬:业务 IO 一律显式传池,supplyAsync(fn, bizIoPool),commonPool 只留给纯 CPU 的短任务。
虚拟线程:阻塞的代码,非阻塞的吞吐
JDK 21(JEP 444)转正的虚拟线程,思路是换一层:线程还是按任务一个,但由 JVM 调度到少数几个载体平台线程上。代码遇到 IO 阻塞时,虚拟线程自动让出载体,阻塞的是"虚拟"的它,不占系统线程。于是几十年前的"thread-per-request"直写阻塞代码又成立了——每个请求一个线程,同步写法,吞吐照旧能扛上万并发。代价与边界:
- 只适合 IO 密集。CPU 密集该多少核还是多少核,载体线程数默认就等于核数。
- 不要池化虚拟线程。它的设计前提是便宜、按需创建,复用反而复杂;限并发用
Semaphore。 ThreadLocal慎放大对象,百万级线程每个挂一份缓存就是事故。JDK 21 里synchronized块内阻塞会钉住载体(pinning),重锁路径换ReentrantLock;JDK 24 的 JEP 491 彻底解决了这个问题。
java
// 一行换掉整个线程池配置辩论
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
pool.submit(() -> orderClient.get(uid)); // 每个任务一个新虚拟线程
pool.submit(() -> stockClient.check(sku));
}怎么选:一棵决策树
三样工具不是互相取代,是各管一段:
mermaid
flowchart TD
A[新需求要并发] --> B{CPU 密集?}
B -->|是| C[平台线程池<br/>固定大小 = 核数附近]
B -->|否, IO 密集| D{需要编排组合?}
D -->|要 fan-out 聚合/链式| E[CompletableFuture<br/>显式传自定义池或虚拟线程池]
D -->|阻塞调用为主/老代码| F[虚拟线程<br/>thread-per-request]
E --> G[超时用 orTimeout / completeOnTimeout]
F --> H[限流用 Semaphore, 不池化]一把尺子:CPU 密集看核数,IO 编排看 CF,IO 高并发阻塞调用看虚拟线程。混用完全可以——CF 的 executor 参数传 newVirtualThreadPerTaskExecutor(),编排能力和吞吐两头都要。
动手实操:同一需求写两版
需求:并行调用户、优惠券、推荐三个下游,单接口 300ms 内不回就用兜底值,最后聚合返回。
版本一:CompletableFuture + 自定义池
java
ExecutorService bizPool = Executors.newFixedThreadPool(20, r -> {
Thread t = new Thread(r, "biz-io");
t.setDaemon(true);
return t;
});
CompletableFuture<User> userF = CompletableFuture
.supplyAsync(() -> userClient.get(uid), bizPool) // 显式传池,不进 commonPool
.completeOnTimeout(User.GUEST, 300, TimeUnit.MILLISECONDS) // 超时兜底
.exceptionally(ex -> { log.warn("user api fail", ex); return User.GUEST; }); // 失败降级
CompletableFuture<List<Coupon>> couponF = CompletableFuture
.supplyAsync(() -> couponClient.list(uid), bizPool)
.completeOnTimeout(List.of(), 300, TimeUnit.MILLISECONDS)
.exceptionally(ex -> List.of());
CompletableFuture<List<Item>> recF = CompletableFuture
.supplyAsync(() -> recClient.recommend(uid), bizPool)
.completeOnTimeout(RecDefaults.HOT_LIST, 300, TimeUnit.MILLISECONDS)
.exceptionally(ex -> RecDefaults.HOT_LIST);
CompletableFuture.allOf(userF, couponF, recF).join(); // 等齐,总耗时≈最慢单个
return new PageData(userF.join(), couponF.join(), recF.join());版本二:虚拟线程,同步直写
java
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> userF = pool.submit(() -> userClient.get(uid)); // 阻塞调用随便写
Future<List<Coupon>> couponF = pool.submit(() -> couponClient.list(uid));
Future<List<Item>> recF = pool.submit(() -> recClient.recommend(uid));
User user = orDefault(() -> userF.get(300, TimeUnit.MILLISECONDS), User.GUEST);
List<Coupon> coupons = orDefault(() -> couponF.get(300, TimeUnit.MILLISECONDS), List.of());
List<Item> recs = orDefault(() -> recF.get(300, TimeUnit.MILLISECONDS), RecDefaults.HOT_LIST);
return new PageData(user, coupons, recs);
}
<T> T orDefault(Supplier<T> call, T fallback) {
try { return call.get(); }
catch (Exception e) { log.warn("downstream fail, use fallback", e); return fallback; }
}两版对比:版本一胜在编排语义清晰,超时、降级、组合都是 API;版本二代码少一半,阻塞写法好排查,线程开销几乎不设上限。都是在等三个接口,最长等待都是 300ms 量级。真实项目里常见的组合拳:外层虚拟线程承接请求,fan-out 聚合用 CF 编排,池子传虚拟线程 executor。
常见误区与小结
thenApply当成异步切换用——它跑在上游完成线程上,慢逻辑会拖垮触发方,换线程用thenApplyAsync(fn, executor)。supplyAsync(fn)裸调跑 IO——任务全进 commonPool,1 核容器里甚至退化成调用线程执行,务必显式传池。join()/get()不带超时——上游一抖线程就堆一层,至少给个orTimeout兜底。- 虚拟线程跑 CPU 密集任务——载体线程就核数那么多,切换不省 CPU,反而多一层调度。
- 给虚拟线程做池化、挂大 ThreadLocal——按需创建加 Semaphore 限流才是设计意图,对象级缓存挪到共享结构。
这条线走到这里:线程怎么建(24)、怎么协作(25-29)、怎么复用(30)、怎么异步(本文)。虚拟线程解决"IO 等待贵",CF 解决"流程描述难",两者经常同台。下一篇是本系列收官:并发问题排查:jstack 与 Arthas 实战,线上 CPU 飙高、线程死锁怎么定位,工具链一次讲完。
参考
- JEP 444: Virtual Threads(https://openjdk.org/jeps/444)
- JEP 491: Synchronize Virtual Threads without Pinning(https://openjdk.org/jeps/491)
- JDK 文档:java.util.concurrent.CompletableFuture