1. 场景还原与问题初现
1.1 一个看似正常的线程池配置
先说个我实际踩过的坑。当时在做一个后台批处理模块,核心逻辑是拉取一批数据,然后对每条数据做二次加工。为了提升速度,我写了一个典型的“分而治之”结构:外层任务提交后,会根据数据量再拆出若干个内部子任务,每个子任务处理一小块,最终把结果汇总返回。
线程池配置长这样:
ExecutorService executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10000), new NamedThreadFactory("batch-worker"), new ThreadPoolExecutor.CallerRunsPolicy() );看着挺规矩的吧?核心线程8,最大线程16,队列容量1万,拒绝策略用CallerRunsPolicy。当时上线后跑了一段时间都没事,直到有一天业务量上来,突然线上就“卡住”了——不是崩溃,不是OOM,而是任务全部堆积、接口超时、数据库连接池被打满,但CPU使用率却不高。
我一开始还以为是数据库问题,查慢查询、查连接数,都没发现异常。直到把线程池的监控捞出来一看,才意识到问题出在自己写的这坨代码上。
1.2 故障现象:任务堆积与线程闲置并存
当时监控面板上的数据非常诡异:
- 线程池队列中的任务数从几百一路涨到几万;
- 活跃线程数始终维持在8,没有增长;
- CPU使用率只有20%左右;
- JVM堆内存正常,GC正常;
- 所有依赖该线程池的业务接口全部超时。
按理说,队列里有这么多任务,线程池应该扩容才对——毕竟最大线程数配的是16。但活跃线程数死活不涨,一直停在8。这意味着核心线程全被占住了,但任务又没有被真正执行完,形成了一个“所有人都在等别人干活”的局面。
当时我第一反应是“是不是子任务抛异常导致线程卡死”,但抓完线程栈后发现根本不是异常问题。真正的原因,是这8个核心线程全都在等待子任务的结果,而子任务本身也提交到了同一个线程池里,根本排不上队。
这就是典型的线程饥饿死锁(Thread Starvation Deadlock)。下面我把这个问题拆开来讲清楚。
2. 父子任务是怎么回事
2.1 任务拆分带来的“等结果”问题
先聊一个更常见的场景。假设你有一个批量接口,需要同时拉取20个数据源的配置,每个配置的处理逻辑耗时差不多200毫秒。如果串行跑,一次要4秒,太慢了。于是你顺手写成这样:
List<Future<String>> futures = new ArrayList<>(); for (String source : sources) { futures.add(executor.submit(() -> fetchConfig(source))); } for (Future<String> future : futures) { results.add(future.get()); // 等待所有子任务完成 }这个写法本身没问题:外层线程负责提交任务,然后阻塞等待结果。如果fetchConfig的执行链路比较简单,子任务提交后很快就能完成,线程池里的线程流转正常,完全不会出问题。
但一旦子任务内部还会继续提交“孙任务”,并且同样需要等待返回,问题就开始酝酿了。举个例子:你要批量处理100个文件,每个文件的处理逻辑是:先读取元信息,再根据元信息去并行查询多个关联数据,最后合并结果。于是你在子任务里又用同一个executor提交了更底层的任务。
结构就变成了这样:
父任务(占1个线程) └── 等待子任务结果 ├── 子任务A(需要排队) └── 子任务B(需要排队)如果核心线程数只有8,而父任务本身已经占了8个,那子任务A和B就全部堆在队列里。父任务拿不到结果,不会释放线程;子任务排不进线程,永远得不到执行。两边互相等待,形成死锁。
2.2 线程饥饿死锁的形成条件
不是所有父子任务都会踩坑,它有比较明确的触发条件。
条件一:父子任务共享同一个线程池,且父任务必须等待子任务完成。
这是最核心的条件。如果你提交子任务之后直接返回,不等待结果,父任务会正常结束,线程自然释放,根本不存在死锁。只有父任务在内部future.get()或者join()等待,才会占住线程不放。
条件二:线程池的工作线程被全部占满,新任务只能排队。
如果线程池最大线程数很大,或者并发量不大,队列里一直有线程在消费子任务,那么父任务很快就能拿到结果,问题也不会暴露。
条件三:任务提交量短期激增,超过线程池吞吐能力。
这就解释了你为什么在低峰期测不出问题——低峰期提交的父任务数量少,核心线程有富余,子任务排得上队;峰值期父任务瞬间占满所有核线程,整个线程池就“假死”了。
如果用的是无界队列,情况会更隐蔽。new LinkedBlockingQueue<>()不设置容量,所有任务都会被接收,但永远不会触发拒绝策略。线程池不会抛异常,也不会丢任务,只是默默堆积等待内存耗尽。线上故障表现就从“接口超时”慢慢演变成“进程OOM”,排查起来更头疼。
一句话总结:父子依赖 + 固定线程数 + 峰值并发 = 死锁高发。
3. 排查过程实录
3.1 抓线程栈的第一现场
当时靠监控面板只能确认线程池异常,真正定位问题靠的是线程快照。线上环境我执行了三次jstack,间隔10秒各抓一次,然后对比线程状态。
关键信息出现在这样一段栈里:
"batch-worker-3" #27 prio=5 os_prio=0 tid=0x00007f0030f8b000 nid=0x5e waiting on condition [0x00007f0029bfa000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:451) at java.util.concurrent.FutureTask.get(FutureTask.java:205) at java.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:238) at com.xxx.BatchProcessor.processFile(BatchProcessor.java:88)我再逐个看其他线程,发现所有batch-worker线程的栈底几乎一样,全部卡在FutureTask.awaitDone。当时心里已经有数了,接着又用jcmd Thread.print看了带类加载信息的详细栈,确认父任务里调用了executor.invokeAll(),而子任务也提交到了同一个线程池。
整个调用链非常清晰:8个worker线程全部卡在“等子任务结果”,子任务躺在队列里标识为waiting,永远轮不到执行。
3.2 为什么监控看不出异常
这里需要解释一个误区:很多人以为线程池“忙不忙”看活跃线程数就行,其实不够。
当时活跃线程数恒定8,但这8个线程全部处于WAITING状态,并没有在执行真正的业务代码。线程池本身不会区分“线程在等待阻塞”和“线程在执行任务”,它只关心线程是否被占用。一个被占住等结果的线程,和真正在计算CPU密集任务的线程,在线程池的角度看没有任何区别——都被视为“繁忙”。
所以监控面板上活跃线程数满、队列上涨、CPU却很低,这个组合本身就是强烈的信号:线程在等什么。这时候直接上线程栈,最有价值。
3.3 复现问题的最小示例
为了验证猜测,我本地写了个最小复现程序。思路很简单:让父任务占满核心线程,然后提交子任务。
public class StarvationDemo { private static final ExecutorService pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1000)); public static void main(String[] args) throws Exception { CountDownLatch latch = new CountDownLatch(2); for (int i = 0; i < 2; i++) { final int id = i; pool.submit(() -> { System.out.println("父任务-" + id + " 开始"); try { // 提交子任务并等待结果 Future<String> sub = pool.submit(() -> "子任务结果"); System.out.println("父任务-" + id + " 等待子任务,结果:" + sub.get()); } catch (Exception e) { e.printStackTrace(); } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); } }这段代码铁定卡死。核心线程2,父任务占满两个,子任务永远排队。跑一下就明白了,程序会一直停留,不打印“子任务结果”。
我把核心线程数改成4、任务数改成4,结果一致。改成10个任务、核心线程5,也一样。只要父任务占满核心线程,子任务就完全没有执行机会。
4. 解决方案:不同场景的正确打法
4.1 方案一:调大核心线程数——治标不治本
最先想到的往往是“那我直接把核心线程调大不就行了”,比如从8调到32。这个方案在并发量相对可控的情况下确实有效:父任务占不满所有核心线程,子任务有富余线程执行,死锁自然消失。
但我不建议把这个当作第一方案,原因有两个。
第一,线程数不是越大越好。每个线程栈默认要分配1MB左右的栈空间,32个线程光栈内存就30多MB,如果还有IO等待、锁竞争,线程调度开销也会上涨。更关键的是,如果父任务拆出的子任务数量本身就大于线程数,死锁照样会发生,只是阈值推后了。
第二,这属于“靠运气活着”。你今天把线程数调到32,明天业务量翻倍,提交40个父任务,又把线程占满了,问题还是回来。而且线程数往往不是拍脑袋定的,跟CPU核数、IO耗时、任务类型都有关系,不能为了规避死锁就随意膨胀。
4.2 方案二:父子任务拆分线程池——最推荐的解法
如果你的业务结构确实是强依赖的父子任务,最干净的做法是让子任务跑在另一个线程池里。父任务池负责拆任务、汇总结果;子任务池专职执行底层原子任务。
这样设计的好处有三个:
- 父任务的等待不会占用子任务的执行线程;
- 两个池的队列和负载互相隔离,可以分别设置参数;
- 某一层的执行异常不会直接拖垮另一层。
代码结构大致这样:
ExecutorService parentPool = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new NamedThreadFactory("parent-worker") ); ExecutorService childPool = new ThreadPoolExecutor( 16, 32, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(5000), new NamedThreadFactory("child-worker") ); // 父任务里提交子任务时,用 childPool Future<String> sub = childPool.submit(() -> fetchDetail());这里有个细节值得说:子任务池的核心线程数最好大于父任务池,因为底层原子任务通常更多、更碎,需要更高的并发度去消化。如果反过来,子任务池很小,又会出现“父亲等儿子、儿子排队”的翻版死锁。
我在实际项目中就是这么改的。改完之后,父任务池的活跃线程监控从8降到2左右,队列水位也稳定了很多,问题彻底消失。
4.3 方案三:限制队列 + 超时兜底——防御性兜底
如果你没有精力重构线程池结构,至少要做两件事:把无界队列改有界,给等待设置超时。
ExecutorService executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), new ThreadPoolExecutor.AbortPolicy() );有界队列的意义在于:当任务积压超过容量上限时,提交方会收到RejectedExecutionException,而不是让任务无限堆积到内存爆炸。虽然异常也需要处理,但至少故障是“快速失败”,监控能及时发现,不会温水煮青蛙到OOM。
同时在父任务等待子结果时,加超时:
Future<String> sub = childPool.submit(() -> fetchDetail()); try { String result = sub.get(5, TimeUnit.SECONDS); } catch (TimeoutException e) { sub.cancel(true); // 打日志、织入降级逻辑 }如果5秒还没等到结果,大概率线程池已经积压了,这时候先取消任务,及时释放父线程,避免雪崩。你可以根据任务耗时分布选超时参数,但原则是“宁可单个任务失败,不能让整个池子陪葬”。
4.4 方案四:改用ForkJoinPool——适合任务可递归拆分的场景
如果你的场景本质上就是“一个大任务可以拆成多个小任务,小任务还可以再拆”,比如大数据量的排序、汇总、聚合,那Java自带的ForkJoinPool比手写线程池更合适。
ForkJoinPool的核心思想是工作窃取(work-stealing),每个工作线程都有自己的双端队列。当一个线程发现自己的队列空了,它会去别的线程队列尾部偷任务执行。更重要的是,ForkJoinTask的join()操作不是死等:当前线程在等待子任务时,会去执行其他可运行的任务,而不是傻傻占着一个线程不放。这天然规避了线程饥饿死锁问题。
示例代码:
ForkJoinPool forkJoinPool = new ForkJoinPool(16); List<BigData> result = forkJoinPool.submit(() -> bigDataList.parallelStream() .map(this::processItem) .collect(Collectors.toList()) ).join();不过要泼盆冷水:ForkJoinPool适合的是“平级递归分解”场景,如果你的父子任务之间还有IO操作、第三方调用、事务边界,比如父任务开了事务,子任务不能在另一个事务里继续提交——那ForkJoinPool不见得是对症药。它解决的是CPU密集计算型任务的并行分解,而不是复杂的业务编排。
4.5 方案五:用CompletableFuture做异步编排
有些读者可能会问:“我不用Future.get()阻塞等待,改用回调不就行了?”没错,CompletableFuture就是干这个的。
它的思路是把“等待结果”换成“结果出来后再执行后续动作”。比如父任务提交子任务时,不再阻塞,而是给子任务挂thenAccept()回调,等子任务完成后异步执行汇总逻辑。
CompletableFuture.supplyAsync(() -> fetchBaseData(), parentPool) .thenCompose(baseData -> CompletableFuture.supplyAsync(() -> fetchDetail(baseData), childPool) ) .thenAccept(detail -> saveResult(detail)) .exceptionally(ex -> { log.error("处理失败", ex); return null; });这样父任务提交完就直接返回,线程释放,不会等子任务。子任务由另一个线程池执行,完成后触发回调。从根上杜绝了“等结果”环节。
但CompletableFuture也不是银弹。它在链路长、回调嵌套多的时候,排查问题要困难很多——你无法直接看到线程栈是卡在具体哪个get()上,只能看回调里哪一步超时。另外如果你依然把supplyAsync和回调都放在同一个池子里,还是有类似问题。比较稳妥的做法还是配合刚才说的双线程池方案一起用。
5. 预防与设计建议
5.1 线程池参数选型速查表
很多人问线程池的队列和线程数到底怎么配。我根据自己的经验,按场景整理了一个速查表,供参考。
| 业务场景 | 核心线程数 | 最大线程数 | 队列类型与容量 | 拒绝策略 | 原因说明 |
|---|---|---|---|---|---|
| 纯CPU密集型计算,任务粒度小 | CPU核数+1 | CPU核数*2 | 有界队列,容量不宜过大 | AbortPolicy | 线程竞争少,队列主要做缓冲,积压意味着算不过来了 |
| IO密集型调用,每个任务耗时可控 | CPU核数*2~4 | CPU核数*8 | 有界队列,容量能撑住峰值 | CallerRunsPolicy | 线程空闲在IO等待,需要更多线程提升吞吐 |
| 含父子依赖、内部再次提交任务 | 禁止共池 | 禁止共池 | 使用独立子任务池 | 见4.2方案 | 子任务池线程数要大于父任务池,防范级联饥饿 |
| 低优先级、可丢弃的日志/埋点 | 1~2 | 4 | 有界队列,容量小 | DiscardPolicy | 丢了不可惜,不能影响主流程 |
| 对实时性要求高的API | 根据接口QPS算 | 超过QPS*单次耗时 | 队列容量尽量小 | CallerRunsPolicy | 宁愿让调用线程自己跑,也不能让请求挂在队列里 |
这个表的核心原则是:队列不是越大越好,拒绝策略不能拍脑袋,要根据“积压之后希望系统怎么表现”来决定。
5.2 提交任务阶段的三道检查
在实际写代码之前,我习惯对自己提三个问题,基本能过滤掉绝大多数线程池坑。
第一问:我提交的这个任务,内部还会再提交任务吗?
如果答案是“会”,我默认后续排查成本非常高。我会直接把父子任务池的方案写进设计文档,而不是等到上线后让监控教我做人。
第二问:父任务会不会等待子任务结果?
如果答案是“会”,我会提醒自己:这里存在死锁风险。接下来的选择就是换池、加超时、改异步,三选一或者组合用。
第三问:任务提交量会不会短时间超过核心线程数?
即使现在不会,也要想想半年后、业务量增长后会不会。预留扩容空间,不要拿当前流量设计线程池。
5.3 监控与告警:队列水位、任务时长
线程池代码上线后,必须有监控,否则前面为规避风险做的所有设计都等于白做。我在项目里给线程池挂了几个核心指标:
- 队列积压数:超过阈值就告警,这是最直接的积压信号;
- 排队时延:任务从提交到真正开始执行的时间差,超过30秒基本就能说明执行能力不足;
- 任务平均执行时长:基线值突然升高,往往意味着外部依赖变慢或者线程卡死;
- 线程池活跃线程数峰值、拒绝次数:拒绝次数大于0时,必须立刻人工介入。
这些指标在咱们常用监控组件里都能拿到。关键是告警阈值要结合业务定,比如平时队列积压都在100以下,你阈值设在1000,等真的触发告警,线上早就死锁好几分钟了。
6. 常见误区与经验小结
6.1 误区:核心线程数是动态调整的
很多刚接触线程池的人会想当然地以为:任务多了,核心线程数会自动增加。实际上核心线程数是固定参数,线程池只会在核心线程全部繁忙、且队列满时才创建新线程到最大线程数。
注意“且”字。如果你用的是无界队列,队列永远满不了,那么永远只会有核心线程数那么多线程在干活——即使最大线程数是100也不起作用。这个误区特别常见,如果你遇到“线程数怎么不涨”的问题,先检查队列容量。
6.2 误区:无限队列 = 不会失败
恰恰相反,无界队列虽然不会抛拒绝异常,但它会把内存慢慢耗尽。一旦系统OOM或卡顿,恢复时间反而更长。宁可几千个任务快速失败、留监控时间来响应,也不要几百万个任务全部积压在内存里拖死整个进程。
6.3 用线程池跑递归任务的风险
递归本身就容易产生大量子任务,如果递归体里还要get()等待下层结果,几乎必然触发死锁。比如某项目里有人用线程池递归处理树形结构,核心线程4,树深度20,第一层4个分支就把线程占满了,第二层就永远卡住。这种代码一定要改成迭代式或者直接换ForkJoinPool。
6.4 一点个人经验
排查线程池问题,我习惯的顺序是:先看监控确认“有没有积压”,再看线程栈确认“是不是等待”,最后配合压测确认“什么并发量下触发”。定位到是父子任务死锁之后,不要急着调参,先想清楚业务结构上能不能解耦。我后来在内部规范里加了一条硬性要求:父子任务默认不准共享线程池,特殊情况必须评审。这条规矩看着简单,带来的收益非常实在。
最后再分享一个小细节。如果在业务代码里必须共用一个线程池,又确实存在等待场景,我建议至少把队列改成有界,并维护一个监控线程定期扫一下队列积压数,超过阈值就打印线程栈快照。这个快照往往比事后看日志有用得多,直接告诉你“当时线程在等什么”,能省下大量排查时间。