在线上排查慢请求或者任务积压问题时,经常能看到这样的现象:某个服务配置了核心线程数10、最大线程数50,监控里却发现线程数一直是10,队列里却堆积了上千个任务。很多人第一反应是线程池配置没生效,或者怀疑框架覆盖了参数,但真正的原因往往藏在ThreadPoolExecutor的扩容规则里:只有当工作队列真正放满了,线程池才会去创建超过corePoolSize的线程。本文就围绕maximumPoolSize的触发条件,把这条规则的来龙去脉和排查方法讲清楚。

一、maximumPoolSize的触发条件到底是什么
先看ThreadPoolExecutor的execute方法源码,这是理解整个扩容逻辑的关键。JDK中execute的执行顺序分为三步,每一步的判断条件决定了任务最终去向。
public void execute(Runnable command) {
int c = ctl.get();
// 第一步:当前线程数小于corePoolSize,直接创建核心线程执行
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 第二步:线程数达到corePoolSize,尝试把任务放入队列
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 第三步:队列放不下了(offer返回false),才尝试创建非核心线程
else if (!addWorker(command, false))
reject(command);
}从源码可以清楚看到,创建非核心线程的addWorker(command, false)只发生在workQueue.offer(command)返回false之后。也就是说,maximumPoolSize能不能触发,取决于队列的offer方法是否入队失败,而不是看当前线程是否繁忙。
这里有一个非常容易踩的坑:如果使用的是无界队列,比如常见的new LinkedBlockingQueue<>(),它默认的容量是Integer.MAX_VALUE,offer几乎永远不会失败。这种情况下maximumPoolSize配置多大都没有意义,线程数会一直停在corePoolSize,直到队列堆积到内存溢出为止。这也是阿里巴巴Java开发手册禁止直接用Executors创建线程池的核心原因。
二、实战排查:为什么队列未满就无法扩容
理解了规则之后,排查就有章法了。第一步先确认队列的实际容量。很多项目里写的配置看起来是SynchronousQueue,实际被框架改成了LinkedBlockingQueue,或者配置文件中队列长度被写成了很大的值,比如10000,那么只要任务量没把队列塞满,线程数就永远不会超过核心数。
第二步观察线程数和队列大小的变化曲线。如果两者呈现明显的相关性,队列涨满的同时线程数开始上升,说明扩容机制本身是正常的,只是扩容触发得太晚;如果队列一直在涨而线程数纹丝不动,基本可以断定队列容量过大或者无界。一个简单的验证代码如下:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, 20, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100)); // 队列容量100
// 快速提交200个任务观察行为
for (int i = 0; i < 200; i++) {
final int taskId = i;
executor.submit(() -> {
try {
Thread.sleep(3000); // 模拟耗时任务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return taskId;
});
}
System.out.println("当前线程数: " + executor.getPoolSize());
System.out.println("队列积压: " + executor.getQueue().size());
// 前5个任务由核心线程执行,第6到105个任务进入队列,
// 第106个任务开始队列已满,线程数才会往20增长第三步还要注意任务提交速率的影响。如果队列虽然设置为有界,但容量设置得远大于业务峰值吞吐量,从服务启动到出问题的时间窗口内队列从未满过,监控上表现出来的就是max配置失效。排查时不能只看配置,要结合队列的highWaterMark或者getQueue().size()的历史峰值一起判断。
三、想让线程池优先扩容的几种改造方案
如果业务特点是任务不能容忍排队延迟,希望先扩线程再排队,可以用SynchronousQueue。它不存储任务,offer只有在恰好有消费者等待时才成功,否则立即返回false,从而让线程池快速扩展到maximumPoolSize,满了之后走拒绝策略。Tomcat、Dubbo等框架的默认线程池就是这个思路。
另一种更灵活的方式是自定义队列,重写offer方法,当当前线程数小于最大线程数时故意返回false,迫使线程池先创建线程,核心思路如下:
public class EagerQueue extends LinkedBlockingQueue<Runnable> {
public EagerQueue(int capacity) {
super(capacity);
}
@Override
public boolean offer(Runnable runnable) {
// 返回false骗过execute,让它优先走addWorker创建非核心线程
return false;
}
// 创建线程失败的兜底:把任务真正放入队列
public boolean retryOffer(Runnable runnable) {
return super.offer(runnable);
}
}使用这种自定义队列时需要注意execute第三步失败的兜底逻辑:当addWorker返回false(线程数已达maximumPoolSize),需要通过自定义的RejectedExecutionHandler把任务重新塞回队列而不是直接拒绝,否则会丢任务。这种方案本质上就是重写了Tomcat的TaskQueue实现思路,适合对延迟敏感的场景。
除了改队列,还可以简单粗暴地调大corePoolSize让两者相等,等于放弃弹性伸缩,换来线程创建的及时性。这种方案实现成本最低,但代价是线程常驻,空闲时资源占用高,适合流量比较平稳的服务。综合来看,排查这类问题的核心永远是先搞清楚offer是否返回false,再根据业务对延迟和资源的取舍选择合适的改造方式。
maximumPoolSizeThreadPoolExecutor线程池扩容修改时间:2026-09-04 15:38:41