ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java线程池ThreadPoolExecutor源码级拆解:原理、参数与线上排查

Java线程池ThreadPoolExecutor源码级拆解:原理、参数与线上排查 聊到 Java 并发线程池是绕不开的高频考点。生产环境的每个高并发接口、每一条异步消息消费链路背后几乎都是 ThreadPoolExecutor 在撑着。很多人背得出核心参数、能默写出四种拒绝策略可一旦遇上诡异问题——比如线程数涨到最大却没人干活、shutdown 后进程卡住不退出、任务明明提交了却石沉大海——就束手无策。原因只有一个只记住了 API 表面没吃透它的实现原理。这篇文章我就带你把 ThreadPoolExecutor 从源码层面完整拆一遍。从 ctl 那个“一个变量管两件事”的设计到 execute 的三次机会、Worker 的任务循环、五个状态的生命周期迁移再到线上排查的真实坑全部讲透。适合刚学完 Java 并发的同学建立完整认知也适合工作了几年的开发者排查线程池问题时回来翻一翻搞懂原理之后很多现象其实一眼就能看出根因。1. 从构造参数看线程池的核心设计1.1 七个参数如何配合线程才不会“白养”也不会“爆仓”先看最常用的构造函数public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)七个参数各管一块理解它们是入门的第一道坎。corePoolSize核心线程数。这些线程是线程池的“常住人口”即使空闲也不会被回收除非你把 allowCoreThreadTimeOut 设为 true。任务还没来的时候默认不会提前创建只有提交第一个任务时才逐步创建。maximumPoolSize最大线程数。线程池允许同时存在的线程数量上限超出这个数新任务只能走拒绝策略。keepAliveTime unit非核心线程的空闲存活时间。当线程数超过 corePoolSize多出来的“临时工”空闲超过这个时间就会被回收。这个参数很容易踩坑因为默认情况下核心线程不受 keepAliveTime 控制。workQueue任务缓冲队列。核心线程全忙时新任务先进队列等待而不是立刻建新线程。threadFactory线程工厂。控制线程命名、是否守护、优先级等。生产环境强烈建议自定义否则日志里全是 pool-1-thread-1排查问题非常痛苦。handler拒绝策略。队列满了且线程数也达到上限时新任务触发拒绝策略。这七个参数内部是有明确的协作顺序的很多人在这里记反了一个关键点——核心线程满后不是“立刻膨胀到最大线程数”而是先排队。只有队列也满了才会去创建非核心线程。如果非核心线程也达到上限才会触发拒绝策略。我常用一个营业厅模型来理解核心线程是固定柜台队列是等候区非核心线程是临时加开的柜台。固定柜台全在办业务客户先去等候区排队等候区满了才加开临时柜台临时柜台也满了再有客户进门就只能拒绝服务。这个模型对应代码逻辑几乎一一匹配后面第 2 章会看到 execute 方法本身就是按这个顺序写的。1.2 看懂 ctl 这个魔法变量一个值同时保存状态和线程数在进入 execute 之前必须先认识线程池里最核心的字段ctl。它是个 AtomicInteger但只用了一个 int 就同时保存了两个信息——线程池运行状态runState和工作线程数workerCount。private final AtomicInteger ctl new AtomicInteger(ctlOf(RUNNING, 0)); private static final int COUNT_BITS Integer.SIZE - 3; // 29 private static final int CAPACITY (1 COUNT_BITS) - 1; // 2^29 - 1 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; }设计是这样的高 3 位存 runState低 29 位存 workerCount。所以 workerCount 的理论上限是(1 29) - 1约 5 亿实际开发中几乎不可能触顶addWorker 里的 CAPACITY 判断只是留个安全边界。为什么这么设计核心原因是“原子性”。线程池的状态和线程数经常需要同时变化比如 shutdown 时把状态从 RUNNING 改成 SHUTDOWN同时希望线程数不被干扰。如果分两个字段就得加锁保证一致性合成一个 int 放进 AtomicInteger一次 CAS 就能同时修改两部分性能和安全兼顾。五个状态的具体值也很讲究状态数值行为说明RUNNING-1 29负数接收新任务处理队列任务SHUTDOWN0不接收新任务但继续处理队列任务STOP1 29不接收新任务不处理队列任务中断所有线程TIDYING2 29所有任务已结束workerCount 为 0即将执行 terminated()TERMINATED3 29terminated() 执行完毕彻底结束注意一个反直觉的细节RUNNING 是负数所以状态之间的数值比较是RUNNING SHUTDOWN STOP TIDYING TERMINATED。源码里大量使用runStateLessThan(c, STOP)或runStateAtLeast(c, STOP)这类判断本质上就是利用这个单调递增的数值链来做范围判断。看懂这个后面读源码就能顺下来。2. 任务提交的完整链路execute 的三次机会2.1 源码级拆解 execute核心线程、队列、非核心线程的优先级任务提交的入口是 execute 方法submit 底层也会转到这里。JDK 1.8 的源码非常紧凑逻辑全部浓缩在几行里public void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); // 第一次机会核心线程未满 if (workerCountOf(c) corePoolSize) { if (addWorker(command, true)) return; c ctl.get(); } // 第二次机会线程池还在运行尝试入队 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); } // 第三次机会队列满了尝试创建非核心线程 else if (!addWorker(command, false)) reject(command); }逐个拆开看。第一次机会只要当前工作线程数小于 corePoolSize就直接 addWorker(command, true)创建核心线程并立刻执行任务。这里有个细节如果 addWorker 失败比如线程池刚好被 shutdown需要重新读取 ctl因为刚才的 c 已经过期了。第二次机会核心线程满了进入队列。入队前要判断线程池是否还在 RUNNING 状态只有运行中才允许入队。入队成功不等于万事大吉紧接着有一个 recheck双重检查。这个 recheck 非常关键因为入队和 recheck 之间线程池可能被另一个线程调用了 shutdown此时池状态变了但任务已经进了队列如果不做处理这个任务可能永远等不到执行。所以发现状态已经不是 RUNNING就调用 remove(command) 把任务从队列移除然后走拒绝策略。recheck 里还有个隐藏逻辑如果 recheck 时发现 workerCount 为 0需要 addWorker(null, false) 补一个非核心线程。为什么因为可能出现了“共享池但核心线程全部退出”的极端情况比如启用了 allowCoreThreadTimeOut如果不补线程队列里的任务就永远没人消费了。第三次机会入队失败说明队列满了此时尝试 addWorker(command, false) 创建非核心线程。如果这也失败说明线程数已经达到 maximumPoolSize只能 reject。整个流程非常清晰地体现了线程池的哲学能复用核心线程就复用复用不了就缓冲缓冲不下才扩编扩编还不行就拒绝。我建议读者把 execute 这段源码背下来面试手撕题和实际排查都会用到。2.2 addWorker 的双重校验与线程启动细节addWorker 是真正干活的地方。这个方法做了两件事先通过 CAS 把 workerCount 加上去再创建 Worker 对象、启动线程。源码较长核心骨架如下private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { int c ctl.get(); int rs runStateOf(c); // 第一道拦截状态不允许 if (rs SHUTDOWN !(rs SHUTDOWN firstTask null)) return false; for (;;) { int wc workerCountOf(c); if (wc CAPACITY || wc (core ? corePoolSize : maximumPoolSize)) return false; if (compareAndIncrementWorkerCount(c)) break retry; c ctl.get(); if (runStateOf(c) ! rs) continue retry; } } // 之后是创建 Worker、加锁、再次检查、启动线程 ... }第一道拦截判断池状态如果已经 SHUTDOWN 以上通常不允许再创建线程。但保留了一个特例——rs SHUTDOWN firstTask null也就是允许在 SHUTDOWN 状态下创建一个“空转线程”。这是为了处理 2.1 里那种 workerCount 为 0 但队列还有任务的情况线程池必须允许补一个线程去把剩余任务消费完。第二道拦截判断线程数不能超过 CAPACITY也不能超过 corePoolSize 或 maximumPoolSize根据 core 参数选择。CAS 失败就重试如果期间线程池状态变了就跳回 retry 重新做状态检查避免用旧状态做出错误判断。创建 Worker 并启动线程的部分也很讲究。拿到 Worker 和 thread 之后需要加 mainLock 锁。这个锁的作用是保护 workers 集合同时保证与 shutdown 的 interruptIdleWorkers 操作互斥。加锁后还要再检查一次池状态确认没有在创建期间被 shutdown 或 stop确认安全才 t.start() 启动线程。这里有个小细节如果 t.start() 因为 ThreadFactory 返回的线程为 null 或启动异常addWorker 会把已经加上的 workerCount 回滚并把 Worker 从集合移除。所以 addWorker 失败时线程池的线程数不会虚高。提示很多人以为 addWorker 只是“new 一个线程然后 start”其实它做了状态校验、CAS 计数、加锁、二次校验、启动、异常回滚六件事。只记住一个“创建线程”是远远不够的。3. Worker 线程的运作模型用任务循环理解整个生命周期3.1 为什么 Worker 要继承 AQS不可重入锁的设计意图Worker 是 ThreadPoolExecutor 的内部类同时也是线程池真正“干活”的单元。它本身实现了 Runnable又继承了 AbstractQueuedSynchronizerprivate final class Worker extends AbstractQueuedSynchronizer implements Runnable { final Thread thread; Runnable firstTask; volatile long completedTasks; }一个很自然的疑问是为什么不直接 new Thread 跑任务非要套一个 Worker 类答案在于 Worker 给它自己装了一把锁。这把锁是不可重入的互斥锁它和任务执行状态严格绑定执行任务前 lock执行完 unlock。外部代码通过 tryLock 就能判断这个 worker 是否空闲。这个判断是 shutdown 机制的核心。看 interruptIdleWorkers 的实现逻辑for (Worker w : workers) { Thread t w.thread; if (!t.isInterrupted() w.tryLock()) { try { t.interrupt(); } finally { w.unlock(); } } }tryLock 成功说明 worker 当前没有在跑任务锁是空闲的可以安全中断tryLock 失败说明它正在执行任务不能中断否则会把一个正在跑的业务逻辑打断。为什么不用 ReentrantLock关键在于“不可重入”。如果换成可重入锁同一个线程在持有锁的过程中再次获取锁也能成功那 tryLock 就无法准确反映“该 worker 是否正在执行任务”。ThreadPoolExecutor 需要的是“已锁 执行中”这个强等价关系所以 JDK 特意用 AQS 实现了一个非重入锁而不是直接复用 ReentrantLock。Worker 还有一个很隐蔽的初始化细节构造函数里会 setState(-1)而不是 0。这是为了让 worker 在真正进入 runWorker 之前不会被 interruptIdleWorkers 误判为空闲线程给中断掉。runWorker 一开头会执行 unlock() 把状态恢复为 0从此才允许被外部中断。这个防“抢跑中断”的设计许多人看源码都不会注意到但正是它保证了线程初始化期的安全。3.2 runWorker 主循环与 getTask 的四个出口Worker 启动后线程就跑进了 runWorker 方法这是线程池工作的主循环final void runWorker(Worker w) { Thread wt Thread.currentThread(); Runnable task w.firstTask; w.firstTask null; w.unlock(); // 恢复可中断状态 boolean completedAbruptly true; try { while (task ! null || (task getTask()) ! null) { w.lock(); // 池处于 STOP 及以上状态需要设置中断标志 if ((runStateAtLeast(ctl.get(), STOP) || (Thread.interrupted() runStateAtLeast(ctl.get(), STOP))) !wt.isInterrupted()) wt.interrupt(); try { beforeExecute(wt, task); Throwable thrown null; try { task.run(); } catch (RuntimeException x) { thrown x; throw x; } catch (Error x) { thrown x; throw x; } catch (Throwable x) { thrown x; throw new Error(x); } finally { afterExecute(task, thrown); } } finally { task null; w.completedTasks; w.unlock(); } } completedAbruptly false; } finally { processWorkerExit(w, completedAbruptly); } }主循环的逻辑是先拿 firstTask拿不到就阻塞等待 getTask() 从队列取任务。拿到任务后 lock设置中断状态如果池已 STOP执行任务前后调用 beforeExecute / afterExecute 钩子任务结束后统计 completedTasks 并 unlock。循环直到 getTask 返回 null最后进入 processWorkerExit 收尾。如果任务执行抛出了 RuntimeException 或 Error会直接向上抛导致线程退出。这看起来可怕但线程池有补救机制processWorkerExit 会在 finally 中检测到 completedAbruptly 为 true然后补充一个新 Worker。所以单次任务的异常不会把线程池打到“没人可用”的状态反而会自动换一个线程继续扛活。getTask 是决定 worker 生死的取任务逻辑它一共有四个出口。第一个出口池状态已经 SHUTDOWN 且队列为空或者池状态已经达到 STOPgetTask 直接返回 null。此时线程退出线程池进入“收拾尾声”的阶段。第二个出口workerCount 大于 maximumPoolSize。这个情况通常发生在运行时调用 setMaximumPoolSize 把上限调小了多出来的线程需要尽快退出。第三个出口超时未取到任务。timed allowCoreThreadTimeOut || wc corePoolSize当线程数超过核心数、或允许核心线程超时时用workQueue.poll(keepAliveTime)等待超时拿不到任务就返回 null。这里有一个安全条件wc 1 || workQueue.isEmpty()也就是说池里最后一个线程在队列非空时不能因为超时退出否则队列里剩下的任务就没人处理了。第四个出口被中断。线程在 take 或 poll 时被 interrupt 后会回到循环顶部重新检查状态如果碰上池已经 SHUTDOWN就会走第一个出口退出。这也是 shutdown 后所有线程能陆续结束的原理。核心线程能不能被回收就藏在这个 getTask 里。默认情况下allowCoreThreadTimeOut false核心线程走workQueue.take()无限期阻塞永远等待任务所以不会被回收。只有显式开启 allowCoreThreadTimeOut核心线程才会变成“临时工”享受同样的超时回收待遇。这个参数要谨慎开启它改变了线程池“保持核心线程常驻”的语义但也避免了低峰期线程白白占内存。4. 线程池状态机从 RUNNING 到 TERMINATED 的完整迁徙4.1 五种状态各自的权限与迁移路径前面列了五个状态的数值这里再从行为角度梳理一遍它们在“接收新任务、处理队列任务、中断线程”三个维度上的权限差异状态接收新任务处理队列任务中断工作线程RUNNING允许允许否SHUTDOWN拒绝允许只中断空闲线程STOP拒绝拒绝中断所有线程TIDYING拒绝拒绝已无线程TERMINATED拒绝拒绝已结束状态迁移路径只有五条RUNNING - SHUTDOWN调用 shutdown()RUNNING 或 SHUTDOWN - STOP调用 shutdownNow()SHUTDOWN - TIDYING队列已空workerCount 为 0STOP - TIDYINGworkerCount 为 0TIDYING - TERMINATEDterminated() 钩子执行完毕真正推动状态从 SHUTDOWN/STOP 走到 TIDYING 的核心方法是 tryTerminate。它的判断逻辑很精巧for (;;) { int c ctl.get(); // 运行中直接返回已终止也返回 // SHUTDOWN 且队列非空说明任务还没消费完不能终止 if (isRunning(c) || runStateAtLeast(c, TIDYING) || (runStateOf(c) SHUTDOWN !workQueue.isEmpty())) return; // 还有存活线程中断一个空闲线程推动其退出 if (workerCountOf(c) ! 0) { interruptIdleWorkers(true); return; } // 线程数为 0CAS 到 TIDYING执行 terminated()然后置 TERMINATED ... }注意 workerCount 不为 0 时它不会傻等而是中断一个空闲线程让这个线程在 getTask 的下一次循环里发现状态变化而退出从而逐步收敛到 0。这就是“中断一个空闲线程”代替“阻塞等待所有线程结束”的推进策略避免 tryTerminate 被卡死。4.2 shutdown 与 shutdownNow优雅关闭和强制关闭的区别这两个方法名字像语义差别非常大实际使用中经常被搞混。shutdown 走的是 SHUTDOWN 状态它只做三件事状态改为 SHUTDOWN、中断所有空闲线程、执行 onShutdown 钩子。注意它不会中断正在执行任务的线程也不会清除队列里尚未执行的任务。也就是说调用 shutdown 之后线程池会像一个已经下班但还留在工位上把手头活儿干完的同事把队列里的任务全部处理干净才进入终止流程。shutdownNow 则完全不同。它把状态直接推到 STOP然后中断所有线程最后把队列中还没执行的任务 drain 出来作为 List 返回。调用方拿到这个 List就能知道哪些任务“还没干成”可以自行处置。维度shutdownshutdownNow移入的状态SHUTDOWNSTOP未开始的任务继续处理返回未执行列表不再处理正在执行的任务不中断让它跑完发送中断信号是否能强制终止任务否否任务需响应中断最后一点值得强调shutdownNow 的“中断所有线程”只是给线程发送中断标志。如果任务代码不检查中断标志、不响应 InterruptedException正在执行的 run 方法照样会跑完。很多“shutdownNow 之后池子还在跑”的疑问根因就在这里——任务本身不配合中断。4.3 钩子方法、awaitTermination 与监控扩展ThreadPoolExecutor 留了三个扩展点beforeExecute、afterExecute、terminated。默认它们都是空实现子类重写后可以实现很多实用功能。beforeExecute 在任务执行前调用可以记录任务开始时间、往 ThreadLocal 写入交易号afterExecute 在任务执行后调用哪怕任务抛了异常也会进入这里异常信息放在第二个参数 thrown 里是最可靠的“任务异常观测点”。terminated 则在池走完 TIDYING 后、变成 TERMINATED 前执行适合做资源清理、记录最终统计指标。awaitTermination 是等待池终止的利器。调用 shutdown 之后如果想确认线程池真的结束了再去做后续操作可以用它做超时等待返回 true 表示池已经进入 TERMINATED返回 false 表示超时仍未结束。生产环境的应用关闭流程基本标配就是 shutdown awaitTermination 超时后的强制兜底。提示重写 beforeExecute/afterExecute 时不要抛异常。尤其是 beforeExecute 一旦抛异常任务本身不会执行而且 worker 会直接退出处理起来异常麻烦。5. 参数调优与实战推演队列、拒绝策略和线程数怎么配5.1 核心线程数到底该设多少从公式到压测线程数设多少是 ThreadPoolExecutor 配置里最玄学的问题。网上流传两个通用公式CPU 密集型设 N1N 是 CPU 核数IO 密集型设 N * (1 等待时间 / 计算时间)。这两个公式能用但只能作为起点。原因很简单真实业务很少是纯 CPU 或纯 IO中间还有锁竞争、GC、网络抖动公式算出的数大概率不是最优值。我更建议按这个思路落地先估算单任务的平均计算耗时和平均等待耗时代入 IO 密集型公式得出一个初始线程数然后把队列长度设为“在目标响应时长内最多能积压的任务数”最后用压测工具逐步加压观察三个核心指标——activeCount 是否稳定、queueSize 是否持续上涨、有没有拒绝异常。如果 activeCount 还没到 max 就开始拒绝说明队列太短或线程数偏低如果 queueSize 一直涨但 activeCount 很低说明任务在等锁或者线程在空转。还有一个很容易被忽视的维度线程不是越多越好。超过一定数量后CPU 上下文切换开销会吃掉并发红利线程内存占用每个线程默认栈约 1MB也会成为隐性成本。生产环境宁可把线程数调得保守一点靠有界队列和拒绝策略做防护也不要一次性把 maximumPoolSize 拉满。5.2 队列选型与四种拒绝策略的本质队列决定了线程池的“缓冲能力”选错队列参数调得再合理都可能失效。四种常见队列各有脾性队列特性适用场景ArrayBlockingQueue有界固定容量底层数组生产环境默认首选能兜底限流LinkedBlockingQueue默认容量无限可指定容量无界时吞吐稳定但任务积压会占满内存SynchronousQueue不存任务直接交接给线程配合 CachedThreadPool线程按需飙升PriorityBlockingQueue无界按优先级出队需要任务优先级时使用但仍要防堆积一个重要的认知如果队列是无界的maximumPoolSize 其实形同虚设。因为队列永远不会满execute 的第三次机会永远走不到非核心线程也就不会被创建任务只会越积越多直到内存撑爆。所以生产环境一定要优先有界队列给线程池一个明确的“承载力边界”。拒绝策略有四种内置实现外加自定义方案AbortPolicy默认策略直接抛 RejectedExecutionException。好处是错误明显坏处是调用方没有心理准备容易崩。CallerRunsPolicy谁提交谁执行。提交任务的线程亲自把任务跑掉等于把压力传导回去天然形成背压而且不丢任务。我比较偏爱这个策略。DiscardPolicy静默丢弃。适合日志、打点这类允许丢的任务但丢之前最好在自定义 handler 里记一条告警。DiscardOldestPolicy丢弃队列里最早的任务再尝试提交新任务。适合不介意丢旧任务、只要新任务的场景。实际项目中重要业务任务我一般用 CallerRunsPolicy防止高峰直接抛异常导致接口 5xx非核心的埋点、日志用 DiscardPolicy 并配告警对账、订单这类绝对不能丢的任务则自定义 handler把失败任务写入本地缓冲由补偿任务重放。5.3 提交20个任务推出的完整走向理论讲再多不如推演一遍。假设 corePoolSize2maximumPoolSize5workQueue 容量为 10连续提交 20 个任务逐条跟踪提交序号线程池行为当前线程数队列长度1创建核心线程执行102创建核心线程执行203入队等待214~12继续入队22~1013队列满创建非核心线程执行31014继续创建非核心线程41015~17继续创建非核心线程51018~20线程数已达 5队列已满触发拒绝策略510这个推演暴露了一个容易混淆的点任务 3 到任务 12 这 10 个任务并没有立刻执行而是全部进了队列等待。只有当队列满到第 11 个任务塞不进去时线程池才开始把线程数从 2 往 5 扩。所以观察线上线程池的 activeCount 时如果看到它长期小于 corePoolSize说明任务量根本没打满如果等于 corePoolSize 且 queueSize 在涨说明队列在承担缓冲压力只有 queueSize 顶满activeCount 才开始往 max 涨。如果队列换成无界 LinkedBlockingQueue那整个推演在任务 3~20 都会停在“入队等待”线程数永远是 2。这就是无界队列掩盖 maximumPoolSize 的原因。理解这个推演配置参数时就能少走很多弯路。6. 线上排查实录高频问题与避坑指南6.1 线程数涨满但队列为空先看是不是 SynchronousQueue我见过一个很典型的案例某服务配置了 SynchronousQueue 和很大的 maximumPoolSize高峰期线程数直接冲到几百CPU 被打满接口超时率飙升。排查时先看队列发现 queueSize 永远是 0——这不是“没有任务排队”而是 SynchronousQueue 本身就不排队。SynchronousQueue 的语义是“生产者直接把任务交给消费者线程”没有中间缓冲。任何提交进来的任务都会立刻触发 addWorker 创建线程线程数自然容易一路顶到 maximumPoolSize。它非常适合线程可快速创建销毁、任务量波动大的场景但要求 maximumPoolSize 必须设得克制同时做好限流。如果业务需要稳定的缓冲和削峰SynchronousQueue 就是错误选择。遇到线程数异常上涨第一反应应该是去看线程池用的是哪种队列、max 设多大再配合线程栈确认线程都在干什么。别一上来就猜业务问题很多“并发异常”其实是线程池配置与业务模型不匹配。6.2 execute 与 submit 的异常差异静默失败的高发区execute(Runnable) 和 submit(Callable/Runnable) 都能提交任务但异常处理路径完全不同这是线上“任务静默失败”的高发原因。execute 提交的任务如果抛出 RuntimeException异常会直接冒出 runWorker导致当前 worker 线程退出。线程池感知到异常后processWorkerExit 会补一个新线程进来。这个异常会打印到 System.err但对业务代码来说几乎是透明的——你没有办法在提交方捕获它唯一可靠的观测点是重写 afterExecute。submit 则不同。任务被包装成 FutureTask异常会被 FutureTask 内部捕获并存起来提交方必须调用 future.get() 才能拿到 ExecutionException。如果没人调 get这个异常就被吞得干干净净日志里什么都看不到。所以我的建议是异步任务要么统一走自定义 handler afterExecute 记录异常要么全部用 submit 并且及时处理 Future。不要混用 execute 和 submit否则排查问题时你不知道哪个任务在哪一层丢了异常。尤其是批量异步任务提交后一定要集中等待 Future 完成并对异常做分类处理。6.3 线程池关不上的两个陷阱泄漏与退出卡住线程池“关不上”是另一个高频问题常见原因有两个。第一是线程池泄漏。比如在方法里每次请求都 new 一个 ThreadPoolExecutor执行完既不 shutdown也没有把实例放进容器管理。时间一长线程数不断累积内存和句柄都被耗尽。这种问题用 jstack 看线程名最明显——大量同类前缀的线程堆在等待队列的 take 上。解决方式是让线程池全局单例由 Spring 容器或静态字段管理生命周期而不是每次用都新建。第二是退出卡住。应用要关闭线程池调了 shutdown 但进程一直不退出。常见原因有两种池里还有线程在跑长任务或者队列里还有大量任务没消费完。shutdown 本来就会等待这些任务完成如果业务本身没有结束点进程就会一直挂着。正确做法是使用 shutdown awaitTermination 组合等待超时后对未完成任务做兜底比如取消、落库再配合应用关闭钩子统一处理。另外ThreadFactory 会把线程创建成非守护线程如果池不关闭JVM 也会因为存在非守护线程而拒绝退出。6.4 问题定位速查表把高频问题整理成一张速查表排查时直接对着看能省不少时间现象可能原因检查方法处理建议线程数涨到最大但队列空使用了 SynchronousQueue打印队列类型与 activeCount换有界队列或调低 max任务被大量拒绝队列满 线程满看拒绝策略异常次数增加队列容量或调大 max否则换 CallerRunsPolicy核心线程不断被回收allowCoreThreadTimeOut 被开启检查配置项按业务决定是否关闭此选项提交后长时间不执行队列积压严重查看 queueSize缩短单任务耗时或拆分任务任务异常但没有日志submit 后未调 get检查 afterExecute 是否有埋点统一 afterExecute 记录异常进程退出卡住线程池未优雅关闭jstack 看存活线程shutdown awaitTermination 兜底最后说点个人体会。线程池这种东西源码读三遍不如自己把状态流转图画一遍。我当初就是在纸上把 RUNNING 到 TERMINATED 的路径、execute 的三次机会、getTask 的四个出口全部画完才真正把“线程池”三个字从 API 变成模型。学完原理之后要做的第一件事就是去看看生产环境里线程池的监控指标——activeCount、queueSize、completedTaskCount、拒绝次数这四个数字组合起来基本能解释掉绝大多数线程池疑难杂症。掌握这套底层逻辑再遇到诡异问题先别猜抓线程栈、看队列深度、看拒绝计数根因通常就藏在组合的数据里。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进