一句话结论:
Future、CompletionService、CompletableFuture都能把串行请求变并发,但只有后两者能让你按完成顺序拿到结果。而在真实线上系统里,决定这段代码是否可靠的,往往不是选了哪个 API,而是线程池、超时预算、异常语义和可观测性这四件事有没有做对。
一、从一个真实场景说起
1.1 场景与约束
一个很常见的需求:需要从下游服务拉取一批数据,数据量大到一次拉不完,接口只提供分页。
串行分页的耗时模型是这样的:
总耗时 ≈ 页数 × 单页 RT
100 页、单页 200ms,就是 20 秒。这个耗时放在定时任务里勉强能接受,放在一个同步接口里基本不可用。
于是自然想到:既然每一页的拉取彼此独立,为什么不并发?并发之后的耗时模型变成:
scss
总耗时 ≈ ceil(页数 / 并发度) × 单页 RT + 调度开销
1.2 真正要解决的问题:结果什么时候"可用"
批量任务的耗时几乎不可能一致:有的页命中缓存 10ms 返回,有的页穿透到 DB 走了慢查询 3s 返回。这时有两个完全不同的诉求:
- 诉求 A:我要拿到全部结果再统一处理。 比如要对全量数据做排序、聚合、去重。
- 诉求 B:谁先完成我就先处理谁。 比如边拉边写库、边拉边推 MQ、边拉边流式返回给前端;或者在超时预算内能拿多少算多少,剩下的降级。
诉求 A 用 Future 就够了。诉求 B 用 Future 会很别扭------这也是原文想说清楚的核心点。
这里要先纠正一个容易混淆的地方:"能优先拿到最快完成的结果"改善的是首个结果的可用时间和处理的流水化程度,不等于总耗时会变短。 如果代码最终还是要等所有任务结束,那么三种写法的墙钟耗时都约等于最慢任务的耗时。下面的示例会刻意把这一点暴露出来。
为了让示例可复现,先准备一个统一的辅助方法(注意 InterruptedException 的正确处理,后面会解释为什么不能只 printStackTrace):
java
static void sleepSeconds(long seconds) {
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
// 不要吞掉中断信号:恢复中断标记,让上层有机会感知取消
Thread.currentThread().interrupt();
throw new IllegalStateException("task interrupted", e);
}
}
三个耗时不等的任务(用秒是为了在控制台肉眼可辨,生产代码里当然是毫秒级 RPC):
java
static List<Callable<Integer>> buildTasks() {
return Arrays.asList(
() -> { sleepSeconds(3); System.out.println("task-3s done"); return 3; },
() -> { sleepSeconds(1); System.out.println("task-1s done"); return 1; },
() -> { sleepSeconds(2); System.out.println("task-2s done"); return 2; }
);
}
二、方案一:invokeAll + Future
2.1 代码
java
public static void byInvokeAll() throws InterruptedException {
long start = System.currentTimeMillis();
ExecutorService pool = Executors.newFixedThreadPool(3); // 仅为示例,生产别这么写,见 6.1
try {
List<Future<Integer>> futures = pool.invokeAll(buildTasks());
for (Future<Integer> f : futures) {
try {
System.out.println("result: " + f.get());
} catch (ExecutionException e) {
System.out.println("task failed: " + e.getCause());
}
}
} finally {
pool.shutdown();
}
System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}
输出(顺序稳定):
makefile
task-1s done
task-2s done
task-3s done
result: 3
result: 1
result: 2
cost=3005ms
2.2 为什么拿不到"最先完成"的结果
关键不在 Future,而在 invokeAll 的语义:
invokeAll会阻塞调用线程,直到所有任务全部完成(或被取消) ,然后返回一个与入参集合迭代顺序一致的Future列表。
也就是说,invokeAll 返回的那一刻,所有 Future 都已经是 isDone() == true 了。后面那个 for 循环里的 f.get() 不会再阻塞,它只是按提交顺序把已经躺在那儿的结果读出来。
所以"先完成先返回"在这个写法里根本没有机会发生------你在拿第一个结果之前,就已经等完了最慢的那个任务。日志里 task-1s done 出现在最前面,但 result: 3 出现在最前面,这个错位正好说明了问题:任务的完成顺序和结果的消费顺序被解耦开了,而消费顺序被固定成了提交顺序。
一个常见的"自己造轮子"补救方案是轮询:
java
// 反面示例:忙轮询 isDone()
while (!futures.isEmpty()) {
futures.removeIf(f -> {
if (f.isDone()) { consume(f); return true; }
return false;
});
}
这段代码能"跑通",但它会把一个 CPU 核心打满去做无意义的空转,还容易在任务数多时引入明显的调度抖动。CompletionService 存在的意义就是把这件事做对。
2.3 Future 的能力边界
Future 是 JDK 5 的产物,它的接口只提供了四种能力:查完成、取消、阻塞取结果、带超时地阻塞取结果。它没有的能力:
- 没有完成回调(只能主动问,不能被动通知);
- 没有组合能力(无法表达"A 完成后接 B"、"A、B 都完成后合并");
- 没有按完成顺序获取的入口;
- 异常必须通过
get()抛出的ExecutionException去getCause()剥一层。
2.4 什么时候用它反而是对的
不要因为它"老"就否定它。以下场景 invokeAll 是最简洁、最不容易写错的选择:
- 语义上就是"全部拿到才有意义",比如批量校验、全量聚合;
- 任务数量固定且不多,耗时方差不大;
- 你需要一个整体超时并自动取消未完成任务------这时用带超时的重载,非常好用:
java
// 3 秒整体预算,到点未完成的任务会被 cancel
List<Future<Integer>> futures = pool.invokeAll(buildTasks(), 3, TimeUnit.SECONDS);
for (Future<Integer> f : futures) {
if (f.isCancelled()) {
System.out.println("timeout, skipped"); // 超时被取消
continue;
}
try {
System.out.println("result: " + f.get());
} catch (ExecutionException e) {
System.out.println("failed: " + e.getCause());
}
}
这个重载常被忽略,但它把"整体超时 + 未完成任务取消"两件事一次做了,比自己拿 CountDownLatch 拼要稳妥。
需要留意的是:cancel(true) 只是给线程发中断信号。如果任务体内部是不响应中断的阻塞(例如某些 HTTP 客户端只认自己的 socket timeout),线程不会立刻退出。取消是"请求取消",不是"保证停止",这一点在排查"任务超时了但线程池还满着"时非常关键。
三、方案二:CompletionService
3.1 代码
java
public static void byCompletionService() {
long start = System.currentTimeMillis();
ExecutorService pool = Executors.newFixedThreadPool(3);
try {
List<Callable<Integer>> tasks = buildTasks();
// 注意:CompletionService 要每批次新建,不要做成共享单例,见 3.4
CompletionService<Integer> cs = new ExecutorCompletionService<>(pool);
tasks.forEach(cs::submit);
for (int i = 0; i < tasks.size(); i++) {
try {
Integer r = cs.take().get(); // 按完成顺序返回
System.out.println("result: " + r);
} catch (ExecutionException e) {
System.out.println("task failed: " + e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} finally {
pool.shutdown();
}
System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}
输出:
makefile
task-1s done
result: 1
task-2s done
result: 2
task-3s done
result: 3
cost=3006ms
结果按完成顺序出现,第一个结果在 1 秒时就已经可以处理了。总耗时依然约 3 秒------因为循环还是要取满三个结果。这正好印证 1.2 节的判断。
3.2 原理:多了一个完成队列
ExecutorCompletionService 的实现思路很直白,没有任何魔法:它把你提交的 Callable 包装成一个 QueueingFuture,重写了 FutureTask 的 done() 钩子------任务完成时,把自己塞进一个内部的 BlockingQueue。
于是:
submit():提交到底层Executor,同时登记到完成队列;take():从完成队列阻塞取一个已完成 的Future;poll()/poll(timeout, unit):非阻塞 / 限时获取。
理解了这一点,就能理解它的所有约束:它的能力完全来自那个队列,它不知道你提交了多少个任务。
3.3 关键用法:整体超时预算 + 部分成功
CompletionService 真正的工程价值不在"顺序好看",而在于它天然适合"在给定的时间预算内,能拿多少算多少"。这是接口层做并发聚合时最实用的一个模式:
java
public class BatchFanOut {
public static class BatchResult<T> {
public final List<T> succeeded = new ArrayList<>();
public int failed;
public int unfinished;
public boolean isPartial() { return failed > 0 || unfinished > 0; }
}
/**
* 在 budgetMs 的整体预算内并发执行 tasks,返回已完成的结果,未完成的取消。
*/
public static <T> BatchResult<T> execute(List<Callable<T>> tasks,
ExecutorService pool,
long budgetMs) {
BatchResult<T> result = new BatchResult<>();
CompletionService<T> cs = new ExecutorCompletionService<>(pool);
List<Future<T>> submitted = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
submitted.add(cs.submit(task));
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs);
try {
for (int i = 0; i < tasks.size(); i++) {
long remaining = deadline - System.nanoTime();
Future<T> f = remaining <= 0 ? null : cs.poll(remaining, TimeUnit.NANOSECONDS);
if (f == null) { // 预算用尽
result.unfinished = tasks.size() - i;
break;
}
try {
result.succeeded.add(f.get()); // 此处不会再阻塞
} catch (ExecutionException e) {
result.failed++;
// 生产代码请打日志并带上任务标识,见第七章
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 必须收尾:提前 break 后,未完成任务仍在占用线程池
submitted.forEach(f -> f.cancel(true));
}
return result;
}
}
这段代码里有三个容易漏掉的细节,值得单独强调:
- 超时预算是"整体 deadline",不是"每个任务 timeout"。 如果给每个任务各自 500ms,10 个任务串在一起最坏就是 5 秒;用整体 deadline,总耗时才真正可控。上游接口的 SLA 是整体的,超时预算也应该是整体的。
- 提前退出后一定要
cancel剩余任务。 否则这批任务会继续占用线程池,下一次请求进来时线程池已经被上一批的"僵尸任务"占满------这是并发聚合类代码最常见的雪崩诱因。 f.get()放在poll之后是安全的。 从完成队列里取出来的Future一定已经 done,get()不会阻塞。
3.4 有哪些坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
take() 次数多于实际提交数 |
线程永久阻塞,接口一直不返回 | 严格用提交数计数;能提前退出的场景用 poll(timeout) |
把 CompletionService 做成共享单例/成员变量 |
请求 A 取到了请求 B 的结果,数据串批 | 每批次新建一个实例 ,只共享底层 ExecutorService |
提前 break 却不 cancel |
线程池被上一批任务占满,后续请求排队甚至拒绝 | finally 中统一 cancel(true) |
submit 抛 RejectedExecutionException 后继续 take |
提交数与计数不一致 → 阻塞 | 提交在 try 内,用实际成功提交的数量作为循环上界 |
吞掉 InterruptedException |
上层取消/超时后线程仍在跑 | Thread.currentThread().interrupt() 后退出 |
CompletionService 的能力边界也很清楚:它解决了"按完成顺序消费",但不解决编排 。"A 完成后触发 B,B、C 完成后合并成 D"这类依赖关系,用它写出来就是一堆嵌套的 take 循环。这就是 CompletableFuture 的地盘。
四、方案三:CompletableFuture
4.1 代码
CompletableFuture 提供的是回调式模型:结果就绪时由完成它的线程回调你的处理逻辑,天然就是"先完成先处理"。
java
public static void byCompletableFuture(ExecutorService pool) {
long start = System.currentTimeMillis();
List<Integer> seconds = Arrays.asList(3, 1, 2);
List<CompletableFuture<Integer>> futures = seconds.stream()
.map(s -> CompletableFuture.supplyAsync(() -> {
sleepSeconds(s);
System.out.println("task-" + s + "s done on " + Thread.currentThread().getName());
return s;
}, pool)) // 显式指定线程池,见 4.2
.collect(Collectors.toList());
// 完成即消费(谁先完成谁先进来)
futures.forEach(f -> f.thenAccept(r -> System.out.println("result: " + r)));
// 等全部结束;任一异常会在这里抛 CompletionException
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}
输出:
vbnet
task-1s done on batch-pool-2
result: 1
task-2s done on batch-pool-3
result: 2
task-3s done on batch-pool-1
result: 3
cost=3011ms
4.2 第一个坑:不要用默认线程池
原文示例用的是 supplyAsync(supplier) 单参版本。它的执行器是 ForkJoinPool.commonPool(),这在业务代码里通常是错的,原因有三个:
其一,它是整个 JVM 共享的。 你的批量拉取、别人的 parallelStream()、某个库内部的异步逻辑,全都挤在同一个池子里。一段慢的阻塞任务会把不相关的功能一起拖死,而且极难定位------线程栈上只会看到 ForkJoinPool.commonPool-worker-N,看不出是谁提交的。
其二,它的默认并行度是 可用处理器数 - 1。 在 4 核容器里就是 3。这个池子被设计用来跑 CPU 密集的计算任务,而不是跑一堆 IO 阻塞任务。更隐蔽的是:当计算出的并行度为 0 时(例如容器只分到 1 核),commonPool 会退化成"每个任务新建一个线程"的模式。这意味着你的并发行为会随部署环境的 CPU 配额而变------同一份代码在 8 核机器和 1 核容器里表现完全不同。
其三,它不响应你的治理手段。 你没法给它设置有界队列、拒绝策略、监控指标和有意义的线程名。
正确做法是自己建池并显式传入:
java
ThreadPoolExecutor batchPool = new ThreadPoolExecutor(
8, 8,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界
new CustomizableThreadFactory("batch-fetch-"), // Spring 提供,线程名可辨识
new ThreadPoolExecutor.AbortPolicy()); // 快速失败优于悄悄堆积
batchPool.allowCoreThreadTimeOut(true);
顺便说一句:
parallelStream()同样跑在commonPool上,且不支持指定执行器。在业务代码里对 IO 操作用parallelStream(),属于同一类问题。
4.3 第二个坑:join 和 get 的异常语义不一样
这是 code review 里高频出现的混淆点:
| 方法 | 抛出的异常 | 是否受检 |
|---|---|---|
get() |
ExecutionException、InterruptedException |
是,必须处理 |
join() |
CompletionException |
否,运行时异常 |
两者包装的原始异常都要通过 getCause() 取。join() 写起来干净(尤其在 lambda 里),代价是编译器不会提醒你处理失败 。原文示例中 futures.forEach(CompletableFuture::join) 如果第一个任务抛异常,整个方法直接抛出,后面的任务既没被消费也没被取消------异常路径上的行为和正常路径完全不同。
另外,thenAccept 注册的回调不会 捕获上游异常。上游失败时,thenAccept 会被跳过,异常沿链向下传递。要处理失败必须显式接上 exceptionally / handle / whenComplete:
java
CompletableFuture.supplyAsync(() -> client.fetchPage(pageNo), batchPool)
.thenAccept(this::consume) // 成功路径
.exceptionally(ex -> { // 失败路径,返回兜底值让链继续
log.warn("fetch page {} failed", pageNo, ex);
return null;
});
三者的区别:exceptionally 只在异常时触发并可替换结果;handle 无论成功失败都触发且可改结果;whenComplete 无论成功失败都触发但不改变结果(异常会继续传递)。
4.4 组合能力:allOf / anyOf / 超时
这是 CompletableFuture 相对前两者真正的增量价值。
全部完成后合并 (注意 allOf 返回 CompletableFuture<Void>,结果要自己捞):
java
CompletableFuture<List<PageResult>> all = CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join) // 此时都已完成,join 不阻塞
.collect(Collectors.toList()));
任一完成即返回(典型场景:多个数据源竞速取最快的那个):
java
CompletableFuture<Object> fastest = CompletableFuture.anyOf(fromCache, fromDb);
超时(Java 9+):
java
CompletableFuture<PageResult> f = CompletableFuture
.supplyAsync(() -> client.fetchPage(pageNo), batchPool)
.orTimeout(500, TimeUnit.MILLISECONDS) // 超时则异常完成
.exceptionally(ex -> PageResult.empty(pageNo)); // 降级为空页
// 或者直接给默认值,不走异常路径
CompletableFuture<PageResult> f2 = CompletableFuture
.supplyAsync(() -> client.fetchPage(pageNo), batchPool)
.completeOnTimeout(PageResult.empty(pageNo), 500, TimeUnit.MILLISECONDS);
这里有一个必须知道的语义细节 :orTimeout / completeOnTimeout 只是让这个 CompletableFuture 提前进入完成状态,它不会中断正在执行的那个任务。任务体会继续跑到自然结束,继续占着线程池的一个位置。也就是说,超时保护的是调用方的响应时间,不保护后端资源。真正的资源保护要靠:任务内部自己的 socket/read timeout、连接池上限、以及有界队列 + 拒绝策略。
如果你用 Java 8,没有 orTimeout,就需要自己拿 ScheduledExecutorService 去 completeExceptionally,或者干脆用 CompletionService 的 poll(timeout) 模式(3.3 节),后者在 Java 8 项目里通常更简单。
4.5 第三个坑:回调在哪个线程执行
非 Async 后缀的方法(thenAccept、thenApply...)的执行线程是不确定的:
- 如果注册时上游尚未完成 ,回调由完成上游的那个线程执行;
- 如果注册时上游已经完成 ,回调由当前调用线程执行。
这带来两个实际后果:
- 在回调里做重活或阻塞操作(写库、发 MQ、再发一次 RPC),会占用业务线程池的工作线程,甚至可能占用主线程/Web 容器线程。要隔离就用
thenAcceptAsync(action, anotherPool),把不同性质的工作放到不同的池子里。 - 调试时看到的线程名可能和你预期完全不同,别据此推断"任务跑在哪个池"。
一个容易踩的死锁场景:在同一个固定大小的线程池里,任务 A 内部又提交任务 B 并 join() 等待 B。当池被 A 类任务占满时,B 永远排不上队,A 永远等不到 B。不要在同一个池内做嵌套等待------这是"接口偶发性完全无响应、线程栈全是 WAITING"这类问题的典型成因。
五、三种方案怎么选
5.1 对比表
| 维度 | invokeAll + Future | CompletionService | CompletableFuture |
|---|---|---|---|
| 结果获取顺序 | 提交顺序 | 完成顺序 | 完成顺序(回调) |
| 编程模型 | 阻塞式 | 阻塞式(队列驱动) | 回调式 / 声明式 |
| 首个结果可用时间 | 等最慢任务 | 最快任务完成即可用 | 最快任务完成即可用 |
| 任务编排(串/并/合并) | 不支持 | 不支持 | 支持(thenCompose / thenCombine / allOf / anyOf) |
| 整体超时 | invokeAll(tasks, timeout, unit) |
poll(timeout) + 手动 cancel |
orTimeout / completeOnTimeout(Java 9+) |
| 异常处理 | ExecutionException.getCause() |
同左,可按任务粒度捕获 | 链式:exceptionally / handle / whenComplete |
| 默认线程池 | 必须自己传 | 必须自己传 | 单参重载用 commonPool(陷阱) |
| 心智负担 | 低 | 中 | 中偏高(回调线程、异常传播) |
| 最低版本 | Java 5 | Java 5 | Java 8(超时 API 需 9+) |
5.2 选型判断
按"业务语义"而不是"API 新旧"来选:
- 只要全量结果、任务不多、耗时接近 →
invokeAll。代码最短,最不容易写错。需要整体超时就用带 timeout 的重载。 - 需要边完成边处理,或需要"预算内能拿多少算多少"的部分成功 →
CompletionService。它是这个场景下最直白的工具,尤其适合 Java 8 项目。 - 任务之间有依赖、需要编排,或要接入响应式/异步链路 →
CompletableFuture。前提是你能管住线程池和异常传播。 - Spring 项目里的常规异步 →
@Async配合自定义ThreadPoolTaskExecutor,返回CompletableFuture。注意@Async走的是代理,同类内部方法直接调用不会生效,这是最常被问的一个坑。
反过来,有几种情况不适合做批量并发:
- 下游是共享资源且没有做隔离(同一个 DB 实例、同一个 Redis 分片)。你把串行改并发,只是把压力从自己身上转移到了下游,很可能换来一批慢查询和连接池耗尽。
- 任务之间有强顺序或事务语义。并发会破坏顺序性,且跨线程后本地事务不共享------
@Transactional不会传播到异步线程,回滚边界会碎掉。 - 单个任务本身很快(微秒级),任务数极多。线程调度和上下文切换开销可能超过收益,批量合并请求(batch API)通常比并发更有效。
六、工程化落地:让这段代码上线后不出事
前面讲的是 API,这一章讲的是"为什么线上出问题"。
6.1 不要用 Executors 的工厂方法
Executors.newFixedThreadPool(n) 用的是无界 LinkedBlockingQueue。下游变慢时,任务不会被拒绝,而是无声地在队列里堆积,直到内存被占满触发频繁 GC 乃至 OOM。newCachedThreadPool 是另一个极端:线程数上限接近 Integer.MAX_VALUE,下游变慢时会疯狂创建线程,直到无法再创建原生线程。
结论:用 ThreadPoolExecutor 构造函数显式声明七个参数,队列必须有界。这也是《阿里巴巴 Java 开发手册》里明确要求的一条。
线程数怎么定?给一个可用的起点,而不是精确公式:
- CPU 密集型:接近核心数;
- IO 密集型:
核心数 × (1 + 平均等待时间 / 平均计算时间)是一个粗略上界,实践中更常见的做法是先按"期望 QPS × 平均 RT"估算并发量,再压测调整; - 无论哪种,最终要靠压测和线上指标校准,公式只是初值。
拒绝策略的选择也有讲究:CallerRunsPolicy 常被当作"温柔的降级",但它会让提交者线程 去执行任务。如果提交者是 Tomcat 的工作线程,你就是在用 HTTP 线程去跑批量任务------反压确实产生了,但反压对象是你的接口吞吐量。对于可丢弃的旁路任务,AbortPolicy + 记录指标 + 告警通常更可控。
6.2 超时预算要"自上而下"分配
一个接口的总 RT 预算(比如 1 秒)应该向下拆解:
bash
接口预算 1000ms
├── 参数校验 + 本地缓存 ~10ms
├── 批量并发拉取 预算 600ms ← 整体 deadline,不是单任务 timeout
└── 聚合、序列化、返回 剩余
批量拉取拿到的是 600ms 的整体 预算。落到代码上就是 3.3 节的 deadline 模式。同时别忘了任务内部也要有超时------HTTP 客户端的 connectTimeout / socketTimeout、数据库的 queryTimeout。上层超时管的是响应时间,下层超时管的是资源释放,两者不能互相替代。
6.3 明确"部分成功"是什么语义
批量并发一旦引入超时和降级,返回结果就可能是不完整的。这时必须由业务来回答三个问题,而不是让代码默默决定:
- 部分成功算成功还是失败?返回体里要不要带
partial标记和缺失范围? - 缺失的数据是补拉(异步重试 / 落到延时队列)还是直接丢?
- 调用方能否感知?如果调用方拿到一个"看起来完整"的残缺列表,后续的聚合、对账、库存计算都可能出错。
这类问题不解决,"并发优化"就会变成"偶发性数据不一致"。在我的经验里,批量并发上线后的事故,绝大多数不是并发本身写错了,而是部分成功的语义没定义清楚。
6.4 保护下游
- 限制并发度 :并发度不是越大越好,要匹配下游的承载能力。除了控制线程池大小,也可以用
Semaphore在任务内部做二次限流。 - 给下游连接池留余量:8 个线程并发查库,就意味着瞬时占用 8 个数据库连接。线程池大小超过连接池大小时,多出来的线程只是在等连接。
- 失败要有边界:熔断/隔离(Sentinel、Resilience4j 等)应该作用在下游调用上,避免一个慢下游把整个批量任务的线程池长期占满。
七、可观测性与线上排查
并发代码最大的成本不在写,在出问题时看不见。
7.1 线程名是排查的起点
默认线程名 pool-1-thread-3 没有任何信息量。给每个池起一个业务可辨识的名字,抓一次 thread dump 就能立刻定位是谁在阻塞:
java
// Spring 项目
new CustomizableThreadFactory("order-batch-fetch-");
// 或用 JDK 原生方式
ThreadFactory factory = r -> {
Thread t = new Thread(r, "order-batch-fetch-" + counter.incrementAndGet());
t.setDaemon(false);
t.setUncaughtExceptionHandler((th, e) -> log.error("uncaught in {}", th.getName(), e));
return t;
};
顺带说一句 submit 和 execute 的差别:用 submit 提交时,任务抛出的异常会被封进 Future,如果你不调用 get(),异常就被彻底吞掉了 ,UncaughtExceptionHandler 也不会触发。这是"任务好像没执行,但也没有任何报错日志"的常见原因。
7.2 traceId 必须跨线程透传
异步化之后,子线程的日志会丢掉 MDC 里的 traceId,链路直接断在这里。Spring 的 ThreadPoolTaskExecutor 提供了 TaskDecorator 扩展点:
java
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> parent = MDC.getCopyOfContextMap(); // 提交线程的上下文
return () -> {
try {
if (parent != null) {
MDC.setContextMap(parent);
}
runnable.run();
} finally {
MDC.clear(); // 池化线程必须清理,防止污染下一个任务
}
};
}
}
@Bean("batchExecutor")
public ThreadPoolTaskExecutor batchExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("batch-fetch-");
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
注意 finally 里的清理:线程是复用的,不清理会导致下一个任务打出上一个请求的 traceId,比没有 traceId 更容易误导排查。
如果需要透传的不只是 MDC,还有自定义的 ThreadLocal 上下文(用户身份、灰度标记、租户 ID),可以考虑 TransmittableThreadLocal(alibaba/transmittable-thread-local),它提供了 TtlExecutors 来包装线程池。
7.3 线程池要有指标
线程池是黑盒,必须暴露出来。ThreadPoolExecutor 自带这些可读属性:
| 方法 | 含义 | 关注点 |
|---|---|---|
getActiveCount() |
正在执行任务的线程数 | 长期贴着 maxPoolSize → 容量不足或任务变慢 |
getQueue().size() |
队列积压 | 持续增长 → 下游变慢,即将触发拒绝 |
getCompletedTaskCount() |
累计完成数 | 增速停滞 → 任务卡死 |
getLargestPoolSize() |
历史峰值线程数 | 判断 maxPoolSize 是否被打满过 |
接入 Micrometer 只需要一行包装,之后线程池指标就能进 Prometheus / Grafana:
java
ExecutorService monitored = ExecutorServiceMetrics.monitor(
meterRegistry, batchPool, "batch-fetch", Tags.of("biz", "order"));
再补三个业务维度的指标,排查效率会完全不同:批次总耗时、单任务耗时分布(P99)、部分成功率 / 超时任务数。
7.4 排查对照表
| 线上现象 | 优先看什么 | 常见根因 |
|---|---|---|
| 接口 RT 从毫秒级跳到超时 | 线程池队列长度、activeCount | 队列积压,下游变慢;线程池被占满 |
| 接口偶发完全无响应,线程栈大量 WAITING | thread dump 中的 park 位置 |
同池嵌套等待死锁(4.5);take() 次数多于提交数(3.4) |
| 任务像没执行,但没有异常日志 | 是否用 submit 且从未 get() |
异常被封进 Future 后吞掉(7.1) |
| 老年代持续增长直至 OOM | 队列类型是否无界 | Executors.newFixedThreadPool 的无界队列(6.1) |
| 日志里 traceId 丢失或串了 | 是否配置 TaskDecorator 且 finally 清理 |
MDC 未透传 / 未清理(7.2) |
| 超时已生效但线程池仍满 | 任务是否响应中断、下游 socket timeout | orTimeout 不中断任务(4.4);cancel 只是发中断(2.4) |
| 同一份代码在不同环境并发表现不一致 | 是否用了 commonPool / parallelStream |
并行度随 CPU 配额变化(4.2) |
八、总结
回到原文的那个问题------"如何让先完成的任务先返回结果",答案分三层:
- API 层面 :
invokeAll的语义就是"等全部完成",所以它做不到;CompletionService通过完成队列做到了;CompletableFuture通过回调做到了,并且额外提供了编排能力。 - 收益层面 :按完成顺序消费改善的是首个结果的可用时间 和处理的流水化程度,不等于总耗时变短。只有配合超时预算、提前退出、流式输出,它才会转化为可感知的 RT 收益。
- 可靠性层面:这段代码上线后能不能扛住,取决于线程池是否有界、超时预算是否自上而下、部分成功语义是否定义清楚、traceId 和线程池指标是否可观测。这四件事比选哪个 API 重要得多。