线程池的核心参数有哪些?任务提交后的执行流程是怎样的?
简化版
ThreadPoolExecutor 有 7 个参数,核心是:核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略。提交任务时按固定顺序处理:核心线程 → 排队进队列 → 开非核心线程 → 触发拒绝策略。
详细版
七个参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime + unit(非核心线程空闲存活时间)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
提交一个任务的执行流程:
- 当前线程数 < 核心数 → 直接新建核心线程执行(哪怕有空闲线程也先建,直到核心数满);
- 核心线程已满 → 任务进工作队列排队;
- 队列也满了,且线程数 < 最大数 → 新建非核心线程执行;
- 线程数已达最大数且队列满 → 触发拒绝策略。
四种内置拒绝策略:
AbortPolicy(默认):抛RejectedExecutionException;CallerRunsPolicy:让提交任务的线程自己执行该任务(起到降速/背压作用);DiscardPolicy:默默丢弃新任务;DiscardOldestPolicy:丢掉队列里最老的任务,再尝试提交当前任务。
完整版教学
一、这个执行顺序为什么反直觉
最容易记错的是第 2、3 步的顺序——很多人以为是「核心满了就开非核心线程」,其实是「核心满了先排队,队列满了才开非核心线程」。
为什么这么设计?因为创建线程是昂贵的(要分配栈内存、涉及系统调用)。线程池的哲学是:能复用就复用,能排队就排队,实在扛不住了才多开线程。所以队列是「新开线程」前的缓冲带。
理解这个顺序,才能解释下面那个最经典的坑。
二、无界队列会让 maximumPoolSize 失效
Executors.newFixedThreadPool() 使用容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue。在正常资源边界内,它几乎不会被填满,因此任务通常只会排队,线程数不会超过核心线程数,maximumPoolSize 与拒绝策略也很难发挥保护作用。
如果提交速度长期高于处理速度,队列会持续增长,延迟和内存占用随之上升,最终可能触发 OOM。因此生产系统通常显式创建 ThreadPoolExecutor,让容量、拒绝策略和资源预算都可控:
new ThreadPoolExecutor(
8, 16, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 有界队列,才能触发扩容和拒绝
new ThreadPoolExecutor.CallerRunsPolicy() // 明确的拒绝策略
);
Executors.newCachedThreadPool() 是另一个极端:它用 SynchronousQueue(不存任务)+ 最大线程数 Integer.MAX_VALUE,任务来了就开线程,可能瞬间创建海量线程把机器压垮。
三、参数怎么定:CPU 密集 vs IO 密集
线程数不是拍脑袋,要看任务类型:
- CPU 密集型(加密、计算):线程多了只会增加上下文切换开销,核心数设为 CPU 核数 + 1 左右。
- IO 密集型(网络、数据库、文件):线程大部分时间在等 IO、不占 CPU,可以开更多,经验公式约 CPU 核数 × (1 + 平均等待时间/计算时间),实际常设到核数的几倍。
记忆点:CPU 密集「宁少勿多」,IO 密集「可以多开」。但真实场景要靠压测调,公式只是起点。
四、拒绝策略怎么选
- 任务不能丢、需要感知失败 →
AbortPolicy(抛异常,让上层处理); - 想自动降速、别丢任务 →
CallerRunsPolicy(提交者自己跑,天然背压); - 任务可丢弃(如实时性要求高的监控上报)→
Discard系列; - 需要自定义(落库、告警、降级)→ 实现
RejectedExecutionHandler。
五、队列容量要从流量峰值算出来
假设线程池稳定处理能力为每秒 200 个任务,突发流量为每秒 500 个任务并持续 3 秒,若不考虑扩容,积压量约为:
积压任务 = (到达速率 - 处理速率) × 持续时间
= (500 - 200) × 3 = 900
队列设为 1,000 看似能装下,但排在末尾的任务等待时间可能接近 900 / 200 = 4.5 秒,如果接口 SLA 只有 500 ms,这个队列早已失去业务意义。因此容量要同时受内存与最大可接受排队时间约束。
| 指标 | 作用 |
|---|---|
| 到达速率 | 峰值每秒进入多少任务 |
| 服务时间 | 单任务占用线程多久 |
| 可接受等待 | SLA 能留给排队多少时间 |
| 任务对象大小 | 队列占用的堆内存 |
| 下游容量 | 增加线程是否只会压垮依赖 |
六、线程数公式只是起点
常见估算是 N_threads ≈ N_cpu × U_cpu × (1 + W/C),其中 W 是等待时间、C 是计算时间。例如 8 核、目标 CPU 利用率 80%、W/C=4,初值约为 8×0.8×5=32。它假设任务特征相对稳定,真实值还受上下游限流、锁竞争、GC 和容器 CPU 配额影响。
CPU 密集任务通常从接近有效核心数开始;I/O 密集可多一些,但必须通过压测观察吞吐、P99、CPU、队列长度和拒绝数。线程越多并不等于吞吐越高,上下文切换和共享资源竞争会形成拐点。
记忆钩子:线程池不是“把任务藏进队列”,而是一个背压器;线程、队列、拒绝策略共同决定系统在过载时是等待、降级还是把压力扩散给下游。
七、拒绝策略必须匹配业务语义
AbortPolicy 明确失败,适合让上层感知过载;CallerRunsPolicy 让提交线程执行任务,可形成自然反压,但如果提交线程是关键事件循环就可能拖死入口。DiscardPolicy 和 DiscardOldestPolicy 会丢任务,只能用于业务确认允许丢弃且有监控的场景。
任务提交成功也不等于业务完成。关闭时要区分 shutdown() 的有序停止与 shutdownNow() 的中断请求,并用 awaitTermination 等方式等待或升级处置。
八、常见误区与追问
- 误区:核心线程满后会立即创建非核心线程。 对有界队列的典型流程是先尝试入队,队列满后才在未到 maximumPoolSize 时扩线程。
- 误区:maximumPoolSize 在任何队列下都能控制峰值线程数。 无界队列几乎总能接收任务,通常不会走到扩展非核心线程的分支。
- 误区:Executors 工厂方法绝对不能使用。 风险在于不了解其无界队列或线程策略;受控场景可用,但服务端应明确容量与过载行为。
- 追问:为什么队列越大不一定越安全? 大队列会隐藏过载、增加等待和内存占用,任务可能在执行前就已超时。
- 追问:CallerRunsPolicy 有什么风险? 它在提交线程执行任务,能反压,也可能阻塞事件循环、调度线程或持锁线程。
- 追问:线程池大小怎样验证? 在生产式负载下同时观察吞吐、延迟分位数、CPU、活跃线程、队列和拒绝次数。
- 追问:线程池之间为什么要隔离? 慢依赖或高流量业务若共用池,会占满线程和队列,拖累无关任务。
九、加强记忆
七个参数要沿执行路径记:任务先由 corePoolSize 范围内线程承接,核心忙时尝试入队,队列满且线程数小于 maximumPoolSize 才扩容,再饱和才执行拒绝策略;keepAliveTime 管理符合条件的空闲线程,threadFactory 和 handler 决定可观测性与过载动作。线程数从 CPU、等待/计算比得到初值,队列容量则由峰值差额、等待 SLA 和任务内存共同约束。无界队列会让 maximumPoolSize 大多失去作用,大队列还会把过载变成长尾延迟。最终必须用吞吐、P99、队列与拒绝指标压测,而不是照抄固定公式。