Java 线程池
Java 线程池复用工作线程,并用线程数量与队列容量管理任务。本文从 ThreadPoolExecutor 的构造参数入手,沿任务接收过程解释排队、扩容与拒绝,再介绍容量估算和关闭流程。工作线程在实现中称为 Worker。
1. 面试核心
先记住这几条,再读正文- 任务接收顺序。 运行中的线程池先按核心线程数创建 Worker,再尝试入队,入队失败后才按最大线程数扩容。卡 · 构造参数
- 队列与过载。 无界队列在核心 Worker 创建后持续接收任务,过载会表现为队列和等待时间不断增长。卡 · 队列类型与扩容
- 线程数选择。 I/O 线程数由任务的等待比例和目标吞吐决定,并受下游并发容量与内存限制。卡 · 线程池容量
- 拒绝处理。 池关闭或容量耗尽时会调用拒绝策略。卡 · 过载保护
- 立即关闭。
shutdownNow()停止从队列取任务,尝试中断正在执行的任务,并返回尚未执行的排队任务。卡 · 线程池关闭与终止
2. 构造参数
理解线程池容量,需要把线程数、排队空间和拒绝行为放在一起看。ThreadPoolExecutor 的七个主要构造参数如下:
| 范围 | 参数 | 作用 |
|---|---|---|
| 线程数量 | corePoolSize、maximumPoolSize | 核心阈值与线程上限 |
| 空闲回收 | keepAliveTime、unit | 空闲等待期限 |
| 排队 | workQueue | 保存待执行任务 |
| 创建 | threadFactory | 创建和命名 Worker 线程 |
| 拒绝 | handler | 处理拒绝任务 |
ExecutorService executor = new ThreadPoolExecutor(
8,
16,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.AbortPolicy()
);
示例使用六参数重载,采用默认 ThreadFactory。核心线程数为 8,队列容量为 200,线程上限为 16。超过核心数量的线程空闲 30 秒后可以回收。线程和队列都满时,AbortPolicy 抛出拒绝异常。
自定义 ThreadFactory 可给线程添加业务名称,方便从线程栈识别任务来源。参数还要与队列类型一起解释:队列越小,任务越早进入扩容或拒绝分支。
3. execute 接收流程
execute 要决定新任务由谁执行,以及暂时无法执行时放在哪里。运行中的线程池按创建、排队、扩容、拒绝的顺序处理:
- Worker 数量少于
corePoolSize时,尝试创建 Worker,并让它执行当前任务。 - 未通过第一步交给 Worker 时,尝试把任务放入队列,再复查运行状态。
- 队列无法接收任务时,尝试增加 Worker,最多达到
maximumPoolSize。 - 线程池已关闭,或容量耗尽仍无法接收任务时,执行拒绝策略。
入队后复查,是因为入队与线程池关闭可以同时发生。如果复查发现池已关闭,且能从队列移除当前任务,就拒绝该任务。若任务仍留在队列,而 Worker 数量为零,则尝试补建 Worker,避免队列无人消费。
Worker 完成初始任务后,会继续从队列取任务,所以一个线程可以执行多次任务。超过核心数量的线程空闲达到 keepAliveTime 后,可以超时退出。启用 allowCoreThreadTimeOut(true) 后,核心数量内的线程也可回收,此时 keepAliveTime 必须大于零。
3.1 execute 与 submit 的异常
同一个任务抛出异常时,execute 和 submit 会让调用方通过不同途径发现失败。区别在于 submit 增加了保存结果和异常的任务包装。
直接通过 execute 执行的任务抛出未捕获异常时,Worker 会异常退出。异常交给线程的未捕获异常处理器,线程池再按状态和容量补建 Worker。
submit 会把任务包装成 FutureTask,由它保存结果或异常。调用方执行 Future.get() 时,会收到包装任务异常的 ExecutionException。所以,如果丢弃返回的 Future,任务失败就容易被忽略。
afterExecute 是任务执行后调用的扩展方法,其 Throwable 参数也受这种包装影响。FutureTask 已捕获异常时,这个参数可以为 null。因此,自定义监控还需检查已完成的 Future,才能发现内部任务失败。
4. 线程池容量
配置容量时,先区分任务是在计算,还是在等待。CPU 密集任务持续占用核心,并行度接近可用核心数后,更多线程会争抢 CPU。I/O 任务等待数据库或网络时,可以让出 CPU,但仍会占用连接等下游资源。
可用以下关系估算线程数和在途任务数,作为压测起点:
线程数 ≈ CPU 核数 × 目标利用率 × (1 + 等待时间 / 计算时间)
在途任务数 ≈ 目标吞吐 × 平均响应时间
在途任务指已经进入系统、但尚未完成的任务。第二条是稳定系统下的 Little 定律,用来检查目标吞吐需要承载多少任务。第一条则依赖计算时间与等待时间的测量,所以两者都只是估算。
实际配置还要满足以下资源与延迟约束:
- 数据库连接数限制同时执行的查询数量。
- 下游配额限制同时发起的接口调用数量。
- 内存限制线程栈和在途任务可以占用的空间。
- 容器 CPU 配额和可用核心数限制计算能力。
- 请求超时与延迟预算限制任务可以排队多久。
压测时逐步增加并发,同时观察吞吐、P95/P99、队列和拒绝数。P99 表示 99% 的请求耗时不超过的值,用来观察较慢请求。还要结合 CPU、上下文切换次数和下游等待,判断瓶颈在哪里。吞吐持平而长尾延迟上升时,继续加线程已经没有收益。
5. 过载保护
有界队列限制等待任务的数量,让排队内存可以估算。线程和队列都耗尽后,拒绝策略决定新任务的结果:
| 策略 | 行为 |
|---|---|
AbortPolicy | 抛出 RejectedExecutionException |
CallerRunsPolicy | 池未关闭时由提交线程执行,否则丢弃 |
DiscardPolicy | 静默丢弃当前任务 |
DiscardOldestPolicy | 池未关闭时移除队头,再尝试提交 |
选择拒绝策略时,还要定义调用方收到什么结果。业务可以返回繁忙、有限重试或降级,也可以合并重复任务,或明确允许丢弃。重试任务要有幂等保证,也就是重复执行仍能得到正确结果。还应设置任务截止时间并记录拒绝指标,避免过期工作继续消耗资源。
CallerRunsPolicy 会让提交者花时间执行任务,从而减慢后续提交,这种效果称为反压。但它的效果取决于提交链路。若提交者是 I/O 线程、事件循环或持锁线程,执行慢任务就会扩大阻塞范围。
6. 饱和与堆积诊断
队列长期增长,说明任务到达速率高于完成速率。排查要区分流量增加和任务变慢,再判断是否有扩容空间:
- 查看活跃线程数 active、池大小、队列长度、最老任务年龄、完成数和拒绝数。任务年龄表示它已经等待多久。
- 比较提交速率、完成速率和任务耗时,确认是新任务变多,还是每个任务执行得更慢。
- 连续采集多份线程栈,检查数据库连接等待、下游调用、锁竞争和 CPU 热点。
- 检查父任务是否同步等待同一个池中的子任务,导致 Worker 全部被占用。
- 限制新任务、隔离慢依赖并放弃已过期任务,再根据可用资源评估扩容。
线程多而 CPU 低时,应先定位线程在等待什么;继续加线程会增加同一资源前的等待者。
同池等待的原因见 并发活跃性。
7. 队列类型与扩容
达到核心线程数后,execute 会先尝试入队。因此,同样的线程数配置,搭配不同队列会得到不同的扩容行为:
| 队列 | 接收特点 | 对扩容的影响 |
|---|---|---|
无界 LinkedBlockingQueue | 持续保存等待任务 | 核心数后优先积压 |
有界 ArrayBlockingQueue | 容量固定 | 满后才扩展 Worker |
SynchronousQueue | 容量为零,直接交接 | 无接收者时尝试增线程 |
无界队列持续接收任务,因此很难触发按 maximumPoolSize 增加线程的分支。SynchronousQueue 则直接把任务交给等待者。没有线程接收时,线程池就会尝试增加 Worker。默认 cached pool 使用这种直接交接队列,并给出很高的线程上限。
需要队列缓冲突发流量时,应按可接受排队时间和单任务内存确定容量。取出任务时还要检查截止时间,避免继续执行已经过期的工作。
8. 线程池关闭与终止
关闭方法返回后,线程池中的任务还可能继续执行。理解这一点,需要区分 OpenJDK 21 的五种状态:
| 状态 | 行为 |
|---|---|
RUNNING | 接收新任务,处理队列 |
SHUTDOWN | 拒绝新任务,继续处理队列 |
STOP | 拒绝新任务,停止取队列并尝试中断运行任务 |
TIDYING | Worker 已归零,执行终止钩子 |
TERMINATED | 终止钩子执行完成 |
shutdown() 进入有序关闭:拒绝新任务,继续处理已接受的任务。shutdownNow() 则停止取队列,尝试中断运行任务,并返回尚未执行的排队任务。调用方可以用 awaitTermination 等待真正终止,并为等待设置期限。
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
List<Runnable> pending = pool.shutdownNow();
handlePending(pending);
}
示例先等待 5 秒;如果尚未终止,就尝试中断运行任务,并把排队任务交给 handlePending。这段代码需处理或声明 InterruptedException。
如果运行任务忽略中断,它仍会继续执行,线程池要等它结束才能终止。返回的排队 FutureTask 也需要调用方取消或接管,才能让等待结果的调用方结束等待。
9. 静默拒绝与悬空 Future
有些任务被丢弃后,调用方仍拿着一个永远无法完成的 Future。原因是 submit 先创建 FutureTask,再调用 execute 提交它。拒绝策略静默丢弃任务时,FutureTask 既未执行,也未被取消,所以 get() 会持续等待。
以下三种拒绝情形需要关注:
DiscardPolicy直接丢弃新任务,留下它对应的 Future。- 线程池关闭后,
CallerRunsPolicy也会直接丢弃任务。 DiscardOldestPolicy移除队头任务时,会留下被移除任务的 Future。
需要返回结果的任务应采用显式拒绝,例如用 AbortPolicy 抛出异常。自定义处理器也可以取消被丢弃的 Future,并记录失败。这样调用方才能得到一个明确结束的结果。
10. 常见问题
10.1 创建线程池时会立即创建所有核心线程吗?
核心 Worker 默认在任务到来时按需创建。需要提前准备消费者时,可以用 prestartCoreThread() 启动一个,或用 prestartAllCoreThreads() 启动全部核心 Worker。
10.2 固定线程池为什么还会耗尽内存?
Executors.newFixedThreadPool 固定了线程数量,但使用无界 LinkedBlockingQueue。流量持续超过处理能力时,任务会不断排队,延迟和内存占用也会增长。需要明确等待上限时,可以显式构造有界线程池,并定义拒绝结果。
10.3 队列越大越能应对突发吗?
队列可以吸收短时突发,容量则应由单任务内存和可接受等待时间决定。持续积压时,更大的队列会延长等待、保存更多过期任务,也会推迟拒绝信号。因此,需要结合延迟预算判断缓冲空间是否有用。
11 · 面试题卡片
11.1 ThreadPoolExecutor.execute 的任务处理流程是什么
出现公司:阿里巴巴、美团
考察重点
- 解释创建核心 Worker、入队和按最大线程数扩容的先后关系。
- 说明为什么入队后还要复查状态,以及无界队列怎样影响扩容。
相关内容:构造参数、execute 接收流程。
参考回答
运行中的线程池按核心线程数创建 Worker,随后尝试入队,入队失败才按最大线程数扩容。
如果线程池已关闭,或线程与队列都无法接收任务,就调用拒绝策略。
成功入队后还要复查状态,因为提交期间线程池可能已经关闭。此时,若能移除当前任务,就拒绝它;如果队列还有任务而 Worker 为零,则尝试创建消费者。由于先入队后扩容,无界队列会让过载主要表现为排队增长。
11.2 怎样为 I/O 密集任务配置线程池大小
出现公司:美团、滴滴
考察重点
- 说明等待与计算时间的比例怎样影响线程数,并用目标吞吐估算在途量。
- 结合连接数、下游容量和内存限制,解释压测结果是否支持增加线程。
相关内容:线程池容量。
参考回答
I/O 线程数由等待比例和目标吞吐决定,并受下游容量与内存限制。
初始估算可用“核数 × 目标利用率 ×(1 + 等待时间 / 计算时间)”。再用“目标吞吐 × 平均响应时间”检查需要承载的在途量。
估算之后,要按数据库连接数、对端并发和内存约束确定压测范围。逐步增加线程,同时观察吞吐、P99、队列年龄和拒绝数。结合 CPU、上下文切换和下游等待判断瓶颈;吞吐停止增长而长尾变差时,就应停止加线程。
11.3 队列满或任务持续堆积时应该怎样处理
出现公司:美团、阿里巴巴
考察重点
- 说明任务被拒绝后,调用方会收到什么结果。
- 结合提交与完成速率、线程栈和下游等待,区分流量增加与任务变慢。
参考回答
队列满后,运行中的池会尝试增加 Worker;达到上限仍无法接收时,就执行拒绝策略。
业务应明确选择返回繁忙、有限重试、降级或允许丢弃,并记录队列年龄和拒绝数。
持续堆积时,先比较提交与完成速率,区分流量增加和任务变慢。再用多份线程栈,以及数据库、下游、锁和 CPU 指标定位等待原因。恢复时先限制新增负载、隔离慢依赖并放弃过期任务。有证据表明仍有可用资源时,再增加线程池容量。
11.4 为什么 maximumPoolSize 很大,线程数仍停在 corePoolSize?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释先入队后扩容的顺序,以及不同队列何时会触发新增 Worker。
相关内容:队列类型与扩容。
参考回答
最常见的原因是使用了无界队列:达到 corePoolSize 后,execute 优先入队。
只要队列持续接收任务,就难以触发按 maximumPoolSize 扩容的分支。
有界队列满后才尝试增加 Worker。SynchronousQueue 则没有缓冲空间,没有接收者时就会尝试扩容。
11.5 shutdown、shutdownNow 和 awaitTermination 有什么区别?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分停止接收任务、处理中断和线程池真正终止,并说明排队任务由谁处理。
相关内容:线程池关闭与终止。
参考回答
shutdown() 有序关闭,shutdownNow() 尝试立即停止,而 awaitTermination() 负责等待终止。
前者拒绝新任务后继续执行已接受任务;shutdownNow() 则停止取队列,并尝试中断运行任务。
shutdownNow() 返回尚未执行的排队任务,调用方需要取消或接管它们。运行任务还要响应中断,才能及时退出。调用方可以用 awaitTermination() 设置等待期限,但等待超时本身不会终止任务。
11.6 submit 配合 DiscardPolicy,为什么 get 会一直等待?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释任务被丢弃后,Future 为什么仍未完成,以及怎样让调用方结束等待。
相关内容:静默拒绝与悬空 Future。
参考回答
DiscardPolicy 只丢弃任务,既不执行 FutureTask,也不把它标记为取消。
因此,submit 返回的 Future 一直未完成,get() 就会持续等待。
可以用 AbortPolicy 抛出拒绝异常,让提交方立即发现失败。也可以在自定义拒绝处理器中取消被丢弃的 Future,使等待方收到取消结果。