线程池调参(ThreadPoolExecutor)——参数选型、背压与排障
这篇不讲“线程池是什么”(你在《线程池》里已经讲得很完整了),只讲两件事:
- 怎么配:
core/max/queue/keepAlive/reject如何一起工作; - 怎么救:线程池饱和、延迟飙升、队列堆积、拒绝风暴时怎么定位与止血。
1. 一个核心观念:线程池=吞吐控制器(不是“越大越快”)
线程池的本质是把请求流量“整形”:
- 线程数决定“并发执行能力”(也决定 CPU 上下文切换成本)。
- 队列决定“缓冲能力”(也决定延迟与内存占用)。
- 拒绝策略决定“系统过载时怎么降级/背压”。
工程上最常见的事故组合:
- 无界队列 + 任务变慢 → 队列无限增长 → 内存被打爆。
- 最大线程数过大 + IO 阻塞 → 创建海量线程 → 上下文切换把 CPU 打满。
- 不做背压 + 失败重试风暴 → 雪崩。
2. 线程数怎么估(CPU-bound vs IO-bound)
2.1 CPU 密集(CPU-bound)
特点:任务主要做计算,很少阻塞。
- 建议线程数:接近
CPU 核心数(或核心数 + 少量) - 目标:减少上下文切换,让每个核心尽量一直跑。
2.2 IO 密集(IO-bound)
特点:任务大量等待(RPC/DB/磁盘/锁/队列)。
- 线程数可以大于核心数,用“更多线程”覆盖等待时间
- 但上限取决于:外部依赖承载能力 + 内存 + 线程切换成本
一个常用的估算思路(只作为方向,不要当定律):
- 设
W/C = 等待时间 / 计算时间 - 线程数
N ≈ 核心数 * (1 + W/C)
实际落地时更稳妥的做法:
- 先用较小线程数上线,配合监控(任务耗时分布、队列长度、拒绝数)逐步调。
3. 队列怎么选(决定延迟/内存/弹性)
3.1 有界队列(推荐默认)
ArrayBlockingQueue:数组 + 单锁,延迟更可控;适合需要严格有界。LinkedBlockingQueue(cap):链表 + 两把锁(put/take),吞吐通常好一些。
原则:
- 线上服务优先 有界:把风险从“内存崩溃”变成“可观察的拒绝/降级”。
3.2 SynchronousQueue(零容量,直接移交)
- 没有缓冲:任务要么被空闲线程接走,要么创建新线程(直到 max),否则拒绝。
- 适合:任务很短、希望用“线程扩张”应对突发,而不是排队延迟。
- 风险:max 配太大容易线程爆炸。
3.3 DelayQueue / PriorityBlockingQueue
- 用在定时/优先级场景。
- 注意:PriorityBlockingQueue 默认无界,仍要关注内存风险。
4. 参数如何联动(推荐的“起步模板”)
4.1 建议的起步组合
corePoolSize:能覆盖日常稳定流量maximumPoolSize:覆盖短时突发(不要过大)workQueue:有界RejectedExecutionHandler:明确背压/降级策略ThreadFactory:线程命名 + UncaughtExceptionHandler
示例:
1 | int core = 32; |
4.2 为什么常用 CallerRunsPolicy
- 好处:天然背压——把压力“推回调用方”,提交速度会下降。
- 风险:
- 如果调用方是 Netty IO 线程/Servlet 关键线程,会造成 上游卡顿扩大。
- 如果调用方持锁,会放大锁竞争。
因此 CallerRunsPolicy 更适合:
- 调用方线程可被“拖慢”(例如业务线程/异步线程),且你愿意用它换系统稳定性。
5. 四种默认拒绝策略怎么选(务实版)
AbortPolicy:- 适合:你希望快速失败,交给上层降级/重试/返回错误码。
- 风险:异常风暴(日志刷爆、调用链路重试雪崩)。
CallerRunsPolicy:- 适合:需要背压,且调用方可被拖慢。
DiscardPolicy:- 适合:允许丢弃(例如埋点、非关键异步任务)。
DiscardOldestPolicy:- 适合:更在意“新任务”,老任务过时无价值。
- 风险:很难解释与排障(为什么丢的是谁)。
工程上常见更稳的做法:
- 自定义 handler:记录关键指标(拒绝数/队列长度/调用方信息),并做分级降级。
6. 线程池饱和的排障 checklist(线上救火)
当你看到延迟飙升/超时/拒绝异常时,优先按下面顺序判断(比盲调参数更快):
6.1 看 5 个指标
activeCount:活跃线程数是否接近 maxpoolSize:当前线程数是否在增长/是否已到 maxqueue.size():是否持续增长(堆积)completedTaskCount增长速度:吞吐是否下降rejectCount(需要你埋点/包装 handler):是否发生拒绝风暴
6.2 定位“为什么任务变慢”
- 任务自身变慢:外部依赖慢(DB/RPC)、锁竞争、GC、热点循环
- 任务被阻塞:
- 在池内执行了阻塞 IO
- 在池内调用
Future.get()等待另一个也在同池执行的任务(典型死锁模式)
6.3 立刻止血的手段(按风险从低到高)
- 打开降级开关/限流(减少进入线程池的请求)
- 降低重试并增加抖动(jitter)
- 临时扩容(提升 max 或增加实例)——但要防止线程爆炸
7. 优雅关闭(shutdown)写对
目标:
- 不接新任务
- 尽量处理完队列里已提交任务
- 最终释放资源
模板:
1 | pool.shutdown(); |
8. 高危坑:ThreadLocal 在线程池中的“脏数据/泄漏”
线程池会复用线程:
- 如果你用 ThreadLocal 存用户信息/MDC/租户ID,一定要在 finally 清理。
- 否则会出现:A 请求的数据泄漏到 B 请求、甚至内存泄漏。
9. 总结:最推荐的默认策略
- 生产默认优先:有界队列 + 明确拒绝策略 + 指标可观测。
- 调参不是拍脑袋:先把 任务耗时分布、队列堆积、拒绝率 监控齐,再迭代参数。
下一篇《CompletableFuture 工程实践》会重点讲:
- 为什么默认 commonPool 容易踩坑
- 如何给异步链路配置“专用线程池 + 超时 + 异常治理 + 上下文传播”
面试题 / Checklist
- CPU-bound 与 IO-bound 线程数估算思路分别是什么?各自的上限受什么约束?
- 有界队列 vs 无界队列的风险差异是什么?为什么线上更推荐有界?
CallerRunsPolicy什么时候是救命,什么时候会扩大事故?- 如何判断线程池“变慢”的根因:任务变慢 vs 队列堆积 vs 同池互等?
- 线上排障最先看哪些指标(active/poolSize/queue/reject/吞吐)?
- ThreadLocal 在池化线程里会导致什么问题?如何清理与兜底?
