线程池明明没到最大线程数,为什么任务还在排队?

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 环境实际运行,结果保留在实验记录中。

相关推荐
用户072367066601 小时前
用 Groovy 做动态 Mock:参数分支、造真数据、模拟异常
java·groovy
yuniko-n1 小时前
【JUC】内存屏障
java·开发语言
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 13 篇):放量突破策略
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口
文丑颜不良啊1 小时前
LeetCode626~650题目解析
java·leetcode
tianbin9112 小时前
批处理与微调辅助场景:Claude 中转并发、链路缓存与压测对比
java·开发语言·缓存
frjc2 小时前
Spring AI 里 ChatClient 和 ChatModel 有什么区别?什么时候该用哪个?
java·开发语言·spring·ai
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子功能的表设计方案
java·开发语言·交友
文丑颜不良啊2 小时前
LeetCode601~625题目解析
java·leetcode
文丑颜不良啊3 小时前
LeetCode651~675题目解析
java·leetcode