恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
ThreadPoolExecutor核心解析:状态机、Worker复用与拒绝策略
首页
资讯中心
/
ThreadPoolExecutor核心解析:状态机、Worker复用与拒绝策略
ThreadPoolExecutor核心解析:状态机、Worker复用与拒绝策略
发布时间:2026/10/15 15:16:19
大多数线上服务逃不开线程池但真正把 ThreadPoolExecutor 源码从头读到尾的人可能比能背出构造参数的人少得多。我最早踩线程池的坑是在一次任务批量提交把 CPU 打满的晚上日志里刷满了 RejectedExecutionException线程数忽高忽低队列长度像心电图一样乱跳当时我只知道换一个“看起来更大”的线程池却解释不了为什么换完还是炸。后来老老实实啃了一遍 ThreadPoolExecutor 的实现才发现它远比我想象中克制——一个 AtomicInteger 同时管住运行状态和线程数一组Worker默默循环取任务队列满了才触发拒绝策略。这篇文章不打算复述文档而是把实现原理拆开从构造参数、状态机、ctl 字段、execute 流程、Worker 复用一路讲到拒绝策略和调参实战适合被线程池搞到过怀疑人生、想真正弄懂底层逻辑的同学。1. 线程池的样子从构造参数到五种运行状态1.1 四个数字与一个队列构造参数到底在定什么ThreadPoolExecutor 最常用的构造函数有七个参数很多人能背出来但不一定清楚每个参数在运行时是怎么起作用的参数作用容易忽略的点corePoolSize核心线程数默认情况下核心线程不会被回收除非设置 allowCoreThreadTimeOutmaximumPoolSize最大线程数它不等于“一开始能创建的线程数”而是“队列满后允许扩大的上限”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()不能不能TERMINATEDterminated() 执行完不能不能源码中这些状态是用整数表示的而且带着升降序关系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()) ! nullgetTask 返回 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任务只会排队不会触发拒绝。一旦生产流量突增队列可能堆积千万条任务内存直接被撑爆。newCachedThreadPoolmaximumPoolSize 是 Integer.MAX_VALUESynchronousQueue 不缓存任务。线程空闲 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 这条线一层层追问题基本就能找到根源。