这篇不讲“线程池是什么”(你在《线程池》里已经讲得很完整了),只讲两件事:

  1. 怎么配core/max/queue/keepAlive/reject 如何一起工作;
  2. 怎么救:线程池饱和、延迟飙升、队列堆积、拒绝风暴时怎么定位与止血。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int core = 32;
int max = 64;
int queueCapacity = 1000;

BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(queueCapacity);

ThreadFactory tf = r -> {
Thread t = new Thread(r);
t.setName("biz-pool-" + t.getId());
t.setDaemon(false);
t.setUncaughtExceptionHandler((th, ex) -> {
// 记录日志/报警(注意:线程池内部吞异常的场景要单独处理)
});
return t;
};

RejectedExecutionHandler reject = new ThreadPoolExecutor.CallerRunsPolicy();

ThreadPoolExecutor pool = new ThreadPoolExecutor(
core,
max,
60L,
TimeUnit.SECONDS,
queue,
tf,
reject
);

// 可选:允许核心线程超时(更适合波动明显的流量)
// pool.allowCoreThreadTimeOut(true);

4.2 为什么常用 CallerRunsPolicy

  • 好处:天然背压——把压力“推回调用方”,提交速度会下降。
  • 风险:
    • 如果调用方是 Netty IO 线程/Servlet 关键线程,会造成 上游卡顿扩大
    • 如果调用方持锁,会放大锁竞争。

因此 CallerRunsPolicy 更适合:

  • 调用方线程可被“拖慢”(例如业务线程/异步线程),且你愿意用它换系统稳定性。

5. 四种默认拒绝策略怎么选(务实版)

  • AbortPolicy

    • 适合:你希望快速失败,交给上层降级/重试/返回错误码。
    • 风险:异常风暴(日志刷爆、调用链路重试雪崩)。
  • CallerRunsPolicy

    • 适合:需要背压,且调用方可被拖慢。
  • DiscardPolicy

    • 适合:允许丢弃(例如埋点、非关键异步任务)。
  • DiscardOldestPolicy

    • 适合:更在意“新任务”,老任务过时无价值。
    • 风险:很难解释与排障(为什么丢的是谁)。

工程上常见更稳的做法:

  • 自定义 handler:记录关键指标(拒绝数/队列长度/调用方信息),并做分级降级。

6. 线程池饱和的排障 checklist(线上救火)

当你看到延迟飙升/超时/拒绝异常时,优先按下面顺序判断(比盲调参数更快):

6.1 看 5 个指标

  • activeCount:活跃线程数是否接近 max
  • poolSize:当前线程数是否在增长/是否已到 max
  • queue.size():是否持续增长(堆积)
  • completedTaskCount 增长速度:吞吐是否下降
  • rejectCount(需要你埋点/包装 handler):是否发生拒绝风暴

6.2 定位“为什么任务变慢”

  • 任务自身变慢:外部依赖慢(DB/RPC)、锁竞争、GC、热点循环
  • 任务被阻塞:
    • 在池内执行了阻塞 IO
    • 在池内调用 Future.get() 等待另一个也在同池执行的任务(典型死锁模式)

6.3 立刻止血的手段(按风险从低到高)

  • 打开降级开关/限流(减少进入线程池的请求)
  • 降低重试并增加抖动(jitter)
  • 临时扩容(提升 max 或增加实例)——但要防止线程爆炸

7. 优雅关闭(shutdown)写对

目标:

  • 不接新任务
  • 尽量处理完队列里已提交任务
  • 最终释放资源

模板:

1
2
3
4
5
6
7
8
9
10
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> dropped = pool.shutdownNow();
// 记录 dropped,避免“任务悄悄消失”
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
pool.shutdownNow();
}

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 在池化线程里会导致什么问题?如何清理与兜底?