ThreadPoolExecutor 的构造器把七个参数一次性塞给你;真正决定它行为的只有两个:队列有没有界、拒绝策略是什么。绝大多数线上事故不是参数选错,而是选了一个让某些参数彻底失效的组合——最典型的就是 Executors.newFixedThreadPool:看似"固定大小",实际上把 maximumPoolSize 和 handler 同时废掉了。
1. 七个参数:谁牵制谁
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
| 参数 | 语义 | 被谁牵制 |
|---|---|---|
corePoolSize |
常驻线程数下限 | 被 allowCoreThreadTimeOut(true) 打破 |
maximumPoolSize |
线程数上限 | 队列有界才可能生效 |
keepAliveTime |
超出核心数的空闲线程存活 | 只作用于 > corePoolSize 的线程 |
workQueue |
排队缓冲 | 容量决定 maximumPoolSize 是否可达 |
threadFactory |
线程创建方式 | 线程名、优先级、是否守护 |
handler |
无法接收任务时的动作 | 队列满且线程满才触发 |
一句话概括耦合关系:扩容能力是队列满之后才被解锁的权限。硬约束:maximumPoolSize >= corePoolSize、keepAliveTime >= 0,否则构造器抛 IllegalArgumentException。
2. 执行顺序:四步,顺序不能记错
// 骨架,原文见 JDK 17 ThreadPoolExecutor#execute
if (workerCountOf(c) < corePoolSize)
if (addWorker(command, true)) return; // ① 核心未满 → 建核心线程
if (isRunning(c) && workQueue.offer(command)) { // ② 核心满 → 入队
if (!isRunning(recheck) && remove(command)) reject(command); // 入队后复查防丢
} else if (!addWorker(command, false)) // ③ 队列满 → 扩到最大
reject(command); // ④ 最大也满 → 拒绝
| 阶段 | 判断条件 | 动作 | 常见误解 |
|---|---|---|---|
| ① | 线程数 < corePoolSize |
新建线程直接跑 | 以为先填队列 |
| ② | 核心已满 | queue.offer() |
以为队列只是缓冲 |
| ③ | 队列满 | 扩到 maximumPoolSize |
无界队列时永不发生 |
| ④ | 扩容失败 | 调 handler |
以为会一直等着 |
2.1 "无界队列让 maximumPoolSize 失效"
LinkedBlockingQueue 的默认容量是 Integer.MAX_VALUE,newFixedThreadPool 与 newSingleThreadExecutor 都用它。于是第②步 offer 永远成功、第③步永不执行——maximumPoolSize 写多少都没用,handler 也永不触发。
后果不是"线程少一点",而是任务无界堆积到 OOM——每个排队任务都持有一份业务对象引用。
// 危险:看起来固定 8 线程,实际最大也是 8,队列可无限增长
ExecutorService bad = Executors.newFixedThreadPool(8);
// 安全:显式有界队列 + 命名线程 + 明确的拒绝行为
ThreadPoolExecutor good = new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
r -> new Thread(r, "order-worker-" + SEQ.incrementAndGet()),
new ThreadPoolExecutor.AbortPolicy());
2.2 队列选型
| 队列 | 容量 | 交接语义 | 适用 |
|---|---|---|---|
ArrayBlockingQueue |
构造必须指定 | 有界 FIFO、单锁 | 通用首选,内存可预测 |
LinkedBlockingQueue |
默认 Integer.MAX_VALUE |
双锁(入/出分离) | 必须显式传容量 |
SynchronousQueue |
0 | 直接交接,不排队 | 配合大 maximumPoolSize 跑短任务 |
PriorityBlockingQueue |
无界 | 按优先级出队 | 有优先级且量可控 |
DelayQueue |
无界 | 到期才可取 | 定时重试、延迟任务 |
SynchronousQueue 必须配合足够大的 maximumPoolSize,否则拒绝率高得离谱。newCachedThreadPool(core=0、max=Integer.MAX_VALUE)就这么组合,代价是线程数可能失控。
3. 拒绝策略:四种内置 + 一种自定义
| 策略 | 行为 | 抛出 | 适用 |
|---|---|---|---|
AbortPolicy(默认) |
抛 RejectedExecutionException |
是 | 调用方需感知失败 |
CallerRunsPolicy |
提交任务的线程自己跑 | 否 | 天然背压,会阻塞调用方 |
DiscardPolicy |
静默丢弃 | 否 | 等于吞数据 |
DiscardOldestPolicy |
丢最老任务后重试 | 否 | 只保留最新值 |
自定义拒绝策略至少要回答四个问题,缺一个就是埋坑:记录什么、怎么降级、抛还是返、连续拒绝要不要熔断上游。
class MeteredRejectPolicy implements RejectedExecutionHandler {
private final MeterRegistry meter;
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
meter.counter("pool.rejected").increment(); // 1) 记录
log.warn("reject task={} active={} queue={} max={}", // 2) 打点留证
r.getClass().getSimpleName(), e.getActiveCount(),
e.getQueue().size(), e.getMaximumPoolSize());
throw new RejectedExecutionException("order-worker saturated"); // 3) 反馈
}
}
CallerRunsPolicy 有个隐蔽行为:池已 shutdown 时它不执行任务,直接静默丢弃,停机窗口内的任务会无声消失。
4. 容量估算:唯一的关键输入是等待占比
两条等价公式,前者来自 Little's Law,后者是它的工程化变形:
线程数 ≈ 目标 QPS × 单任务耗时(秒)
线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)
"等待占比"是唯一需要实测的输入,其余都是算术:
| 任务类型 | 等待占比 | 取值 | 说明 |
|---|---|---|---|
| 纯计算(加解密、序列化) | 接近 0 | ≈ 核数 | 再多只增切换开销 |
| 常规 DB 查询 | 中 | 核数 × 2 ~ 4 | 按 JDK 17 核对 + 压测 |
| 远程调用为主 | 高 | 核数 × 8 以上 | 先确认下游能承受 |
| 混合 | — | 分池 | 拆成计算池与 IO 池 |
int cpu = Runtime.getRuntime().availableProcessors();
double waitRatio = 0.9; // 唯一实测输入
int ioBound = (int) Math.ceil(cpu / (1 - waitRatio)); // 8 核 → 80
两个必须写进文档的约束:线程数受下游容量约束(池加到 200 而 DB 连接池只有 20,多出的线程只是在等连接);队列容量是内存预算(容量 × 单任务持有对象大小 即最坏情况堆占用,必须算出来)。
5. 动态调整与上下文传递
5.1 setCorePoolSize 的运行时行为
setCorePoolSize(n) 运行时做两件事:调大时补建线程,调小时中断多余 idle 线程。它不改已在线程的存活语义,也不清空队列。
| 操作 | 运行时效果 | 边界 |
|---|---|---|
setCorePoolSize |
立即补建/回收 | 不得大于 maximumPoolSize |
setMaximumPoolSize |
只影响后续扩容 | 不得小于 corePoolSize |
setKeepAliveTime |
对 idle 线程生效 | 需下一次超时判断 |
setRejectedExecutionHandler |
立即替换 | 无状态策略可热换 |
配置中心热更新的姿势是只调数值、不动结构:队列类型与容量属于结构,改它必须换池;核心/最大/存活时间属于数值,可热改。同时要校验区间,避免配置推错把池推到 0。
5.2 ThreadLocal/MDC 丢失
线程复用意味着 ThreadLocal 的值会串到下一个任务,新任务也可能读不到提交方的上下文(traceId、用户身份)。解法是装饰器:提交时快照 MDC.getCopyOfContextMap(),任务内还原,finally 里恢复或 clear();ThreadLocal 同理,快照、还原、remove() 三步缺一不可。
5.3 关闭:shutdown 与 shutdownNow
| 方法 | 新任务 | 队列中任务 | 运行中任务 |
|---|---|---|---|
shutdown() |
拒绝 | 继续执行完 | 不中断 |
shutdownNow() |
拒绝 | 作为返回值取出 | 尝试 interrupt |
awaitTermination(t) |
拒绝 | 等待 | 等待 |
优雅停机是两段式:
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> dropped = pool.shutdownNow(); // 返回未执行的任务,必须记录
log.warn("force shutdown, dropped={}", dropped.size());
pool.awaitTermination(5, TimeUnit.SECONDS);
}
shutdownNow 的 interrupt 只对响应中断的任务有效。任务里 while(true) 不检查中断标志,awaitTermination 就永不返回——这是任务设计问题,不是线程池问题。
6. 监控与四个高频坑
四个必看指标都来自 ThreadPoolExecutor 自身:
| 指标 | 取值方式 | 判读 |
|---|---|---|
| 活跃线程 | getActiveCount() |
长期顶到 max → 饱和 |
| 队列深度 | getQueue().size() |
单调上升 → 消费不足 |
| 拒绝次数 | 拒绝策略内计数 | 非零即告警 |
| 任务耗时 | 装饰器埋点 p99 | 分任务是变慢还是并发不够 |
Micrometer 的 ExecutorServiceMetrics 可直接把池大小、活跃数、队列长度、完成数绑到 registry,不必自己造轮子。
坑清单:
| 坑 | 表现 | 根因 | 修法 |
|---|---|---|---|
| 无界队列 | 堆缓慢上涨直至 OOM | LinkedBlockingQueue 默认容量 |
显式传容量 |
submit 吞异常 |
任务失败但日志干净 | 异常包进 Future 无人 get() |
改用 execute 或统一 get |
| 池内阻塞重试 | 线程全卡在重试 | 重试占着工作线程 | 交给调度器或 MQ |
| 多业务共用一个池 | 慢业务拖垮快业务 | 无隔离 | 按业务分池 + 各自拒绝策略 |
submit 吞异常值得单说:execute 的任务抛异常会经 afterExecute 打到 UncaughtExceptionHandler;submit 包成 FutureTask 后异常存进 Future,既不打日志也不抛出。要么取结果,要么重写 afterExecute 兜底。
7. 边界:这些情况该换方案
| 场景 | 首选 | 理由 |
|---|---|---|
| 任务有父子依赖 | ForkJoinPool / 结构化并发 |
普通池会死锁 |
| 大量短延迟任务 | SynchronousQueue + 大 max |
排队无意义 |
| 需要背压 | CallerRunsPolicy 或信号量 |
拒绝要能传导 |
| 定时/延迟 | ScheduledThreadPoolExecutor |
普通池无调度语义 |
| 停机不能丢任务 | 先落库再提交 | 内存队列不持久 |
线程池不是"并发开关",而是资源配额 + 排队策略 + 失败语义的组合。调参之前先把这三件事写成文档,参数自然有答案。
8. 自测
newFixedThreadPool(8)里把maximumPoolSize改成 100,为什么不生效?- 执行顺序的四步中,哪一步会触发拒绝策略?它的前置条件是什么?
CallerRunsPolicy在池已shutdown时对任务做了什么?- 容量估算里唯一需要实测的输入是什么?为什么不能直接抄别人的线程数?
shutdown()与shutdownNow()对队列中任务的处理差异在哪?submit提交的任务抛出异常后,为什么日志里可能什么都没有?