corePoolSize=2、maximumPoolSize=3,两个线程忙着干活,第三个任务却进了队列。看到这种状态,很容易怀疑:最大线程数是不是没生效?
这是 ThreadPoolExecutor 的正常行为。达到核心线程数后,新任务优先尝试入队;入队失败,才尝试增加线程,直到最大线程数。 最大线程数是扩容上限,并不是"有任务就立即补足"的目标值。这里讨论线程池仍在运行、采用标准阻塞队列、线程能够正常创建的情况。Oracle Java 8 官方文档的 Queuing 一节明确描述了这个顺序。
我们用一个可复现的小实验,把"正在执行""排队""扩容"和"拒绝"分开观察。
先让任务停在门口
实验参数是核心线程 2、最大线程 3、队列容量 2,拒绝策略显式指定 AbortPolicy。所有任务进入 run() 后,必须等 RELEASE 放行才能完成。
这里不用 sleep。如果只让任务睡一会儿,机器负载和提交速度稍有变化,前面的任务就可能先结束,后面的任务也就不一定排队。我们使用两层同步:每个任务的 entered 通知主线程"我已开始";公共 RELEASE 保证观察结束前没有任务退出。这个机制利用了 CountDownLatch 的等待与释放语义。代码里的 10 秒只是失败超时,作用是避免程序无限等待。
主线程只等待任务 1、2、5 启动。任务 3、4 本来就应该留在队列中,如果也等它们启动,就会把实验自己卡住。无论检查成功还是抛出异常,finally 都会打开门并关闭线程池,避免后台线程阻止进程退出。
以下是完整源码,保存为 PoolQueueExperiment.java 即可编译运行:
java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class PoolQueueExperiment {
static final CountDownLatch RELEASE = new CountDownLatch(1);
static final AtomicInteger COMPLETED = new AtomicInteger();
static final class Task implements Runnable {
final int id;
final CountDownLatch entered = new CountDownLatch(1);
Task(int id) { this.id = id; }
public void run() {
entered.countDown();
try {
RELEASE.await();
COMPLETED.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public String toString() { return "task-" + id; }
}
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void snapshot(ThreadPoolExecutor pool, int id, String result) {
int expectedPool = id == 1 ? 1 : id < 5 ? 2 : 3;
int expectedQueue = id <= 2 ? 0 : id == 3 ? 1 : 2;
check(pool.getPoolSize() == expectedPool, "unexpected pool size");
check(pool.getQueue().size() == expectedQueue, "unexpected queue size");
check(COMPLETED.get() == 0, "a task finished before release");
System.out.printf("submit=%d %s pool=%d queue=%s completed=%d%n",
id, result, pool.getPoolSize(), pool.getQueue(), COMPLETED.get());
}
public static void main(String[] args) throws InterruptedException {
System.out.println("java.version=" + System.getProperty("java.version"));
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 3, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(2),
new ThreadPoolExecutor.AbortPolicy());
try {
for (int id = 1; id <= 5; id++) {
Task task = new Task(id);
pool.execute(task);
if (id <= 2 || id == 5) {
check(task.entered.await(10, TimeUnit.SECONDS), "task did not start");
}
snapshot(pool, id, "ACCEPTED");
}
boolean rejected = false;
try {
pool.execute(new Task(6));
} catch (RejectedExecutionException expected) {
rejected = true;
}
check(rejected, "task 6 was not rejected");
snapshot(pool, 6, "REJECTED");
} finally {
RELEASE.countDown();
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
throw new AssertionError("executor did not terminate");
}
}
check(COMPLETED.get() == 5, "expected 5 completed tasks");
System.out.printf("after release: completed=%d terminated=%s%n",
COMPLETED.get(), pool.isTerminated());
System.out.println("PASS");
}
}
bash
javac -encoding UTF-8 PoolQueueExperiment.java
java PoolQueueExperiment
实际发生了什么
下面是 2026 年 10 月 23 日在本机 Oracle JDK 1.8.0_361 上编译运行的输出,最后的 PASS 表示程序中的状态检查全部通过:
text
java.version=1.8.0_361
submit=1 ACCEPTED pool=1 queue=[] completed=0
submit=2 ACCEPTED pool=2 queue=[] completed=0
submit=3 ACCEPTED pool=2 queue=[task-3] completed=0
submit=4 ACCEPTED pool=2 queue=[task-3, task-4] completed=0
submit=5 ACCEPTED pool=3 queue=[task-3, task-4] completed=0
submit=6 REJECTED pool=3 queue=[task-3, task-4] completed=0
after release: completed=5 terminated=true
PASS
前两个任务分别创建线程,随后被释放信号挡住。提交第 3 个任务时,池中已经有 2 个线程,队列还有空位,所以它进入队列;第 4 个任务继续排队。此时 线程数只有 2,最大值是 3,队列却已经装满。这正是标题描述的现象。
还要区分线程数和 CPU 是否繁忙。实验中的线程在等待信号,几乎不做计算,但它们承接的任务没有结束,也就无法转去处理队列中的工作。CPU 利用率低,并不等于线程池一定有空闲的执行位置。这里没有把活跃线程数当成触发扩容的条件。
第 5 个任务到来时,入队失败,而线程数还没达到 3,于是新建线程直接执行它。输出里的队列仍然是 task-3、task-4,并且代码已等到任务 5 进入 run()。因此,这次实验还证明了另一点:即使等待队列按 FIFO 排列,也不能据此认为整个线程池严格按提交顺序开始执行任务。 扩容时,新任务可以先于队列中的旧任务启动。
第 6 个任务面对的是 3 个无法结束的任务和一个满队列,不能继续扩容,AbortPolicy 抛出 RejectedExecutionException。主线程捕获异常后才打开释放信号,最后 5 个已接收任务全部完成。
"3 个线程加 2 个队列位置,一共接收 5 个任务"只适用于这次任务全部停住的观察窗口。真实系统中,任务在不断完成,不能拿这个数字当每秒处理能力。
为什么只调大 max 可能没用
这个实验的关键变量是队列。假如把容量 2 换成一个足够大的队列,第 5 个任务也能入队,触发扩容的时点就会推迟。对于没有指定容量的 LinkedBlockingQueue,通常会先积累大量任务,不能指望靠增大 maximumPoolSize 及时消化排队。Oracle Java 21 官方文档同样说明了无界队列与最大线程数之间的关系。
看到排队时,先把等待时间和执行时间拆开。可以在提交、开始执行、执行结束三个位置记录时间,再结合队列长度、拒绝次数和下游连接数判断。任务可能在等待数据库连接,也可能在执行耗时计算;两种情况下,单纯增加线程的结果会不同。队列容量也应结合能够接受的等待时间来定,而不是只追求"没有拒绝"。
面试中可以这样回答:先看核心线程数,再看队列是否接收任务;队列拒收后才看能否扩到 max,仍无法接收就进入拒绝策略。 这次实验验证的是普通 ThreadPoolExecutor、有界队列和运行状态下的提交行为,不覆盖关闭中的线程池、定制队列或线程创建失败等分支。换成 SynchronousQueue、其他拒绝策略,或者动态调整参数,都需要重新分析,不能机械套用这里的六次提交结果。
延伸练习:MTX Java 并发专题。
创作说明:本文使用 AI 辅助撰写与核对;示例代码已在文中注明的 JDK 环境实际运行,结果保留在实验记录中。