线程明明空闲,任务为什么还是被拒绝了?
线上偶发 RejectedExecutionException 时,我先检查了线程池配置:32 个核心线程,最大线程数也是 32,队列长度 16。任务按批提交,每批完成后才会进入下一批。单看这些数字,线程池似乎留有余量,不应该轻易触发拒绝。
调大队列以后,异常暂时消失了,但这只能说明故障和瞬时容量有关,并没有解释为什么。后面的排查发现,我对 corePoolSize + queueCapacity 的理解更深了一层:它描述的是某一时刻能够容纳多少在途任务,并不保证每次批量提交都能被接住。
暂停两批任务之间的间隔,没有用
现场代码经过脱敏后,可以简化成下面的结构:
1 | ThreadPoolExecutor pool = new ThreadPoolExecutor( |
CountDownLatch 保证上一批任务都执行完,才会提交下一批。按照我当时的理解,32 个线程加上 16 个队列位置,线程池最多可以容纳 48 个任务;一批任务远小于这个数字,拒绝不该发生。
第一次怀疑的是批次交接:也许上一批刚刚结束,工作线程还在做收尾,下一批任务已经提交进来了。为了验证,我在两批之间加了 100ms 的暂停,之后又逐步增加到 2 秒,异常仍然会出现。
这个结果排除了“上一批没有及时结束”,却还没有触及提交过程本身。两秒的等待发生在一批任务开始之前;进入内层循环后,主线程依然会在很短的时间里连续调用多次 execute。
去掉 CountDownLatch,现象反而更随机
第二个怀疑对象是 CountDownLatch。去掉等待以后,程序有时能多执行一些批次,有时仍然会被拒绝。继续降低每批任务数,也只是让问题更难出现,并没有让它彻底消失。
这一步看起来更混乱,其实提供了一个线索:结果会随着线程调度而变化。如果是固定的参数错误,通常会稳定地在某个位置失败;现在有时成功、有时失败,更像是任务进入队列和工作线程取走任务之间存在竞争。
CountDownLatch 在这里的职责也很有限。它能控制批次之间的先后顺序,却不能降低同一批任务的提交速度。即使上一批已经完全结束,下一批仍可能瞬间把一个很短的队列填满。
execute 不会把任务直接交给空闲核心线程
为了看清任务是怎样被接收的,我把线程池缩小到 2 个核心线程、2 个最大线程和 1 个队列位置,再跟进 ThreadPoolExecutor.execute()。它的处理顺序可以压缩成三步:
- 当前工作线程数小于
corePoolSize时,创建核心线程执行任务; - 达到核心线程数以后,先尝试通过
workQueue.offer()把任务放进队列; - 队列已满时,再尝试创建非核心线程;如果线程数已经达到
maximumPoolSize,触发拒绝策略。
这里容易忽略的是,第一步判断的是工作线程数量,不是正在执行任务的线程数量。核心线程即使处于空闲状态,仍然会计入 workerCount。线程池达到核心线程数以后,新任务不会由提交线程直接交给某个空闲 Worker,而是先进入工作队列,再由 Worker 取走。
对于 corePoolSize == maximumPoolSize 的固定大小线程池,队列满了以后也没有继续创建线程的余地。使用默认的 AbortPolicy 时,execute 便会抛出 RejectedExecutionException。
一个长度为 1 的队列,足以暴露这个时间窗口
把队列缩小到 1 以后,故障发生时的顺序大致如下:
1 | 线程池中已有 2 个 Worker,当前处于空闲状态 |
ArrayBlockingQueue.offer() 不会等待消费者腾出位置。调用发生时有空位就返回 true,没有空位立即返回 false。工作线程稍后会通过 take() 取出任务,但它是否能在下一次 offer() 之前运行,取决于当时的调度。

把这个现象称为生产者与消费者“错序”并不准确,它属于正常并发下的时序竞争。提交线程没有义务等待工作线程完成 take();ThreadPoolExecutor 使用非阻塞的 offer() 入队,也正是为了在队列已满时立即转入扩容或拒绝分支。
这里还要补充一个边界:如果每批任务数不超过队列的完整容量,并且上一批确实已经全部完成,那么即使 Worker 一个任务都没有及时取走,这一批也应该全部进入队列。也就是说,32/32/16 的线程池配合每批恰好 16 个任务,单凭文中的简化代码无法复现拒绝。现场代码经过脱敏,说明实际运行条件与简化示例之间仍有未呈现的差异。把复现参数改成“批量大于剩余队列容量”,问题才是自洽的。
为什么扩大队列以后不再报错
队列从 16 调大到 160 后,同一批任务可以在 Worker 尚未开始消费时全部进入队列,因此测试不再触发拒绝。这个结果与前面的时间线一致:修改增加了线程池吸收瞬时突发的空间。
我也尝试过在自定义队列的 offer() 中加入等待,让工作线程有时间取走前一个任务。实验中异常确实不再出现,但这种改法只是人为放慢生产者,改变了 offer() 原本的非阻塞语义,不能作为实际修复方案。换回标准 ArrayBlockingQueue 后,拒绝仍然可以复现。
这次现场最终通过扩大队列解决了问题,但队列长度不能只凭“调大以后不报错”来决定。队列越大,突发流量越不容易被拒绝,任务排队时间和内存占用也可能随之增加。更合适的取值要结合单批提交量、任务耗时、允许的等待时间和系统资源一起判断。
如果提交方本身可以降速,还可以在入口限制批量大小,或者选用能够形成背压的拒绝策略。例如 CallerRunsPolicy 会让提交线程执行被拒绝的任务,从而自然降低提交速度,但它是否合适仍取决于调用线程能否承担这段执行时间。
这次排查留下的结论
这次故障里,最容易误导我的数字是 32 + 16 = 48。这个容量只有放回具体时刻才有意义:线程池刚启动时,前 32 个任务可能用于创建核心线程,后续任务进入队列;线程池已经预热后,即使 32 个 Worker 都空闲,它们仍然计入核心线程数,新任务依旧先走队列。
因此,排查拒绝策略时,除了核心线程数、最大线程数和队列长度,还需要观察提交瞬间的 poolSize、activeCount、队列剩余容量和任务提交速率。只看累计提交了多少批,或者在两批之间增加等待,都不足以说明当时是否达到了容量边界。
调大队列解决了这次现场问题。留下的限制也很明确:它提高的是突发缓冲能力,没有增加任务处理速度。以后再调整这类线程池,需要同时验证高峰期的队列长度、任务等待时间和拒绝次数,避免把拒绝异常换成更长时间的排队。