并发07:异步编程
从 CompletableFuture 的编排价值到线程池选择,再到异常处理和超时控制,系统理解 Java 异步编程的设计思想和最佳实践。
异步编程解决什么问题
CompletableFuture 最适合解决的,不是”让代码看起来高级”,而是多个独立任务可以并行执行、执行完再组合结果。
比如商品详情页要查商品、价格、库存、推荐。同步写法是一段接一段等,总耗时接近所有接口耗时之和。如果这些查询互不依赖,就可以并行:
CompletableFuture<Product> productFuture =
CompletableFuture.supplyAsync(() -> productClient.get(id), pool);
CompletableFuture<Price> priceFuture =
CompletableFuture.supplyAsync(() -> priceClient.get(id), pool);
CompletableFuture<Stock> stockFuture =
CompletableFuture.supplyAsync(() -> stockClient.get(id), pool);
线程池选择
CompletableFuture.supplyAsync 如果不传线程池,会使用公共的 ForkJoinPool.commonPool()。这在简单 demo 里没问题,在业务系统里要谨慎。
公共线程池是全局共享的。你把阻塞式 HTTP、数据库查询、文件 IO 都丢进去,可能影响同一个 JVM 里其他也依赖公共池的任务。更稳妥的做法是按业务和资源隔离线程池:
ExecutorService detailPool = new ThreadPoolExecutor(
20,
40,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(300),
namedThreadFactory("product-detail"),
new ThreadPoolExecutor.AbortPolicy()
);
| 任务类型 | 线程数建议 | 说明 |
|---|---|---|
| CPU 密集型 | 接近 CPU 核数 | 避免上下文切换 |
| IO 密集型 | CPU 核数 * 2 或更高 | 利用等待时间 |
编排关系
CompletableFuture 的价值在编排。独立任务用并行,依赖任务用串联,多个结果用组合。
CompletableFuture<Detail> detailFuture =
productFuture.thenCombine(priceFuture, Detail::new)
.thenCombine(stockFuture, Detail::withStock);
| 方法 | 用途 | 返回值 |
|---|---|---|
thenApply | 同步转换 | 新的值 |
thenApplyAsync | 异步转换 | 新的值 |
thenCompose | 链式异步 | CompletableFuture |
thenCombine | 组合两个结果 | 新的值 |
allOf | 等待所有完成 | CompletableFuture<Void> |
anyOf | 等待任一完成 | CompletableFuture<Object> |
如果下一个步骤依赖上一个步骤的结果,用 thenApply。如果下一个步骤本身又返回一个 CompletableFuture,用 thenCompose,避免套娃:
userFuture.thenCompose(user -> couponClient.queryAsync(user.id()));
带 Async 和不带 Async 的区别
CompletableFuture 里带 Async 和不带 Async 的方法,不只是名字差异:
- 不带 Async:后续阶段通常会在完成前一个阶段的线程上继续执行
- 带 Async:会提交到线程池执行;如果没有指定 executor,会落到公共池
这意味着重处理要显式交给合适线程池:
future.thenApplyAsync(this::heavyTransform, computePool);
但也不要所有阶段都 Async。每加一次异步边界,都可能多一次调度成本和上下文传递成本。轻量转换可以留在当前线程,阻塞 IO 和重计算才需要明确隔离。
超时和异常处理
聚合接口最怕一个非核心下游拖住整体返回。Java 9 之后,CompletableFuture 提供了 orTimeout 和 completeOnTimeout:
CompletableFuture<List<Recommend>> recommendFuture =
CompletableFuture
.supplyAsync(() -> recommendClient.query(userId), pool)
.completeOnTimeout(List.of(), 200, TimeUnit.MILLISECONDS)
.exceptionally(ex -> List.of());
异常处理不能一刀切吞掉,要按业务后果决定:
| 场景 | 处理方式 |
|---|---|
| 推荐数据超时 | 返回空列表 |
| 价格接口失败 | 整体失败 |
| 库存接口失败 | 展示”暂不可售” |
超时预算分配
聚合接口不能给每个分支都随便设置超时。如果入口 SLA 是 800ms,应该按业务优先级拆分预算:
入口总预算:800ms
商品基础信息:300ms,失败则整体失败
价格:250ms,失败则整体失败
库存:200ms,失败则展示暂不可售
推荐:150ms,失败则返回空列表
每个分支的超时、异常和降级都应该反映业务优先级。否则最慢的非核心分支会决定整个接口体验。
常见死锁写法
最危险的写法之一,是在同一个小线程池里提交父任务,父任务再提交子任务并等待子任务:
CompletableFuture.supplyAsync(() -> {
CompletableFuture<Result> child =
CompletableFuture.supplyAsync(this::queryRemote, samePool);
return child.join();
}, samePool);
如果父任务把线程占满,子任务没有线程执行,父任务又一直等子任务,线程池就可能卡死。
解决方式:
- 能用
thenCompose编排,就不要在中间join - 不同性质的任务要分线程池
- 必要等待时设置超时
上线后监控
用了 CompletableFuture,监控不能只看接口总耗时。至少要能看到:
- 每个下游任务的耗时、超时和异常数
- 异步线程池的活跃线程、队列长度和拒绝数
- 聚合接口里各个降级分支是否频繁触发
- traceId 能不能跨异步线程传递
异步会切线程,很多日志上下文默认不会自动传过去。项目里要明确 MDC 或 trace context 的传递方式。
常见误区
误区一:把 CompletableFuture 当成线程池
它只是任务编排工具,真正执行任务的是 executor。executor 没隔离、队列无界、下游没超时,异步链路一样会被打满。
误区二:异常统一 exceptionally 返回默认值
推荐失败可以返回空列表,价格失败却不能随便返回 0。异常处理要跟业务后果绑定。
误区三:只给最终 get() 设置超时
最终等待超时只能保护调用方,不能保证下游请求停止。HTTP 客户端、RPC 客户端、数据库查询也要有自己的超时。
项目里怎么判断要不要用
- 多个任务之间是否真的相互独立?
- 总耗时是否主要来自 IO 等待,而不是 CPU 计算?
- 每个分支是否能定义清楚失败后的业务语义?
- 是否已经有独立线程池、超时、监控和 trace 上下文传递?
如果只是为了”异步而异步”,代码会更难排查;如果是聚合查询、并行 IO、依赖编排,CompletableFuture 才能真正把关系写清楚。