一、问题现象
并发场景下,某个"加载主数据 + 并发拉取多份关联数据"的聚合查询接口永久挂起不返回,接口超时、线程池活跃数打满且不下降、后续请求全部堆积。在高并发入口(批量导入类操作)触发时尤为明显。
二、问题原因(根因)
外层任务和内层子任务复用同一个有界线程池,且旧代码在"池线程上"用阻塞join()等待子任务完成。
三个关键事实:
外层任务跑在池线程上 方法首行通过自定义
futureExecutor.executeSupplier("xxxExecutorKey", ...)提交,内部实现是Map<String, ThreadPoolExecutor>,即把 supplier 丢到某个有界ThreadPoolExecutor上执行。整个方法体的后半段都跑在这个池线程里。所有子任务提交回同一个池 加载地址、运费、托盘、发票、物流等子任务,各自又通过
executeSupplier/executeRunnable带着同一个 executor key 提交。也就是说,子任务要能跑起来,必须从同一个池里再借一个线程。旧代码在池线程里调
join()executeSupplier(QUERY_KEY, () -> { ... // 跑在池线程上 CompletableFuture<Void> allInner = allOf(addressCf, feeCf, palletCf, ...); allInner.join(); // ← 在池线程上阻塞等待子任务 buildResult(...); return result;}).join();
三、产生的过程(死锁怎么形成)
设池大小为 N。
池线程 T1 跑外层任务 → 执行到 allInner.join() → 阻塞,占住 T1 不放 ↓ join 在等:addressCf / feeCf / palletCf 完成 ↓ 这些子任务在池队列里排队,等一个空闲池线程来执行 ↓ T1 被外层占着,T2..TN 也可能被别的并发请求以同样方式占着 → 子任务永远调度不出去 → join 永不返回持有资源(池线程)的同时等待同一类资源(池线程),经典闭环。
- 单请求:N 足够大、子任务能借到别的线程时可能侥幸不死锁;N=1 时单次请求即可死锁。
- 并发请求:每个请求抢一个池线程跑外层、立刻
join阻塞;当被占住的池线程数 ≥ N 时,所有池线程都卡在 join 上,队列里的子任务无线程可调度,全员饥饿死锁。
这不是传统"互等锁"的死锁,而是 Thread Starvation Deadlock(线程饥饿死锁):任务占着池线程等子任务,子任务又需要同一个池的线程,池被打满后谁也推进不了。
CompletableFuture/ExecutorService文档里专门提示过这种陷阱。
四、修复方案
把"池线程上的阻塞等待"改成"异步回调编排",只在非池的调用方线程上join()。
executeSupplier(QUERY_KEY, () -> queryMain(...)) // 第一段查询,提交到池 .thenCompose(mainList -> { // 在完成线程上构造 future,不阻塞 ... CompletableFuture<Void> allInner = allOf( addressCf, logisticsCf, feeCf, invoiceCf, invoiceDetailCf, palletCf); return allInner.thenApply(v -> { // 子任务全部完成后回调 buildResult(mainList); return mainList; }); }) .join(); // ← 由调用本方法的业务/HTTP 线程调用,不在池里为什么不再死锁:
thenCompose/thenApply是异步编排:池线程跑完第一段查询后,在完成线程上构造好子任务 future 就立即返回、释放线程,不持有任何池线程去等待;- 子任务各自从池借线程跑,跑完触发
thenApply回调,无需主链占线程; - 最后的
.join()是原始调用方线程(业务线程,不是该池的线程)在阻塞,它占着不释放不影响子任务调度,所以不会饥饿。
五、经验沉淀
| 要点 | 说明 |
|---|---|
| 禁止在池线程上阻塞等待同池任务 | 在池任务里对"同池提交的子任务"调join()/get()是饥饿死锁的充要条件,并发时必现。 |
用thenCompose/thenApply编排,不用join串接 | 让回调链在完成线程上推进,池线程用完即释放。 |
| 阻塞只发生在池外调用方 | 需要join()时,确保调用者是业务线程/HTTP 线程,而非池线程本身。 |
| 父任务与子任务最好用不同池 | 复用同一池时,必须保证池内任何线程都不阻塞等待同池任务。 |
| 定位信号 | 接口整体挂起、线程池活跃数 = 核心数且不下降、堆栈大量卡在join/get,基本就是此类饥饿死锁。 |