线程池介绍
1. ThreadPoolExecutor 的七个核心参数
线程池的核心是 ThreadPoolExecutor,它的构造参数决定了“并发上限、排队策略与背压方式”。
| 参数 | 作用 | 关键影响 |
|---|---|---|
corePoolSize |
核心线程数 | 小于该值时优先新建线程执行 |
maximumPoolSize |
最大线程数 | 队列满后是否还能扩容线程 |
keepAliveTime + unit |
非核心线程空闲回收时间 | 回收非核心线程,释放资源 |
workQueue |
任务队列 | 决定排队语义与是否“无界堆积” |
threadFactory |
线程工厂 | 线程命名、是否守护线程、异常处理 |
rejectedExecutionHandler |
拒绝策略 | 饱和时如何背压(抛错/降速/丢弃) |
补充点:allowCoreThreadTimeout(true) 可以让核心线程也参与空闲回收,但要结合业务流量形态评估。
2. workQueue 怎么选
常见队列语义:
ArrayBlockingQueue:有界队列,容量可控,推荐线上优先考虑。LinkedBlockingQueue:可配置容量,但很多工厂方法默认是无界,容易堆积导致 OOM 或延迟飙升。SynchronousQueue:不存储任务,直接交接;容易触发线程数扩到maximumPoolSize。
队列的选择决定了“突发流量是排队还是扩线程”,也决定了故障形态是“拒绝”还是“堆积”。
3. 拒绝策略
拒绝发生的典型条件:
- 线程池已关闭仍提交任务。
- 线程数达到
maximumPoolSize且队列已满。
ThreadPoolExecutor 内置四种策略:
AbortPolicy:默认,抛RejectedExecutionException,让调用方显式感知失败。CallerRunsPolicy:调用者线程执行任务,起到“主动降速”的背压效果。DiscardPolicy:直接丢弃任务(静默),只适合极少数允许丢的场景。DiscardOldestPolicy:丢弃队列最旧任务再尝试入队,适合“保最新、可丢历史”的场景。
线上常见做法是自定义拒绝策略:记录日志与指标,并明确降级路径,避免静默丢任务。
4. Java 创建线程池时需要注意什么
创建线程池不是“随便 new 一个 ExecutorService”就结束了,真正要考虑的是:
- 线程数是否合理。
- 队列是否可控。
- 饱和后系统是降速、失败,还是把内存打爆。
- 任务异常、上下文、关闭流程是否可观测、可治理。
4.1 不要在线上默认使用 Executors 的几个工厂方法
面试和生产实践里,这几乎是必考点。
原因是 Executors 提供的若干快捷方法,默认参数容易埋坑:
newFixedThreadPool():底层通常配无界LinkedBlockingQueue,流量堆积时容易造成内存膨胀、延迟失控。newSingleThreadExecutor():本质也是无界队列,只是变成单线程串行执行。newCachedThreadPool():底层是SynchronousQueue,线程数理论上可无限增长,突发流量下容易把 CPU、内存、上下文切换打满。newScheduledThreadPool():适合定时任务,但如果任务执行时间长、任务堆积严重,同样会出现延迟和资源争用问题。
因此线上更推荐显式使用 ThreadPoolExecutor,把:
- 核心线程数。
- 最大线程数。
- 队列容量。
- 线程工厂。
- 拒绝策略。
都写清楚、管起来。
4.2 线程数要看任务类型
线程池大小和任务类型强相关。
常见经验:
- CPU 密集型任务:线程数通常接近
CPU 核数或CPU 核数 + 1。 - I/O 密集型任务:线程数可以适当大一些,因为线程大量时间在等待网络、磁盘、数据库。
- 混合型任务:最好拆池,不要把 CPU 计算和慢 I/O 混在同一个池里。
如果线程数过小,会导致吞吐上不去;如果线程数过大,会导致:
- 上下文切换增加。
- CPU 抖动。
- GC 压力增大。
- 数据库连接池、下游服务被打满。
所以线程池容量不能脱离下游资源单独配置。
4.3 队列必须尽量有界
无界队列看上去“很安全”,因为不容易触发拒绝,但它把问题从“失败”变成了“无限堆积”。
典型后果:
- 请求持续排队,延迟越来越大。
- 任务对象堆积导致 OOM。
- 上游以为系统还活着,实际上已经进入“假性可用、真实不可用”状态。
因此线上一般优先考虑:
ArrayBlockingQueue- 指定容量的
LinkedBlockingQueue
核心思想是:宁可有边界地失败,也不要无边界地堆积。
4.4 拒绝策略一定要和业务语义匹配
拒绝策略不是“最后兜底的小配置”,而是线程池的背压设计。
例如:
- 核心链路任务:通常不能静默丢弃,适合显式报错或降级。
- 可延迟但不能丢的任务:可以考虑调用方阻塞、降速、落消息队列。
- 非核心、可丢弃任务:才可能考虑
DiscardPolicy一类策略。
很多线上事故不是线程池“满了”,而是满了以后行为不明确。
4.5 线程工厂不要省略
自定义 ThreadFactory 通常至少要做三件事:
- 给线程起可识别的名字。
- 决定是否为守护线程。
- 配置
UncaughtExceptionHandler。
线程名非常重要,因为它直接决定:
jstack能不能快速定位问题。- 监控平台能不能区分不同线程池。
- 日志排查是否高效。
示例:
ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger seq = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-worker-" + seq.getAndIncrement());
t.setUncaughtExceptionHandler((thread, ex) ->
log.error("thread {} crashed", thread.getName(), ex));
return t;
}
};
4.6 注意 execute() 和 submit() 的异常差异
这是 Java 线程池里非常容易忽视的点。
execute():任务抛出的运行时异常会走线程的未捕获异常处理逻辑。submit():异常会被封装进Future,如果调用方不执行get(),异常很容易被“吞掉”。
所以如果使用 submit(),要明确:
- 谁来消费
Future。 - 谁来记录任务失败。
- 是否需要统一包装任务埋点和异常日志。
否则你会看到“任务没成功,但也没人报警”。
4.7 不要把不同性质的任务混在一个线程池
典型反模式:
- 把快任务和慢任务放在一个池里。
- 把 RPC 调用、数据库写入、文件 I/O、CPU 计算都扔进同一个池。
- 把核心链路任务和低优先级异步任务共用同一个池。
这样很容易出现:
- 慢任务把线程全占满。
- 快任务排队甚至超时。
- 整个服务被低优先级任务拖垮。
工程上通常要做线程池隔离:
- 按业务隔离。
- 按任务类型隔离。
- 按优先级隔离。
- 按下游依赖隔离。
4.8 警惕线程池里的阻塞和嵌套等待
线程池最怕的不是“有任务”,而是“任务把线程卡住”。
典型风险包括:
- 任务内部执行慢 SQL、慢 RPC、长时间锁等待。
- 一个线程池任务内部再提交子任务到同一个线程池,并同步等待结果。
Future#get()、CountDownLatch.await()、join()等阻塞调用把工作线程耗尽。
最经典的死锁场景是:
- 线程池只有少量线程。
- 父任务占住线程后,又向同一个池提交子任务。
- 父任务同步等子任务结果。
- 子任务因为没有空闲线程永远无法执行。
所以线程池设计时要特别小心池内嵌套提交 + 同步等待。
4.9 在线程池里使用 ThreadLocal 必须手动清理
线程池线程会复用,所以 ThreadLocal 的问题比普通新建线程更严重。
如果不清理,容易出现:
- 上一个请求的上下文串到下一个请求。
- 用户信息、租户信息、TraceId 污染。
- 长生命周期线程挂住对象,造成内存泄漏。
正确做法通常是:
executor.execute(() -> {
try {
contextHolder.set(ctx);
doWork();
} finally {
contextHolder.remove();
}
});
4.10 关闭线程池要规范,不要只管创建不管回收
线程池是资源,需要明确生命周期。
至少要考虑:
- 应用停止时是否调用
shutdown()。 - 是否需要
awaitTermination()等待任务收尾。 - 超时后是否
shutdownNow()。 - 容器托管环境下是否交给框架统一管理。
这里还有一个很容易被问到的问题:调用 shutdown() 以后,线程会不会立刻停止?
答案是:不会立刻停止。
shutdown()的语义是拒绝接收新任务,但会继续把队列里已经提交的任务和正在执行的任务跑完。- 所以调用
shutdown()后,线程池通常会进入“停止接单,但处理存量任务”的状态,而不是马上把工作线程杀掉。
而 shutdownNow() 也不是“保证立刻停掉所有线程”,它做的是:
- 尝试中断正在执行的工作线程;
- 把队列里还没开始执行的任务直接返回给调用方。
但要注意,中断只是通知,不是强杀。如果任务代码:
- 没有响应中断;
- 卡在不支持中断的阻塞里;
- 自己吞掉了中断信号继续执行;
那么即使调用了 shutdownNow(),任务也可能不会马上结束。
所以更准确的理解是:
shutdown():平滑关闭,不会立刻停。shutdownNow():尽力快速关闭,但也不保证正在运行的任务瞬间停止。
工程上更推荐的关闭顺序通常是:
- 先
shutdown(),停止接收新任务。 - 再
awaitTermination(),给存量任务一个收尾时间。 - 超时后再
shutdownNow(),尝试中断仍未结束的任务。
如果线程池不关闭,常见后果有:
- JVM 无法退出。
- 线程泄漏。
- 定时任务、后台任务在应用停机时仍继续运行。
4.11 要有监控,不要等线程池打满才知道
线程池属于必须重点监控的基础设施。
建议至少监控:
- 当前线程数。
- 活跃线程数。
- 队列长度。
- 任务完成数。
- 拒绝次数。
- 任务平均耗时、P99 耗时。
如果线程池没有监控,线上出问题时通常只能看到一个现象:
- 请求慢了。
- 但不知道是线程池满了、队列堆了、任务阻塞了,还是下游慢了。
5. 一个更适合线上使用的线程池示例
下面给一个比 Executors.newFixedThreadPool() 更适合线上治理的最小示例:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new NamedThreadFactory("order-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.allowCoreThreadTimeOut(false);
这个配置体现了几个原则:
- 线程数有明确上限。
- 队列有界。
- 线程有名字。
- 饱和时通过
CallerRunsPolicy给调用方施加背压。
它不一定适合所有业务,但比默认无界队列或无限扩线程更可控。
6. 面试里怎么回答“创建线程池要注意什么”
- 线上尽量不要直接用
Executors的默认工厂方法,因为很多默认实现要么无界队列,要么线程数可能无限扩张。 - 线程池参数要结合任务类型来定,区分 CPU 密集型和 I/O 密集型,核心线程数、最大线程数和队列容量都要有依据。
- 队列尽量有界,拒绝策略要和业务语义匹配,不能让任务无限堆积。
- 要自定义线程工厂,做好线程命名、异常处理和监控。
- 要做线程池隔离,避免慢任务拖死快任务,避免线程池内部嵌套等待导致饥饿和死锁。
- 在线程池中使用
ThreadLocal要手动清理,线程池也要有规范的关闭流程和监控告警。