上周参加一家中厂面试,开场很顺利。当被问到“请说说线程池的核心参数”时,我几乎是不假思索地将 corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、handler 流利地背了一遍。面试官点点头,紧接着追问:“那你说说,核心线程是什么时候创建的?是线程池一启动就创建好,还是来了任务才创建?什么时候会执行拒绝策略?”
我当场卡住了——参数背了那么多遍,却从未深入思考过线程池究竟在哪个瞬间创建线程,也没仔细琢磨过拒绝策略的触发时机。结果可想而知,面试倒在了这道“看似简单”的题目上。
回去后,我翻出 JDK 源码,跑了好几个测试用例,甚至还复盘了半年前线上因拒绝策略设置不当导致任务丢失的事故。这才深刻体会到:线程池的参数不是“背”出来的,而是“用”出来的。今天,我就把线程池底层的执行流程掰开揉碎,讲清楚:
- 7 大参数各自管什么,如何配置
- 核心线程到底是“懒创建”还是“预创建”
- 拒绝策略何时会触发,触发后又由谁来处理
- 以及一份可以直接参考的实战配置清单
无论你是为了面试突击,还是想排查线上线程池问题,这篇文章都能带来实实在在的收获。
一、先看全家福:线程池的 7 个核心参数
Java 原生的 ThreadPoolExecutor 提供了最丰富的构造参数,我们通常见到的 Executors 工厂类只是对它做了预设包装。理解这 7 个参数,才算真正入了门。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
| 参数 |
作用 |
类比(餐厅) |
| corePoolSize |
核心线程数,即使空闲也会保留的线程数量 |
餐厅的正式员工,餐厅没关门他们就在 |
| maximumPoolSize |
线程池允许的最大线程数 |
正式员工 + 临时工的最大数量 |
| keepAliveTime |
非核心线程空闲多久后被回收 |
临时工如果在规定时间内没活干,就结账走人 |
| unit |
keepAliveTime 的时间单位 |
分钟、秒等 |
| workQueue |
任务队列,存放等待执行的任务 |
餐厅门口的排队等候区 |
| threadFactory |
线程工厂,用于创建新线程 |
招聘员工的方式(HR 部门) |
| handler |
拒绝策略,当任务无法提交时的处理方式 |
排队区满了,新客人来了怎么处理(告知没位、让客人自己喊号、直接拒绝等) |
很多教程把这 7 个参数罗列完就结束了,但面试官真正想听的是:这些参数在运行时是如何协同工作的?下面我们就从两个最关键的时机切入。
二、核心线程的启动时机:不是“一启动就有”,而是“来活了才招人”
很多初学者误以为 new ThreadPoolExecutor(...) 之后,线程池里就会立刻创建 corePoolSize 个线程。错! 默认情况下,线程池是“懒加载”的:只有提交了任务,并且当前工作线程数小于 corePoolSize,才会创建新线程。
源码级的证据
打开 ThreadPoolExecutor.execute(Runnable command) 方法,核心逻辑如下(精简注释版):
public void execute(Runnable command) {
int c = ctl.get();
// 1. 如果工作线程数 < corePoolSize
if (workerCountOf(c) < corePoolSize) {
// 添加核心线程并执行该任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2. 如果线程池还在运行,尝试入队
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);
}
// 3. 入队失败,尝试创建非核心线程
else if (!addWorker(command, false))
reject(command);
}
关键代码: if (workerCountOf(c) < corePoolSize) { addWorker(command, true); }
这段代码清晰地告诉我们:核心线程是在提交任务时,发现当前线程数不够 corePoolSize,才现创建的。而且每次只创建 1 个,用这个新线程去执行刚提交的任务。
什么时候会提前创建核心线程?
线程池也提供了两个“预启动”方法:
prestartCoreThread():启动一个空闲的核心线程,等待任务。
prestartAllCoreThreads():启动全部核心线程。
如果你的业务对首次任务响应时间要求极高(比如金融交易),可以调用这些方法预热。但绝大多数场景不需要,因为懒加载既能节省资源,又能延迟初始化,通常问题不大。
生活化类比:餐厅招人
一家新餐厅开业,不会在开张前就招满 5 个大厨(corePoolSize=5)干坐着。而是先招 1 个大厨,来客人了就让这个大厨做菜;如果客人太多,1 个大厨忙不过来,才会继续招第 2 个、第 3 个……直到招满 5 个。这 5 个就是“正式工”,即使没客人也不会被辞退(除非调了 allowCoreThreadTimeOut(true))。
三、拒绝策略的执行时机:不是队列满了就触发,还得看最大线程数
这是面试中容易出错的重灾区。很多人以为:当 workQueue 满了,就执行拒绝策略。错!实际流程是:
- 任务提交时,如果当前线程数 < corePoolSize → 创建核心线程执行。
- 如果当前线程数 ≥ corePoolSize → 尝试将任务放入 workQueue。
- 如果 workQueue 已满 → 尝试创建非核心线程(直到 maximumPoolSize)。
- 如果当前线程数已经达到 maximumPoolSize,且 workQueue 已满 → 执行拒绝策略。
关键点:拒绝策略的触发条件是“线程数达到 max 且队列满”,缺一不可。
模拟演示:有图有真相
下面这张图是线程池处理任务的完整决策流程图,建议保存下来,面试时能画出来绝对加分:
提交任务
│
▼
是否 < corePoolSize? ──是──→ 创建核心线程执行
│
否
▼
队列是否还能添加? ──是──→ 加入队列等待执行
│
否
▼
是否 < maximumPoolSize? ──是──→ 创建非核心线程执行
│
否
▼
执行拒绝策略
核心逻辑可视化:这张图清晰地展示了线程池的三级扩容机制:核心 → 队列 → 最大 → 拒绝。绝大多数面试官问“拒绝策略何时触发”,实际是想考察你是否理解“先扩容线程,再触发拒绝”的顺序。
代码示例:观察拒绝策略的触发
我们写一段测试代码,把每一步的线程数、队列大小打印出来。
public class ThreadPoolDemo {
public static void main(String[] args) {
// 核心1,最大2,队列容量2,AbortPolicy
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 0L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 1; i <= 5; i++) {
int taskId = i;
try {
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
try { Thread.sleep(1000); } catch (InterruptedException e) {}
});
} catch (Exception e) {
System.err.println("任务 " + taskId + " 被拒绝: " + e.getMessage());
}
// 打印当前状态
System.out.printf("提交任务%d: 线程数=%d, 队列大小=%d%n",
taskId, executor.getPoolSize(), executor.getQueue().size());
}
executor.shutdown();
}
}
输出结果(关键部分):
提交任务1: 线程数=1, 队列大小=0 // 核心线程执行
提交任务2: 线程数=1, 队列大小=1 // 入队
提交任务3: 线程数=1, 队列大小=2 // 入队(队列满)
提交任务4: 线程数=2, 队列大小=2 // 队列满,创建非核心线程
提交任务5: 线程数=2, 队列大小=2 // 线程已达最大,队列已满 → 拒绝
任务5 被拒绝: Task rejected from java.util.concurrent.ThreadPoolExecutor...
关键输出: 提交任务5: 线程数=2, 队列大小=2 → 拒绝
这个案例完美印证了拒绝策略的触发时机:队列已满,且线程数已达最大值。
四、四种拒绝策略,别再只会背名字了
RejectedExecutionHandler 接口有四个内置实现:
| 策略 |
行为 |
适用场景 |
| AbortPolicy(默认) |
抛出 RejectedExecutionException |
强调不能丢失任务,需调用方捕获处理 |
| CallerRunsPolicy |
让提交任务的线程自己执行该任务 |
降低任务提交速度,反馈控制 |
| DiscardPolicy |
直接丢弃,不抛异常 |
可丢弃的任务,如日志 |
| DiscardOldestPolicy |
丢弃队列中最老的任务,然后重新提交 |
追求最新数据,如实时推荐 |
【避坑指南】
线上曾发生过一个事故:使用 AbortPolicy 却未捕获异常,导致大量请求直接返回 500 错误,业务方半夜告警。后来改为 CallerRunsPolicy,虽然执行线程从 Web 容器线程池变成了业务线程池,但至少任务没有丢失,系统也避免了崩溃。
【面试官追问】
问:CallerRunsPolicy 会不会导致主线程阻塞?如果主线程是 web 请求线程,会有什么后果?
答:会的。当线程池饱和,Web 请求线程需要自己执行任务。如果任务耗时较长,该请求线程会被长期占用,可能导致容器内的其他请求排队等待,甚至耗尽 Web 容器的线程池。因此 CallerRunsPolicy 更适合任务执行速度快、且对响应延迟不敏感的场景。
五、实战配置清单:看完就能用
【实战总结】
- 核心线程数(corePoolSize)
- IO 密集型(大部分网络请求、DB 操作):
核心数 * 2
- CPU 密集型(大量计算):
核心数 + 1
- 混合型:拆分为不同线程池,避免相互干扰。
- 最大线程数(maximumPoolSize)
- 一般设为核心数的 2~4 倍,必须考虑服务器整体负载。
- 千万不要用
Integer.MAX_VALUE,这等于没有上限。
- 队列(workQueue)
- 有界队列(
ArrayBlockingQueue/LinkedBlockingQueue 带容量)必须设置容量,防止内存溢出。
- 同步移交队列(
SynchronousQueue)适用于任务处理速度极快、希望立刻交给线程执行的场景,但此时 maxPoolSize 必须设得较高。
- 拒绝策略(handler)
- 关键业务:自定义策略,将失败任务写入 DB 或 MQ 异步补偿。
- 一般业务:
CallerRunsPolicy 兜底,但需监控执行耗时。
- 可丢弃业务:
DiscardPolicy + 日志记录。
- 线程工厂(threadFactory)
- 必须自定义线程命名,便于问题排查(例如
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build())。
- 动态监控
- 暴露
getPoolSize(), getQueue().size(), getCompletedTaskCount() 等指标到监控系统,并设置合理的告警阈值。
很多面试题,背答案或许能过一面,但二面深挖就容易露馅。像线程池这种天天用的工具,值得我们翻翻源码,把边界条件理清楚。下次再有人问你“拒绝策略什么时候触发”,希望你能清晰地讲出“线程数达最大且队列满”的完整逻辑,甚至可以反问一句:“你遇到的任务丢失问题,是不是队列和最大线程数的配比出了问题?”
共勉。
—— 一个曾经在面试官面前卡壳,如今致力于分享实用多线程知识的程序员。本文由 云栈社区 整理发布,希望能帮助更多开发者深入理解技术细节。