← 返回博客
2026-09-15 08:00:02

Java 线程池核心学习笔记:任务提交后到底进队列还是开新线程

Java 线程池核心学习笔记:任务提交后到底进队列还是开新线程

线上有个导报表接口,平时 QPS 很低,某天运营批量导出,接口全卡住,日志里线程池队列堆到几千。排查发现用的是 Executors.newFixedThreadPool,队列是 LinkedBlockingQueue,默认容量 Integer.MAX_VALUE,任务只进队列不加线程,maximumPoolSize 形同虚设。这篇就从这个坑出发,把线程池的任务流转、参数、源码和 ThreadLocal 串值一次讲透。

手把手实操

先复现问题。写一个线程池,核心 2、最大 4、队列容量 2,提交 10 个任务,每个睡 1 秒,打印线程名:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    2, 4, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(2),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.AbortPolicy());

for (int i = 0; i < 10; i++) {
    final int id = i;
    try {
        pool.execute(() -> {
            System.out.println(Thread.currentThread().getName() + " -> task " + id);
            try { Thread.sleep(1000); } catch (InterruptedException e) {}
        });
    } catch (RejectedExecutionException e) {
        System.out.println("rejected task " + id);
    }
}

跑起来你会看到:前 2 个任务直接开线程,第 3、4 个进队列,第 5、6 个又开线程(此时线程数到 4),第 7 个开始抛 RejectedExecutionException。这就是线程池的提交顺序:核心线程 → 队列 → 非核心线程 → 拒绝

关键点在队列容量和拒绝策略这两个参数,ArrayBlockingQueue 有界所以能触发扩容到 maximum,换成 LinkedBlockingQueue 无界就永远走不到第 5 步。踩坑场景就是开头那个:用 Executors 工厂方法图省事,队列无界,maximum 白设。

看源码及解析

核心在 ThreadPoolExecutor.execute(Runnable command),JDK 8 起逻辑稳定,分三步:

if (workerCountOf(c) < corePoolSize) {
    if (addWorker(command, true)) return;
    c = ctl.get();
}
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);
} else if (!addWorker(command, false)) {
    reject(command);
}

addWorker(command, true) 的第二个参数是 core 标志,true 表示按 corePoolSize 上限加,false 按 maximumPoolSize 上限加。为什么先入队再加非核心线程?因为创建线程有成本,队列没满说明任务能缓冲,没必要急着开线程。这个设计权衡是吞吐优先还是延迟优先——队列缓冲降低线程创建开销,代价是任务可能排队等很久。

ctl 是个 AtomicInteger,高 3 位存运行状态,低 29 位存线程数,一个变量同时管两件事,避免状态和数量分别加锁。线程数上限因此是 2^29 - 1,不是 Integer.MAX_VALUE

线程复用靠 Worker 内部类,它继承 AbstractQueuedSynchronizer 并实现 RunnablerunWorker 里是个循环:

while (task != null || (task = getTask()) != null) {
    // 执行 task
}

getTask() 从队列取任务,取不到就根据 allowCoreThreadTimeOut 和当前线程数决定是阻塞等待还是超时退出。keepAliveTime 就是这里生效的:非核心线程空闲超过这个时间,getTask 返回 null,runWorker 退出循环,线程销毁。核心线程默认不超时,除非调 allowCoreThreadTimeOut(true)

拒绝策略四个内置实现:AbortPolicy 抛异常(默认)、CallerRunsPolicy 让提交任务的线程自己跑、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢队列最老的再重试。CallerRunsPolicy 有意思,它会让调用方线程执行任务,相当于给上游一个背压信号,但如果你在 Web 请求线程里提交,会把 Tomcat 工作线程占住。

验证方法

跑上面的代码,观察线程名和拒绝日志,能对上"2 核心 + 2 队列 + 2 非核心 + 拒绝"就说明流转搞懂了。

再验证线程复用:把任务改成打印 Thread.currentThread().getName(),提交 100 个短任务,你会看到线程名反复出现,线程数不会涨到 100。加一行 pool.getPoolSize()pool.getCompletedTaskCount() 打印,能看到线程数稳定在核心数附近。

ThreadLocal 串值验证:在任务里 threadLocal.set(id),任务结束不 remove,再提交一个不 set 的任务,读出来是上一个任务的值。这就是串值。线程池线程复用,ThreadLocal 的 Entry 挂在线程的 ThreadLocalMap 上,线程不死,值就不释放,ThreadLocal 对象本身被回收后 key 变 null,value 还在,就是内存泄漏。验证方式是 set 后不 remove,用 jmapjvisualvm 看堆里 ThreadLocalMap$Entry 的数量。

InheritableThreadLocal 的坑:它只在创建线程时把父线程的值复制给子线程。线程池的线程是复用的,第一次创建时复制了提交线程的值,后续任务复用的是同一个线程,不会重新复制,所以拿到的是第一个提交者的值。这个在父子任务传递上下文(比如 traceId)时特别容易踩。

面试速答

核心收获:线程池的参数不是孤立的,队列类型决定了 maximum 有没有机会生效。下一步,把你项目里所有 Executors.newXxx 找出来,换成显式 ThreadPoolExecutor 并给线程起名。

本文关键词:ThreadPoolExecutor、任务流转、拒绝策略、ThreadLocal 串值、InheritableThreadLocal