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 并实现 Runnable。runWorker 里是个循环:
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,用 jmap 或 jvisualvm 看堆里 ThreadLocalMap$Entry 的数量。
InheritableThreadLocal 的坑:它只在创建线程时把父线程的值复制给子线程。线程池的线程是复用的,第一次创建时复制了提交线程的值,后续任务复用的是同一个线程,不会重新复制,所以拿到的是第一个提交者的值。这个在父子任务传递上下文(比如 traceId)时特别容易踩。
面试速答
- 提交顺序:核心线程 → 队列 → 非核心线程 → 拒绝策略,队列无界则 maximum 永远用不上。
ctl高 3 位状态低 29 位线程数,一个原子变量管两件事。- 线程复用靠
Worker+getTask循环,keepAliveTime控制非核心线程空闲回收,核心线程默认不回收。 - ThreadLocal 在线程池里必须
remove,否则串值 + 内存泄漏;InheritableThreadLocal只在建线程时复制,线程池复用下不可靠。 - 生产别用
Executors工厂方法,手动new ThreadPoolExecutor指定有界队列和命名线程工厂。
核心收获:线程池的参数不是孤立的,队列类型决定了 maximum 有没有机会生效。下一步,把你项目里所有 Executors.newXxx 找出来,换成显式 ThreadPoolExecutor 并给线程起名。
本文关键词:ThreadPoolExecutor、任务流转、拒绝策略、ThreadLocal 串值、InheritableThreadLocal