线程池到底怎么调?从源码看懂 ThreadPoolExecutor 的 7 个参数
面试问线程池,大多数人能背出 7 个参数,但一问你"核心线程 10、最大线程 20、队列 100,来 130 个任务会怎样?"就卡壳了。
更扎心的是生产事故:某系统高峰期线程数飙到 2000,内存被打爆;另一个系统线程池全部排队,接口超时一片。本质都是没搞懂线程池的调度模型。
这篇从源码出发,把 ThreadPoolExecutor 的 7 个参数和任务调度链路彻底讲清楚。
一、线程池状态机:两个核心字段
先看 ThreadPoolExecutor 最核心的 ctl 字段,一个 int 存了两样东西:
java
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 高 3 位:线程池状态(runState)
// 低 29 位:线程数量(workerCount)
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
private static int runStateOf(int c) { return c & ~COUNT_MASK; } // 取状态
private static int workerCountOf(int c) { return c & COUNT_MASK; } // 取线程数
状态枚举:
| 状态 | 含义 | 能否接收新任务 | 能否处理队列任务 |
|---|---|---|---|
| RUNNING | 运行中 | ✅ | ✅ |
| SHUTDOWN | 关闭(不再接新) | ❌ | ✅ |
| STOP | 停止 | ❌ | ❌(中断) |
| TIDYING | 收尾 | ❌ | ❌ |
| TERMINATED | 终止 | ❌ | ❌ |
生命周期:RUNNING → SHUTDOWN/STOP → TIDYING → TERMINATED。
二、7 个参数,一次讲透
java
new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程空闲存活时间
unit, // 存活时间单位
workQueue, // 任务队列
threadFactory, // 线程工厂
handler // 拒绝策略
);
| 参数 | 作用 | 易错点 |
|---|---|---|
| corePoolSize | 常驻线程数(默认不回收) | 核心线程也可能被回收(allowCoreThreadTimeOut) |
| maximumPoolSize | 最多能开的线程数 | 必须 ≥ corePoolSize |
| keepAliveTime | 非核心线程空闲多久回收 | 回收的是超出 core 的线程 |
| workQueue | 排队队列 | 选错队列直接影响行为 |
| threadFactory | 线程命名/守护 | 不设置全叫 pool-N-thread-M,排查时懵 |
| handler | 拒绝策略 | 默认 AbortPolicy 直接抛异常 |
| allowCoreThreadTimeOut | (可选)核心线程也超时回收 | 少见,但能降空闲成本 |
三、任务来了怎么走?核心调度流程
用一句话概括:核心不满加核心,队列不满进队列,满了开新线程到最大,再满就拒。
arduino
提交任务
│
┌───────────▼────────────┐
│ workerCount < coreSize │ ──是──→ 新建核心线程执行
└───────────┬────────────┘
│ 否
┌───────────▼────────────┐
│ 尝试入队 workQueue │ ──成功→ 排队等待(后面讲)
└───────────┬────────────┘
│ 队列满
┌───────────▼────────────┐
│ workerCount < max? │ ──是──→ 新建非核心线程执行
└───────────┬────────────┘
│ 否
┌───────────▼────────────┐
│ 执行拒绝策略 handler │
└────────────────────────┘
关键源码:execute 方法
java
public void execute(Runnable command) {
int c = ctl.get();
// 第一步:线程数 < 核心线程数 → 直接新建
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) return;
c = ctl.get();
}
// 第二步:RUNNING 状态下入队
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 双重检查:线程池若被关闭,回滚并拒绝
if (!isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 第三步:队列满了 → 尝试开新线程到 maximum
else if (!addWorker(command, false))
// 第四步:加不进去(超过 maximum)→ 拒绝
reject(command);
}
这个顺序非常重要 :核心→队列→最大线程→拒绝。很多人以为"线程不够了就直接开新线程",实际上队列优先于新线程。
四、队列的 4 种选择(直接决定线程池行为)
| 队列 | 特点 | 典型效果 |
|---|---|---|
| ArrayBlockingQueue(有界) | 容量固定 | 满才开新线程,可控 |
| LinkedBlockingQueue(无界/有界) | 默认无界 | 开不到非核心线程,任务全排队 |
| SynchronousQueue | 容量 0,直接交接 | 无队可言,来一个开一个 |
| PriorityBlockingQueue | 带优先级 | 优先级高的先执行 |
经典坑 :Executors.newFixedThreadPool 用的是无界队列 LinkedBlockingQueue。队列无限大 → maximumPoolSize 永远用不上 → 任务无限堆积 → 内存 OOM。这就是生产上禁用 Executors 默认工厂的根源。
五、线程池吃饱了:拒绝策略怎么选
| 策略 | 行为 | 适用 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 有兜底且想快速失败 |
| CallerRunsPolicy | 提交任务线程自己跑 | 降速+不丢任务,适合能接受慢 |
| DiscardPolicy | 静默丢弃 | 可丢的任务(日志类) |
| DiscardOldestPolicy | 丢弃队头最旧任务 | 追求最新数据的场景 |
生产建议 :CallerRunsPolicy 或 AbortPolicy + 降级,别裸 Discard(丢了没感知)。
六、真实调优:一个订单系统的压测过程
假设接口:单次查询约 80ms,QPS 目标 300,高峰期约 1200,需要能扛 3 倍峰值的短暂积压。
方案推导:
ini
目标 QPS = 300,单任务 80ms
单线程吞吐 = 1000 / 80 ≈ 12.5 req/s
所需线程 ≈ 300 / 12.5 = 24 个(理论)
core = 24 // 日常够用
maximum = 60 // 峰值可以扩张到 60
queue = 1000 // 允许短暂积压 1000 个任务
keepAlive = 60s // 高峰过后 60 秒回收多余线程
注意:这只是"计算起点",必须用压测实测,观察 TP99、队列积压、线程数曲线再迭代。
七、线程池监控:4 个必须看的指标
java
ThreadPoolExecutor pool = ...;
int active = pool.getActiveCount(); // 正在跑
int queueSize = pool.getQueue().size(); // 排队
long taskCount = pool.getTaskCount(); // 累计任务
long completed = pool.getCompletedTaskCount(); // 已完成
监控重点:
queue.size()长期 > 0 → 线程不够 or 任务过多;active长期接近 maximum → 考虑扩容;- 拒绝计数持续上升 → 马上告警。
建议配 ThreadFactory 命名:order-pool-%d,方便线程 dump 时定位。
八、总结
- 调度顺序:核心线程 → 队列 → 最大线程 → 拒绝。先队列后扩线程。
- 7 个参数:核心/最大/存活/队列/工厂/拒绝/时间单位,个个有坑。
- 队列决定行为:无界队列 = 线程永远不扩 + OOM 风险。
- 拒绝策略:默认 Abort 抛异常,生产用 CallerRuns 或自定义降级。
- 调优靠压测:公式只是起点,监控才是终点。
下一篇:ReentrantLock 与 AQS 源码拆解,看看"锁"到底是怎么排队的。