我们为什么需要线程池:
在回答问题之前,先问一个问题:为什么不能来一个任务就 new Thread()?
你可以把"线程"想成"员工",把"任务"想成"订单"。如果每来一个订单就临时招一个员工,做完立刻开除,会怎样?招人、开除都要成本;订单一多,员工数量失控;没人管理谁在干什么;系统很快被压垮。线程池就是:提前养一批可复用的员工,订单来了放队列,谁空谁去处理,并且限制总人数。
线程不是免费的
在 Java 中,传统线程通常映射到操作系统线程。创建一个线程至少要付出:
-
创建和销毁成本:涉及系统调用、栈内存分配、调度器注册。
-
内存成本 :每个线程都有自己的栈,默认可能约 1MB,可通过
-Xss调整。线程一多,内存很快耗尽。 -
上下文切换成本:CPU 在线程之间切换要保存/恢复现场,线程太多,CPU 大量时间花在切换而不是业务上。
-
调度成本:操作系统要管理大量线程,整体吞吐反而下降。
所以,如果代码写成:
while (true) {
new Thread(() -> handleTask()).start();
}
高并发下很可能出现:
-
OutOfMemoryError: unable to create new native thread -
CPU 飙高,但业务处理量没上去
-
系统响应变慢,甚至崩溃
Java 线程池怎么工作?
以 ThreadPoolExecutor 为例,核心参数:
new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程空闲存活时间
unit, // 时间单位
workQueue, // 任务队列
threadFactory, // 线程工厂
handler // 拒绝策略
);
提交任务时大致流程是:
-
运行线程数 <
corePoolSize:创建核心线程执行; -
核心线程满了:任务进队列;
-
队列满了:如果线程数 <
maximumPoolSize,创建非核心线程; -
线程数也到最大:触发拒绝策略。
常见拒绝策略:
-
AbortPolicy:抛异常,默认; -
CallerRunsPolicy:提交任务的线程自己执行,形成背压; -
DiscardPolicy:悄悄丢弃; -
DiscardOldestPolicy:丢掉最老的任务。
注意:如果用的是无界队列,比如 LinkedBlockingQueue 不指定容量,那么 maximumPoolSize 基本失效,任务会一直堆积,最后可能 OOM。
每一个后端项目都要设计线程池吗?
不是每一个后端项目都要"自己设计线程池",但每一个后端项目几乎都在"使用"线程池。区别在于:你是无意识地用,还是有意识地管。
先区分两个概念:使用 vs 设计
很多同学以为"设计线程池"就是自己写:
new ThreadPoolExecutor(...)
其实不是。一个 Spring Boot 后端项目,哪怕你一行线程池代码都没写,也已经有很多线程池在跑:
-
Tomcat / Jetty 处理 HTTP 请求的线程池;
-
Spring
@Async背后的TaskExecutor; -
@Scheduled定时任务的调度线程; -
CompletableFuture.supplyAsync默认用的ForkJoinPool.commonPool(); -
HTTP 客户端、Redis 客户端、MQ 消费者内部的线程池;
-
数据库连接池 HikariCP,虽然不是线程池,但同样是资源池化。
所以问题不是"要不要线程池",而是:
要不要针对自己的业务,显式设计、隔离、配置和监控线程池?
哪些项目不需要自己设计线程池?
如果你的项目符合下面这些特点,通常不需要自己设计:
-
简单 CRUD 后台管理系统,QPS 不高;
-
业务基本是同步处理,请求进来,查库,返回;
-
没有大量异步任务、并行调用、定时批处理;
-
用的是 Spring Boot 默认配置,Tomcat 线程池、连接池够用;
-
项目规模小,团队没有专门的服务治理需求。
这种情况下,你只要知道默认线程池的参数大概是多少,别把任务随便丢进无界队列,基本就够了。
比如 Tomcat 默认最大线程数大约 200,HikariCP 默认最大连接数 10。低并发下,这些默认值通常能撑住。
哪些项目必须显式设计线程池?
如果项目属于下面几类,就不能"随便用默认"了:
-
核心交易、支付、网关、订单链路;
-
有大量异步任务,比如发短信、推消息、写日志、跑批;
-
需要并行调用多个下游,比如同时查用户、商品、库存、优惠;
-
不同任务类型差异大:CPU 密集型、IO 密集型混在一起;
-
需要资源隔离,不能因为一个非核心任务把核心接口拖死;
-
需要限流、背压、拒绝策略、监控告警;
-
对稳定性、SLA 有要求的生产级服务。
这时你必须回答几个问题:
-
这个线程池给谁用?
-
核心线程数、最大线程数多少?
-
队列用有界还是无界?
-
满了之后是抛异常、调用者执行,还是丢弃?
-
线程怎么命名?出问题怎么排查?
-
活跃线程数、队列长度、拒绝次数怎么监控?
-
超时时间怎么设?要不要熔断降级?
不回答这些问题,线程池就可能变成故障源头。
不同的项目在设计线程池时的行为差异大吗,设计线程池时又相同的行为吗?
不同项目设计线程池时,参数和策略差异很大;但设计线程池的底层机制、核心原则和治理行为,高度相同。
换句话说:形不同,神相同。
一、先分清两层:机制相同,策略不同
1. 底层机制相同
只要你用的是 ThreadPoolExecutor,不管什么项目,任务提交后的基本行为都一样:
-
运行线程数 <
corePoolSize:创建核心线程执行; -
核心线程满了:任务进
workQueue; -
队列满了:线程数 <
maximumPoolSize,创建非核心线程; -
线程数也到最大:触发
RejectedExecutionHandler。
这个"核心 → 队列 → 非核心 → 拒绝"的流程,是所有项目共用的骨架。
2. 参数和策略差异很大
但下面这些,每个项目可能完全不同:
-
核心线程数、最大线程数;
-
队列类型和容量;
-
拒绝策略;
-
空闲存活时间;
-
线程命名;
-
监控指标;
-
是否隔离;
-
超时和降级怎么做。
所以:机制是统一的,设计是定制的。
二、不同项目差异为什么大?
因为线程池不是孤立存在的,它服务于业务。业务不同,线程池行为就不同。
1. 任务类型不同
| 任务类型 | 特点 | 线程池设计倾向 |
|---|---|---|
| CPU 密集型 | 计算多、等待少 | 线程数接近 CPU 核数,如 N+1 |
| IO 密集型 | 等 DB、等 HTTP、等 Redis | 线程数可大于核数,但受下游容量限制 |
| 混合型 | 计算和 IO 混合 | 拆分线程池,避免互相影响 |
| 定时任务 | 周期性执行 | 小池,防止任务重叠 |
| 异步通知 | 允许失败、可重试 | 有界队列 + 降级/丢弃策略 |
2. 流量特征不同
-
平稳流量:线程池可以小一点;
-
突发流量:需要队列缓冲,但队列必须有界;
-
秒杀流量:队列可能直接满,需要快速失败;
-
批处理流量:可以慢慢跑,队列可以大一些,但不能无限。
3. 重要性不同
-
核心支付、订单:线程池满了要快速失败,不能拖垮主链路;
-
非核心日志、埋点:可以丢弃,不能影响核心业务;
-
消息推送:可以重试,但要控制并发。
4. 下游依赖不同
线程池大小往往不取决于自己,而取决于下游能承受多少并发。
-
数据库连接池只有 10 个连接,你开 100 个线程去查库,只会把数据库压垮;
-
下游 HTTP 接口限流 50 QPS,你开 200 个线程去调,只会大量超时;
-
Redis 很快,但也不是无限并发。
所以线程池要和连接池、限流、熔断、超时一起设计。
5. 顺序性要求不同
-
Kafka 分区消费:要保证分区内顺序,可能一个分区一个线程;
-
订单状态机:同一订单不能并发处理;
-
普通异步任务:无所谓顺序。
三、不同项目设计线程池时,哪些行为相同?
虽然参数不同,但优秀的设计在原则和治理上高度一致。
1. 目标相同
所有线程池都追求:
-
复用线程,降低创建销毁成本;
-
控制并发,防止资源耗尽;
-
缓冲任务,削峰填谷;
-
隔离故障,避免互相拖累;
-
可观测,可调优,可治理。
2. 参数模型相同
都围绕这六个核心参数:
new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
unit,
workQueue,
threadFactory,
handler
);
不同项目只是给这些参数填不同的值。
3. 设计原则相同
优秀线程池设计,几乎都会遵守这些原则:
-
不用无界队列 :
LinkedBlockingQueue不指定容量,容易 OOM; -
拒绝策略必须明确:不能假装看不见;
-
线程必须命名:出问题能定位;
-
必须监控:活跃线程数、队列长度、拒绝次数、任务耗时;
-
必须隔离:核心业务和非核心业务不共用一个池;
-
必须优雅关闭 :
shutdown()+awaitTermination(); -
必须压测:公式只是起点,真实容量靠压测;
-
任务异常必须处理:否则可能被静默吞掉。
4. 反模式相同
不管什么项目,下面这些做法都是坑:
-
Executors.newFixedThreadPool():无界队列,可能 OOM; -
Executors.newCachedThreadPool():最大线程数接近无限,可能创建过多线程; -
所有任务共用一个巨大线程池:一个非核心任务拖死核心业务;
-
线程池不监控:出事只能靠猜;
-
拒绝策略用默认
AbortPolicy却不处理异常:任务悄悄失败。
四、一个对比表:差异在哪里,相同在哪里
| 维度 | 不同项目差异 | 所有项目相同 |
|---|---|---|
| 核心线程数 | CPU 密集 N+1,IO 密集可能 2N 或更大 |
都要根据任务类型和压测确定 |
| 最大线程数 | 核心链路可能等于核心数,非核心可弹性 | 必须有上限 |
| 队列 | 类型和容量不同 | 推荐有界队列 |
| 拒绝策略 | 核心抛异常,非核心可丢弃/调用者执行 | 必须明确选择 |
| 线程命名 | 前缀不同 | 必须命名 |
| 监控 | 指标细节不同 | 必须监控活跃、队列、拒绝 |
| 隔离 | 按业务/下游划分 | 核心与非核心必须隔离 |
| 关闭 | 关闭顺序不同 | 必须优雅关闭 |
| 底层机制 | 基本一致 | 核心→队列→非核心→拒绝 |
五、设计线程池时,真正相同的"行为"是什么?
可以总结为四句话:
-
机制相同 :
ThreadPoolExecutor的任务调度流程固定; -
原则相同:有界、隔离、命名、监控、拒绝、优雅关闭;
-
方法相同:先分析任务类型和流量,再估算参数,最后压测调优;
-
目标相同:让并发可控、系统稳定、故障可查。
不同的,是业务给出的答案:
-
核心线程数填多少?
-
队列多长?
-
拒绝后怎么办?
-
超时设多少?
-
要不要重试?
六、统一设计模板
不管什么项目,设计线程池时都回答这八个问题:
-
这个池服务什么任务?核心还是非核心?
-
任务是 CPU 密集、IO 密集还是混合?
-
预估 QPS、单任务耗时、允许排队时间是多少?
-
队列用哪种?容量多大?必须有界。
-
核心线程数、最大线程数设多少?压测验证。
-
满了怎么办?拒绝策略是什么?
-
怎么监控?活跃线程、队列长度、拒绝次数、任务耗时。
-
怎么关闭?是否优雅停机?
回答完这八个问题,线程池设计就八九不离十了。
总结
不同项目设计线程池时:
-
差异大:参数、队列、拒绝策略、隔离边界、监控重点都不同;
-
相同多:底层机制、核心原则、治理行为、设计方法高度一致。
所以,不要背一套参数到处用,也不要每个项目从零发明。
你要做的是:用相同的原则,回答不同业务的问题。
一句话:线程池的骨架相同,血肉因业务而异。
在设计线程池时,那些内容需要重点考虑呢?
你要考虑的不是一个点,而是一整条链路。给你拆成八个重点维度,每个维度都要问自己几个问题。
一、任务性质:先搞清楚"干什么活"
这是所有设计的起点。任务类型不同,线程池的脾气完全不同。
1. CPU 密集型
任务大部分时间在计算,比如加解密、图片处理、复杂算法。
-
线程太多 → 上下文切换开销大,反而变慢;
-
线程数建议接近 CPU 核数,常见
N+1; -
队列不宜太长,因为任务本身耗 CPU,排队只会增加延迟。
2. IO 密集型
任务大部分时间在等,比如查数据库、调 HTTP、读 Redis、写文件。
-
线程在等待时不占 CPU,可以适当多开;
-
线程数可以大于核数,常见
2N甚至更多; -
但不能只看自己,要看下游能承受多少并发。
3. 混合型
既有计算又有 IO。
-
最好拆分线程池,避免互相影响;
-
如果无法拆,按瓶颈资源来估算。
4. 定时/周期任务
-
用
ScheduledThreadPoolExecutor; -
核心线程数通常很小,1~3 个就够;
-
要防止任务重叠执行,必要时加分布式锁。
关键问题:
-
这个池主要跑什么任务?
-
瓶颈是 CPU、IO 还是下游?
-
单任务平均耗时、P99 耗时多少?
二、容量估算:核心线程数、最大线程数、队列
这是最核心的部分,但记住:公式只是起点,压测才是答案。
1. 核心线程数
-
常驻线程,即使空闲也不销毁(除非设置
allowCoreThreadTimeOut); -
太小:任务来了要频繁创建线程;
-
太大:浪费资源,空闲线程占内存。
2. 最大线程数
-
队列满后能扩容到的上限;
-
如果队列无界,这个参数基本失效;
-
必须设置上限,防止线程无限增长。
3. 队列
这是最容易被忽视、也最容易出事的地方。
| 队列类型 | 特点 | 适用场景 |
|---|---|---|
ArrayBlockingQueue |
有界,数组实现 | 推荐,容量可控 |
LinkedBlockingQueue |
默认无界,可指定容量 | 必须指定容量 |
SynchronousQueue |
不存储,直接移交 | CachedThreadPool 用,适合短任务 |
PriorityBlockingQueue |
优先级队列,无界 | 有优先级需求,慎用 |
DelayedWorkQueue |
延迟队列 | 定时任务 |
原则:队列必须有界。
无界队列的后果:任务无限堆积 → 内存暴涨 → OOM → 整个服务挂掉。
4. 一个粗略估算思路
假设:
-
目标 QPS = 1000;
-
单任务平均耗时 = 50ms;
-
允许排队时间 = 200ms。
那么需要的并发处理能力大约是:
并发数 ≈ QPS × 单任务耗时 = 1000 × 0.05 = 50
也就是说,大约需要 50 个线程同时工作,才能让任务不排队。
再考虑峰值、下游容量、安全系数,才能定核心和最大线程数。
但注意:这只是数学估算。真实系统里,下游数据库连接池可能只有 20,你开 50 个线程去查库,数据库先挂。
关键问题:
-
核心线程数多少?最大线程数多少?
-
队列用哪种?容量多少?
-
队列满了之后,是扩容线程还是拒绝?
-
下游连接池、限流阈值是多少?线程池不能超过下游容量。
三、拒绝策略:满了怎么办?
这是线程池的"最后一道防线",必须明确。
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy |
抛 RejectedExecutionException |
核心业务,必须感知失败 |
CallerRunsPolicy |
提交任务的线程自己执行 | 形成背压,适合非核心但不想丢 |
DiscardPolicy |
静默丢弃 | 允许丢失的非核心任务,如埋点 |
DiscardOldestPolicy |
丢弃最老任务 | 只保留最新任务,如实时行情 |
自定义拒绝策略也常见,比如:
-
记录日志 + 监控告警;
-
写入重试队列;
-
降级到本地缓存;
-
返回兜底结果。
关键问题:
-
拒绝后业务能接受吗?
-
是抛异常、丢弃、还是调用者执行?
-
拒绝次数怎么监控?达到阈值怎么告警?
四、线程工厂:线程怎么创建?
别小看这个,出问题时它决定你能不能快速定位。
ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger index = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("order-async-" + index.getAndIncrement());
t.setDaemon(false);
t.setUncaughtExceptionHandler((thread, ex) -> {
log.error("线程 {} 发生未捕获异常", thread.getName(), ex);
});
return t;
}
};
重点:
-
线程命名 :必须带业务前缀,如
order-async-、sms-send-; -
异常处理器:避免异常被静默吞掉;
-
是否守护线程:一般用非守护线程,配合优雅关闭;
-
线程优先级:一般不改,默认即可。
关键问题:
-
线程名字能不能一眼看出属于哪个业务?
-
出了异常能不能被记录?
-
线程池关闭时,线程能不能正确退出?
五、隔离:别让一个池拖垮所有业务
这是生产环境最重要的设计之一。
1. 按业务隔离
-
订单一个池;
-
支付一个池;
-
短信一个池;
-
日志一个池。
2. 按重要性隔离
-
核心链路一个池,快速失败;
-
非核心链路一个池,允许降级。
3. 按下游隔离
-
调用户服务一个池;
-
调商品服务一个池;
-
调库存服务一个池。
为什么?因为如果共用一个池,某个下游变慢,线程全部阻塞,其他下游也调不了,形成雪崩。
关键问题:
-
哪些任务可以共用?哪些必须隔离?
-
核心和非核心是否分开?
-
不同下游是否分开?
六、监控与可观测性:出问题能查到
线程池不是配完就不管了,它需要持续监控。
必须监控的指标:
-
活跃线程数 :
getActiveCount(); -
当前线程数 :
getPoolSize(); -
队列长度 :
getQueue().size(); -
队列剩余容量;
-
完成任务数 :
getCompletedTaskCount(); -
拒绝次数:自定义拒绝策略里计数;
-
任务耗时:平均、P95、P99;
-
线程池饱和度:活跃线程 / 最大线程。
告警建议:
-
队列长度持续超过阈值 → 告警;
-
拒绝次数 > 0 → 告警;
-
活跃线程数长期接近最大线程数 → 告警;
-
任务耗时 P99 明显上升 → 告警。
关键问题:
-
这些指标怎么采集?Micrometer?Prometheus?
-
告警阈值多少?
-
出问题后怎么快速定位是哪个池?
七、生命周期:怎么创建、怎么关闭
1. 创建
-
优先用
ThreadPoolExecutor手动创建,不用Executors; -
参数来自配置中心,便于动态调整;
-
可以结合
ThreadPoolTaskExecutor(Spring)使用。
2. 关闭
优雅关闭三步:
java
executor.shutdown(); // 不再接受新任务
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 取消未执行任务,中断正在执行的任务
}
-
shutdown():平缓关闭,已提交任务继续执行; -
shutdownNow():尝试中断,返回未执行任务列表; -
awaitTermination():等待一段时间。
还要注意:
-
Spring 容器关闭时,线程池要跟着关闭;
-
如果线程池是静态的,要注册 JVM 钩子;
-
关闭顺序:先停上游流量,再关线程池,最后关连接池。
关键问题:
-
服务下线时,线程池里的任务怎么办?
-
能不能保证不丢任务?
-
关闭超时时间设多少?
八、上下文传递:ThreadLocal、MDC、TraceId
线程池复用线程,会带来一个隐蔽问题:上下文污染和丢失。
1. ThreadLocal
-
任务提交时,父线程的 ThreadLocal 不会自动传到工作线程;
-
需要手动传递,或者用
InheritableThreadLocal(但线程池场景下也不可靠); -
任务执行完必须
remove(),否则线程复用时数据串了,甚至内存泄漏。
2. MDC(日志上下文)
-
日志里的
traceId、userId在线程池中会丢; -
需要在任务提交时捕获,在执行时恢复,执行后清理。
3. 解决方案
-
包装
Runnable,在run()里做上下文传递和清理; -
用阿里
TransmittableThreadLocal(TTL); -
用 Spring 的
TaskDecorator。
关键问题:
-
日志能不能串起完整链路?
-
ThreadLocal 用完有没有清理?
-
异步任务里能不能拿到用户上下文?
九、一张设计检查清单
设计线程池时,逐条回答:
-
这个池服务什么业务?核心还是非核心?
-
任务是 CPU 密集、IO 密集还是混合?
-
QPS、单任务耗时、P99 是多少?
-
核心线程数、最大线程数设多少?依据是什么?
-
队列用哪种?容量多少?为什么有界?
-
拒绝策略是什么?拒绝后业务怎么办?
-
线程怎么命名?异常怎么处理?
-
和哪些业务隔离?为什么?
-
监控哪些指标?告警阈值多少?
-
怎么优雅关闭?关闭超时多少?
-
ThreadLocal、MDC、TraceId 怎么传递和清理?
-
压测过吗?瓶颈在哪里?
总结
设计线程池,重点考虑八个方面:
-
任务性质:CPU、IO、混合、定时;
-
容量估算:核心数、最大数、有界队列;
-
拒绝策略:满了怎么办;
-
线程工厂:命名、异常、守护属性;
-
隔离:按业务、重要性、下游拆分;
-
监控:活跃、队列、拒绝、耗时;
-
生命周期:创建、优雅关闭;
-
上下文:ThreadLocal、MDC、TraceId。
一句话:
线程池设计不是填七个参数,而是回答一整套关于任务、容量、故障、隔离、监控和生命周期的工程问题。
参数只是结果,思考过程才是关键。