跳到主要内容
约 11 分钟6 张卡

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 要决定新任务由谁执行,以及暂时无法执行时放在哪里。运行中的线程池按创建、排队、扩容、拒绝的顺序处理:

  1. Worker 数量少于 corePoolSize 时,尝试创建 Worker,并让它执行当前任务。
  2. 未通过第一步交给 Worker 时,尝试把任务放入队列,再复查运行状态。
  3. 队列无法接收任务时,尝试增加 Worker,最多达到 maximumPoolSize。
  4. 线程池已关闭,或容量耗尽仍无法接收任务时,执行拒绝策略。

入队后复查,是因为入队与线程池关闭可以同时发生。如果复查发现池已关闭,且能从队列移除当前任务,就拒绝该任务。若任务仍留在队列,而 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. 饱和与堆积诊断​

队列长期增长,说明任务到达速率高于完成速率。排查要区分流量增加和任务变慢,再判断是否有扩容空间:

  1. 查看活跃线程数 active、池大小、队列长度、最老任务年龄、完成数和拒绝数。任务年龄表示它已经等待多久。
  2. 比较提交速率、完成速率和任务耗时,确认是新任务变多,还是每个任务执行得更慢。
  3. 连续采集多份线程栈,检查数据库连接等待、下游调用、锁竞争和 CPU 热点。
  4. 检查父任务是否同步等待同一个池中的子任务,导致 Worker 全部被占用。
  5. 限制新任务、隔离慢依赖并放弃已过期任务,再根据可用资源评估扩容。

线程多而 CPU 低时,应先定位线程在等待什么;继续加线程会增加同一资源前的等待者。

同池等待的原因见 并发活跃性。

7. 队列类型与扩容​

达到核心线程数后,execute 会先尝试入队。因此,同样的线程数配置,搭配不同队列会得到不同的扩容行为:

队列接收特点对扩容的影响
无界 LinkedBlockingQueue持续保存等待任务核心数后优先积压
有界 ArrayBlockingQueue容量固定满后才扩展 Worker
SynchronousQueue容量为零,直接交接无接收者时尝试增线程

无界队列持续接收任务,因此很难触发按 maximumPoolSize 增加线程的分支。SynchronousQueue 则直接把任务交给等待者。没有线程接收时,线程池就会尝试增加 Worker。默认 cached pool 使用这种直接交接队列,并给出很高的线程上限。

需要队列缓冲突发流量时,应按可接受排队时间和单任务内存确定容量。取出任务时还要检查截止时间,避免继续执行已经过期的工作。

8. 线程池关闭与终止​

关闭方法返回后,线程池中的任务还可能继续执行。理解这一点,需要区分 OpenJDK 21 的五种状态:

状态行为
RUNNING接收新任务,处理队列
SHUTDOWN拒绝新任务,继续处理队列
STOP拒绝新任务,停止取队列并尝试中断运行任务
TIDYINGWorker 已归零,执行终止钩子
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 固定线程池为什么还会耗尽内存?​

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,使等待方收到取消结果。

12. 参考资料​