大多数线上服务逃不开线程池,但真正把 ThreadPoolExecutor 源码从头读到尾的人,可能比能背出构造参数的人少得多。我最早踩线程池的坑,是在一次任务批量提交把 CPU 打满的晚上:日志里刷满了 RejectedExecutionException,线程数忽高忽低,队列长度像心电图一样乱跳,当时我只知道换一个“看起来更大”的线程池,却解释不了为什么换完还是炸。后来老老实实啃了一遍 ThreadPoolExecutor 的实现,才发现它远比我想象中克制——一个 AtomicInteger 同时管住运行状态和线程数,一组Worker默默循环取任务,队列满了才触发拒绝策略。这篇文章不打算复述文档,而是把实现原理拆开,从构造参数、状态机、ctl 字段、execute 流程、Worker 复用,一路讲到拒绝策略和调参实战,适合被线程池搞到过怀疑人生、想真正弄懂底层逻辑的同学。
1. 线程池的样子:从构造参数到五种运行状态
1.1 四个数字与一个队列:构造参数到底在定什么
ThreadPoolExecutor 最常用的构造函数有七个参数,很多人能背出来,但不一定清楚每个参数在运行时是怎么起作用的:
| 参数 | 作用 | 容易忽略的点 |
|---|---|---|
| corePoolSize | 核心线程数 | 默认情况下核心线程不会被回收,除非设置 allowCoreThreadTimeOut |
| maximumPoolSize | 最大线程数 | 它不等于“一开始能创建的线程数”,而是“队列满后允许扩大的上限” |
| keepAliveTime + unit | 空闲线程存活时间 | 只对超出核心线程数的部分生效,核心线程默认不参与 |
| workQueue | 任务缓冲队列 | 队列类型会直接改变线程创建策略的真实效果 |
| threadFactory | 线程工厂 | 不设置的话,默认线程名是 pool-N-thread-M,排查问题不方便 |
| handler | 拒绝策略 | 当队列满且线程数到上限时触发 |
我记得第一次看源码时最不适应的一点是:任务提交后,线程池并不是“先尽量创建线程,放不下再排队”,而是有严格的优先级。核心线程没满就立刻addWorker;核心线程满了,先尝试把任务放入workQueue;只有队列也放不下时,才考虑创建超出核心线程数的临时线程。换句话说,队列是第二道缓冲,maximumPoolSize 是第三道保险。这个顺序直接决定了为什么Executors.newFixedThreadPool看起来线程上限很大,实际永远不触发拒绝——它内部用的是无界队列,任务永远“放得下”,maximumPoolSize 形同虚设。
队列的选择也有讲究。用LinkedBlockingQueue这种有界队列,当队列长度越界后才会触发扩容;用SynchronousQueue则相当于不缓存任务,提交一个任务就必须有一个线程立即接手,否则就扩容或拒绝。这两种队列带来的行为差异,等聊到 execute 流程时会非常明显。
1.2 RUNNING到TERMINATED:状态机决定生死
线程池不是一个“活到老干到老”的傻对象,它有一套状态机约束自己什么时候能收新任务,什么时候只能处理存量,什么时候彻底停止。状态只有五个:
| 状态 | 含义 | 能不能接收新任务 | 能不能处理队列中已有任务 |
|---|---|---|---|
| RUNNING | 正常接收任务 | 能 | 能 |
| SHUTDOWN | 已调用 shutdown() | 不能 | 能 |
| STOP | 已调用 shutdownNow() | 不能 | 不能,且要中断运行中线程 |
| TIDYING | 队列已空、workerCount 为 0,准备执行 terminated() | 不能 | 不能 |
| TERMINATED | terminated() 执行完 | 不能 | 不能 |
源码中这些状态是用整数表示的,而且带着升降序关系:RUNNING 最小,TERMINATED 最大。状态比较用runStateAtLeast/runStateLessThan这类方法,本质上就是比较整数值。因为状态是单调推进的,从 RUNNING 到 SHUTDOWN 再到 STOP、TIDYING、TERMINATED,不会回退,所以用数值顺序表达语义非常自然。
这里值得注意的一点:shutdown()和shutdownNow()的区别不只是“是否中断线程”,还体现在状态切换路径上。shutdown()只是把状态推到 SHUTDOWN,已经进入队列的任务还会被 Worker 继续消费;shutdownNow()直接跳到 STOP,会把当前队列中的任务清出来返回给你,并且尝试中断正在运行的任务。实际操作中,很多人调用了 shutdown 后立刻判断线程池终止,这是不对的,终止是一个异步过程,必须等所有 Worker 退出、计数归零后才能走到 TERMINATED。
2. ctl的设计:一个int装下整个线程池的全局状态
2.1 为什么不用两个独立字段
讲 ctl 之前,先问一个问题:为什么线程池不干脆用两个成员变量分别保存“当前线程数”和“当前状态”,非要把它们塞进一个 int?
因为线程池是个高并发组件,多个线程可能同时在提交任务、在拒绝任务、在触发 shutdown。如果状态和线程数分开存储,就会出现一个中间窗口:状态已经变成 SHUTDOWN 了,但线程数还没来得及同步,此时另一个线程看到“旧的线程数”和“新的状态”,就会做出错误判断。比如以为线程池还在 RUNNING,往队列里塞任务,结果任务永远没人消费。
用同一个 AtomicInteger 保存两部分信息,任何一次更新都是整数值的 CAS 操作,要么旧值一起变,要么新值一起变,不存在“状态和线程数不一致”的中间态。原子性是这个设计的第一诉求。
2.2 位掩码与CAS:状态和线程数如何一起变更
在 ThreadPoolExecutor 里,ctl是一个AtomicInteger,高 3 位存运行状态,低 29 位存 workerCount。这个分配方案对应源码中的常量:
private static final int COUNT_BITS = Integer.SIZE - 3; // 29 private static final int CAPACITY = (1 << COUNT_BITS) - 1; // 低29位全1低 29 位意味着线程数上限是 5 亿多,业务根本够不到。有人会问为什么用 29 位而不是 16 位,因为 JDK 想保留足够的线程数表达空间,同时又想让 5 种状态有独立的高位区间。三位的二进制组合能表达 8 种,留了余量。
从 ctl 中拆分状态和线程数,用的是简单的位运算:
private static int runStateOf(int c) { return c & ~CAPACITY; } private static int workerCountOf(int c) { return c & CAPACITY; } private static int ctlOf(int rs, int wc) { return rs | wc; }runStateOf把低 29 位清零,只看高位;workerCountOf把高 3 位清零,只看低位。新增一个 Worker 时,源码并不是直接给某个变量加一,而是通过 CAS 把 ctl 整体更新为ctlOf(runStateOf(ctl.get()), workerCountOf(ctl.get()) + 1)。每次操作虽然看着要算好几个位运算,但换来的是全局状态的一致性,这个代价很值。
理解了这个设计,后面看 execute、addWorker 里的那些c = ctl.get()就不会晕了——它每次都是抓取快照,后续所有判断都基于这个快照,避免反复读取导致判断不一致。
3. execute()入口:任务提交之后的每一步判定
3.1 核心线程优先,还是队列优先
execute 是整个线程池的前门。很多文章画大图讲流程,但实操中我最常被问到的是:为什么核心线程还没满时不直接放队列,而是非要新建线程?原因有两层:
- 核心线程是“常驻劳动力”,花一次创建成本,之后可以无限复用。优先创建核心线程是为了快速提高处理能力。
- 队列是为突发任务做缓冲,如果把任务先塞队列而线程没起来,突发流量会让队列快速膨胀,后续处理延迟变大。
execute 的第一段逻辑相当于:
int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) { return; } c = ctl.get(); // addWorker失败后重新拿快照 }也就是说,只要当前线程数没到核心线程数,就会尝试创建一个带 firstTask 的核心 Worker。注意这里 addWorker 的第二个参数传的是true,含义是“这次创建的是核心线程”;传false则创建的是临时线程。两者在后续的 keepAlive 判定、超时退出条件上行为不同。
我第一次看源码时忽略了c = ctl.get()这一行。如果 addWorker 创建失败(比如已经 shutdown 或线程数超过上限),但当前快照还停留在旧状态,后面判断就会基于过期数据。所以每次 addWorker 失败后,都必须重新读取 ctl,后面的分支才可靠。
3.2 入队成功之后,为什么要再做一次recheck
如果核心线程已满,execute 尝试把任务放进 workQueue。这一步在源码里同样只有几行,但藏着很细的 double-check 逻辑:
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); } }任务成功进入队列后,线程池为什么要立刻再检查一次状态?因为从判断isRunning(c)到真正把任务 offer 进队列之间,可能有另一个线程调用了 shutdown 或者 shutdownNow。如果运气不好,任务刚入队,线程池就进入 SHUTDOWN/STOP,那么这个任务可能再也没人消费。所以在 offer 成功后再查一次 ctl:
- 如果线程池已经不在 RUNNING,并且任务还能从队列中移除,就把它移除并交给拒绝策略处理。这保证不会把任务留在已停止的池子里。
- 如果线程池还活着,但 workerCount 是 0,说明没有一个 Worker 在跑,那默认的若干个核心线程因为某种原因全部退出了。这时需要补一个
addWorker(null, false),专门创建一个“没有首个任务”的 Worker 去循环 consume 队列。注意这时候创建的是临时线程,所以传 false。
这个workerCountOf(recheck) == 0检查特别容易被忽略,但它是 shutdown 场景下保证“队列里剩余任务还能被执行完”的关键。
3.3 从addWorker到启动线程:中间还有两重检查
当 execute 走到addWorker(command, false)时,意味着队列也满了,只能尝试扩容创建临时线程。如果 addWorker 失败,最后一步就是执行reject(command)。
addWorker 内部其实做了两轮防御检查,这也是实现原理中最严谨的部分之一。第一轮是在加锁前先循环校验状态和线程数:
retry: for (;;) { int c = ctl.get(); int rs = runStateOf(c); if (rs >= SHUTDOWN && !(rs == SHUTDOWN && firstTask == null)) return false; if (workerCountOf(c) >= CAPACITY) return false; int wc = workerCountOf(c); if (wc >= (core ? corePoolSize : maximumPoolSize)) return false; if (compareAndIncrementWorkerCount(c)) break retry; }这段校验回答了两个问题:线程池状态还允许新建线程吗?线程数是否已经到达 core 或 max 的上限?满足条件后,CAS 把 workerCount 加一。
第二轮是在获取mainLock之后,真正创建 Worker 之前,再次检查状态。为什么不只检查一次?因为 CAS 成功后到获取 mainLock 之间,可能已经有另一个线程调用了 shutdown,状态可能已经变化。所以加锁后要再看一遍 rs,如果不合法就把刚刚加上的 workerCount 减回去。这轮检查配合workers集合的 add / removal 操作,保证 Worker 的创建过程不会有“孤儿线程”泄露。
真正创建 Worker 后,把 Worker 加入workersHashSet,然后启动它内部的 thread。你能看到的线程池里正在跑的业务线程,其实就是这些被 Worker 包装起来、从池子里启动的线程。线程启动前的 lastInterruptTime 等细节,这里不展开,但要清楚 addWorker 的过程远比 “new Thread().start()” 要谨慎得多。
4. Worker线程复用之谜:runWorker循环与getTask退出逻辑
4.1 Worker为什么既是AQS又是Runnable
线程池里真正干活的单位不是线程,而是 Worker。Worker 本身实现了两个关键接口:一个继承 AbstractQueuedSynchronizer,一个实现 Runnable。有人第一次看到会懵:一个工作单元为什么要同时搞两套身份?
Worker 作为 Runnable,是因为它要被包装进一个 Thread 里执行;Worker 作为 AQS,是为了精细控制线程的“忙碌/空闲状态”和“中断状态”。
AQS 自定义的锁逻辑非常简单:state 只有 0 和 1。Worker 在真正执行任务时通过lock()把 state 置为 1,标记自己正在工作;在执行完一个任务、准备从队列取下一个任务前,通过tryLock()判断是否能拿到锁。shutdownNow 尝试中断线程时,会拿着 mainLock 遍历所有 Worker,对还在工作的线程发送中断信号,同时避免打断那些正闲在队列上等待任务的线程。这里面的精确控制,用普通 synchronized 很难做到,但用 AQS 子类能方便地实现可尝试锁和打断语义。
runWorker 的核心循环可以简化理解成:先执行 firstTask,再不断从 workQueue 取下一个任务,直到取不到为止。这个循环就是“线程复用”的本体。
4.2 getTask的四种退出条件和超时机制
一个 Worker 什么时候退出?答案藏在 getTask 返回 null 的时刻。runWorker 里判断task != null || (task = getTask()) != null,getTask 返回 null 或抛出异常,循环就结束,Worker 随之进入退出清理流程。
getTask 返回 null 的常见条件有四种:
- 线程池状态已经是 STOP,直接退出,因为不允许继续消费队列;
- 线程池状态是 SHUTDOWN,并且队列已经空了,继续留着没意义;
- 当前线程数已经超过 maximumPoolSize,说明池子在收缩;
- 本次是“超时检查”任务,并且
allowCoreThreadTimeOut为 true,或当前线程数大于 corePoolSize,且队列已经空了一段时间。
第 4 条就是 keepAliveTime 的落点。对临时线程来说,它从workQueue.poll(keepAliveTime, NANOSECONDS)里拿任务,等满 keepAliveTime 还拿不到,poll 返回 null,就走到退出分支,同时通过 CAS 把 workerCount 减回去。对于核心线程,默认情况下timed是 false,执行的是workQueue.take(),会一直阻塞等任务,绝不会因为空闲而退出,除非你调用了allowCoreThreadTimeOut(true)。
这个设计解释了为什么线程池“复用”线程而不是“反复创建”:任务减少时,线程不是立刻销毁,而是先在队列上等一段时间;任务增多了,这些空闲线程又立刻被唤醒取任务。只有长期没有任务可做的临时线程才会被淘汰。
4.3 任务抛异常之后,线程池是怎么“保命”的
很多人有个错误直觉:任务里抛出 RuntimeException,线程就直接没了,线程池就废了。实际上,runWorker 确实会让当前 Worker 退出,但退出逻辑里有一个“补位”机制:processWorkerExit 在执行完清理后,会根据线程池状态判断是否补一个 addWorker。如果线程池还在 RUNNING,且当前 workerCount 已经小于 corePoolSize,它就会创建新的 Worker 补齐劳动力。
所以,线程池不会因为某个任务抛异常而整体崩溃,而是默默换掉出问题的 Worker。但这里有一个隐藏提示:任务抛出的异常不会自动被线程池吞掉,也不会调用拒绝策略。异常的堆栈通常只会打到线程自带的 UncaughtExceptionHandler 或标准错误流里,非常容易被忽略。如果业务任务里没有捕获异常,线程被换掉后,很多监控可能完全看不出异常,任务本身已经失败了。这也是我一直强调“线程池里的任务必须自己做好异常捕获和补偿”的原因。
5. 队列塞满之后的四种拒绝策略:不只是抛异常
5.1 AbortPolicy、DiscardPolicy和DiscardOldestPolicy的取舍
当任务提交时线程池确实无法处理,execute 的最后一步就会调用handler.rejectedExecution(command, this)。四种内置策略看起来都是“处理任务失败”,但行为差异极大:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy | 抛出 RejectedExecutionException | 调用方如果没捕获,任务直接失败,异常可能打崩上层流程 |
| CallerRunsPolicy | 在提交任务的线程里直接执行 | 不丢任务,但会阻塞提交方,可能拖慢调用线程 |
| DiscardPolicy | 静默丢弃新任务 | 任务会丢,且没有任何提示 |
| DiscardOldestPolicy | 丢弃队列里最早的任务,再重新提交当前任务 | 老任务被丢,新任务也不一定保证能执行 |
AbortPolicy 是默认策略,也是行为最“硬”的。它适合明确拒绝、必须让调用方感知到压力的场景。比如风控系统、支付回调这种绝对不能静默丢任务的业务,宁可让提交方看到异常去走补偿,也不能把任务偷偷扔掉。
DiscardOldestPolicy 经常被误认为“很聪明”,但从实现上看它只是从队列头 poll 掉一个最老的 Runnable,然后再次调用 execute。如果再次执行还是满的,就会再次走进拒绝策略。对强顺序敏感的任务来说,把老任务丢掉可能造成严重的状态不一致,所以它更适合无状态、可丢早不丢晚的批量任务。
5.2 CallerRunsPolicy的背压价值与潜在风险
如果让我在绝大多数内部服务里选,我往往倾向于 CallerRunsPolicy,它天然带“背压”。任务提交不进去时,它不让任务消失,而是让提交任务的线程自己把这个任务执行掉。提交线程一旦被占用,后续任务的提交速度自然就慢下来,给下游处理留出时间。
但 CallerRunsPolicy 有一个很隐蔽的风险:如果调用方本身是 Tomcat 的请求线程,一个队列满的线程池会把计算压力反压回 HTTP 请求线程,导致接口响应时间飙升。在这种情况下,你看到的不再是“线程池拒绝异常”,而是接口大面积变慢。很多团队遇到线程池满的第一反应是加队列长度、加线程数,而不是检查业务量波动,结果就是整个链路被拖垮。
使用 CallerRunsPolicy 时,最好配合一个拒绝监控指标:一旦触发策略,就说明线程池和队列已经饱和,此时应该报警而不是当作“正常处理”。否则“不拒绝”反而掩盖了容量不足的问题。
5.3 两个常见的错误兜底:无脑重试与静默吞掉
在被拒绝策略折磨过之后,我见过两种特别常见的“补救”写进代码里,而且都问题很大。
第一种是无脑 catch RejectedExecutionException 后立刻重试。队列满说明资源已经不足,立刻重试只会加剧压力,通常重试两三次后依然失败,最终任务堆积在内存里,GC 和 CPU 一起爆炸。更合理的做法是带退避的重试,或者直接把任务投到消息队列,让消费者在低峰期慢慢追。
第二种是 catch 之后什么也不做。尤其用 DiscardPolicy 时,任务丢失完全没有提示,等业务对账才发现少了数据,那时只能靠回溯日志找线索。我一直建议,任何线程池至少记录一条 WARN 日志,包含任务名称、线程池当前线程数、队列长度、拒绝次数。日志量通常不会大到不可接受,但对事后排查的帮助是巨大的。
内置策略没有被执行的 handler 顺序讲完,本质上都是rejectedExecution方法被回调。如果你对策略有特殊要求,可以自己实现 RejectedExecutionHandler,比如组合“先记录日志、再投递到死信队列”,或者“按任务优先级丢弃”。但自定义策略一定要考虑到“提交线程被阻塞”和“任务可丢弃性”这两个前提,否则容易引入更隐蔽的线上事故。
6. 调优实战:核心线程数、队列长度与服务类型匹配
6.1 CPU密集和IO密集的任务该怎么估算线程数
调线程池参数没有银弹,但可以给一套起步思路。先分清任务类型:
- CPU 密集:任务的耗时主要花在计算上,线程数超过 CPU 核数后无法带来更多收益,反而增加上下文切换。经验起步值是
CPU核心数 + 1,比如 8 核机器设为 9。这个 1 是为了偶发缺页中断等情况留的余量。 - IO 密集:任务大量时间在做网络请求、磁盘读写、数据库调用,线程在等待 IO 时 CPU 是空的,所以线程数可以超过核心数。常用的经验公式是:
线程数 ≈ CPU核心数 × 目标CPU利用率 × (1 + 等待时间 / 计算时间)
假设 8 核 CPU,目标利用率 80%,任务平均执行耗时 200ms,其中纯计算 50ms、等待 IO 150ms,那么启动线程数可以估为8 * 0.8 * (1 + 150/50) ≈ 25.6,先给 26 个线程。这个公式只是一个起点,最终必须靠压测修正。
队列长度的设定建议不要走极端。Queue 设太小会频繁触发拒绝,设太大则会让任务在队列中滞留过久,造成“看起来都提交成功,实际上延迟很高”。我习惯先把队列设成“单线程每秒处理量 × 目标容忍秒数”,比如单线程每秒处理 10 个任务,允许任务排队 5 秒,那队列可以先设 50 到 100。之后再根据监控收紧。
6.2 为什么我不推荐用Executors的快捷工厂
Executors 提供的几个快捷方法确实省事,但它们包含几个“教学上很好、生产上危险”的默认配置:
newFixedThreadPool:用的是无界 LinkedBlockingQueue,任务只会排队不会触发拒绝。一旦生产流量突增,队列可能堆积千万条任务,内存直接被撑爆。newCachedThreadPool:maximumPoolSize 是 Integer.MAX_VALUE,SynchronousQueue 不缓存任务。线程空闲 60 秒就回收,但突发流量下线程数无限扩张,能达到几万个线程,直接把系统打挂。newSingleThreadExecutor:同样用无界队列,语义是单线程串行,但它把队列底层的无界问题又复刻了一遍。
当然,不是说这些快捷方法完全不能用。如果在内部低峰工具里明确知道任务量和执行时间,图省事可以用。但线上核心链路我不推荐,因为“显式构造 ThreadPoolExecutor”带来的参数直觉,本身就是提醒你把容量、拒绝策略、线程名称这些细节想清楚。
线程名称这块也值得说一句:用自定义 ThreadFactory 给核心线程命名,比如order-async-worker,在出问题时候查线程 dump 会轻松很多。默认的 pool-1-thread-1 在多个线程池共存时,根本分不清是哪个池子出的问题。
6.3 运行时指标和动态调整:别等线上告警才看线程池
线程池不是配好就一劳永逸的静态对象。ThreadPoolExecutor 暴露了不少运行时信息:
getPoolSize():当前线程数,用于判断是否触发了扩容。getActiveCount():正忙碌的线程数。如果长期接近池子规模上限,说明容量吃紧。getQueue().size():待处理任务数,这是最直观的积压指标。getTaskCount()和getCompletedTaskCount():累计提交和完成任务数,可推算平均吞吐。getRejectedExecutionCount():这个方法是 JDK 1.8 加的,直接反映拒绝次数。
我自己的习惯是给每个核心业务线程池加一个周期日志或指标记录,采样间隔 30 到 60 秒,重点看队列长度和 activeCount 的比值。如果队列持续占满、activeCount 长满,说明参数需要扩,或业务需要分流。如果队列经常是空的、线程数却经常冲高,说明峰值流量来去太快,可以适当降低核心线程数并继续观察。
另外,ThreadPoolExecutor 支持动态调参,setCorePoolSize、setMaximumPoolSize、setKeepAliveTime都可以在运行期调用。这些方法内部也会用到 mainLock,并且像 setCorePoolSize 一样在调整后判断是否需要减少或补充线程,理论上可以做基于指标的自动伸缩。但我通常不建议线上自动改参数,最好还是人工确认后调整,因为自动伸缩一旦阈值设置不合理,很容易在流量抖动时反复重建线程,比固定参数更不稳定。
如果还要深入一步,可以参考线程池对prestartCoreThread和prestartAllCoreThreads的处理。默认情况下核心线程是懒创建,第一个任务来了才启动。对于延迟敏感的定时任务场景,可以考虑启动时主动预热核心线程,让线程池一上线就具备处理能力,省去前几个任务的启动等待。
聊到这里,ThreadPoolExecutor 从构造参数到执行流转,再到拒绝策略和调优,基本都过了一遍。我在实际项目里最大的体会是:线程池的源码并不难懂,难的是把它和真实流量模型对应起来。同一个池子,核心线程数 10、队列 100、最大线程 20,在低峰期表现很好,在高峰期可能频繁拒绝;换一个业务场景,同样的参数又可能让内存暴涨。没有一套参数能适配所有服务,理解 execute 的顺序、getTask 的退出条件、拒绝策略的触发时机,至少能让你在出问题时有清晰的排查路径,而不是靠运气调参。下次再看到 RejectedExecutionException,沿着 ctl、workQueue、handler 这条线一层层追,问题基本就能找到根源。