入职第三年,我第一次在线上碰到线程池被打满的故障。业务高峰期,某个核心接口的响应从 200ms 一路飙升到 3 秒,还在往上走,监控面板上线程池的队列深度一个多小时没降下来。事后复盘,根子不在代码逻辑,而在大家对 ThreadPoolExecutor 执行顺序的理解不一致——有人以为核心线程满了就该直接新建线程,有人以为队列永远能兜底,结果参数一配,行为完全不是预期。
其实 ThreadPoolExecutor 这套东西,网上讲原理的文章一抓一大把,但大多数人看完还是糊的:看着源码能读,合上源码就忘。这篇文章我不想从头到尾背一遍官方注释,而是把它的实现原理拆成几个核心问题来讲:线程池到底在解决什么问题、任务进来之后按什么顺序流转、Worker 线程是怎么诞生的、又是怎么被回收的、线程池状态怎么切换、参数该怎么根据场景去配。每一块我都会结合源码、运行机制和实际踩过的坑一起说,适合正在准备面试、刚接手线上系统、或者想自己写一个线程池管理组件的人参考。
1. 线程池设计的四个关键问题:从一次线上事故说起
1.1 为什么需要线程池:线程创建开销与池化思想
先说个最基础的话题:直接new Thread()去执行一个任务,到底慢在哪里。
线程不是纯粹的用户态对象,它背后对应着操作系统的一个内核线程或者轻量级进程。创建一个线程,JVM 要分配栈空间(默认 1MB 左右),操作系统要建立线程控制块、参与调度器的管理。创建和销毁都有开销,销毁阶段还涉及内核对象的释放。如果你是一个高并发接口,每秒来几千个请求,每个请求都走"新建线程 → 执行 → 销毁线程"这条路,CPU 时间就大量浪费在线程本身的创建和销毁上,这是第一笔隐性成本。
第二笔成本是资源损耗。线程多了,光栈空间就要吃不少内存,而且线程上下文切换也会抢占 CPU。假设一个任务本身只执行 2ms,但创建线程花 1ms、销毁又花 1ms,那额外开销占到了整个任务耗时的 50%。
线程池的池化思想就是把线程创建的开销前置。启动时先准备好一批线程,任务来了直接复用,执行完一个任务再去取下一个,线程本身不销毁,直到超时或线程池关闭。这和数据库连接池、HTTP 连接池是一个逻辑:复用比重建便宜得多。
1.2 ThreadPoolExecutor 的构造参数:七个参数每个都是决策点
ThreadPoolExecutor 的完整构造函数有七个参数,很多人背参数名都能背,但没想过每个参数后面到底代表了一种什么权衡。
- corePoolSize:核心线程数,可以理解为池子的"保底兵力"。
- maximumPoolSize:最大线程数,池子能膨胀的上限。
- keepAliveTime + TimeUnit:非核心线程空闲多久后被回收。
- workQueue:任务队列,核心线程忙不过来的时候,任务先放哪里。
- threadFactory:线程工厂,决定线程怎么创建、怎么命名。
- handler:拒绝策略,池子和队列都满的时候怎么办。
从设计角度看,这七个参数其实在回答四个问题:池子里最少留多少人(corePoolSize)、最多能扩到多少人(maximumPoolSize)、人不够的时候任务先放在哪(workQueue)、放不下的怎么办(handler)。keepAliveTime 和 threadFactory 都是在补充边界条件。
很多线上问题都出在"参数之间互相影响"上。比如 maximumPoolSize 设得很大,但 workQueue 用的是无界队列,那么 maximumPoolSize 除了在队列为空的极端瞬间有机会生效,其余时间形同虚设。再比如 keepAliveTime 设太短,业务稍有波动,非核心线程就被回收,下个高峰又重新创建,线程数量反复抖动。
1.3 ctl 字段:一个 Int 变量如何装下状态与数量
看 ThreadPoolExecutor 源码,最先要搞清楚的是一个叫ctl的字段:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static final int COUNT_BITS = Integer.SIZE - 3; private static final int CAPACITY = (1 << COUNT_BITS) - 1;这个设计很巧妙。ctl是原子的,但只用了一个 int 同时存两样东西:高 3 位存线程池运行状态,低 29 位存工作线程数量。为什么能这么做?因为 29 位足够表示 5.36 亿个线程,实际业务里根本不会达到这个量级。
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; }workerCountOf用低 29 位拿线程数,runStateOf用高 3 位拿状态。用一个原子变量把两个经常要同时判断的数据合在一起,就避免了加锁。后面看 execute、addWorker、getTask 的时候会发现,大量并发判断都是基于ctl的 CAS 操作,这就是它的意义所在。
我在看源码时最大的体会是:如果不用 ctl 这种位设计,线程池的并发控制会复杂得多,因为你必须保证"读取状态"和"修改线程数"这两个操作在并发下完全一致。现在一个compareAndIncrementWorkerCount就把"判断数量没超上限并增加计数"合成了单步操作。
2. 任务提交后的完整决策链路:execute() 源码逐行拆解
2.1 四个分支:优先开辟还是先入队?跟直觉不一样
很多人的直觉是:线程不够了,先开新线程,新线程也不行了,再扔队列。但 ThreadPoolExecutor 的真实流程不是这样的。来看 execute 方法的核心四段:
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); }第一步:如果当前工作线程数小于核心线程数,直接尝试新建一个核心线程去执行这个任务。注意这里用的是"尝试",因为 addWorker 内部要 CAS 更新 ctl,一旦失败说明并发下别人已经把核心线程建满了,流程继续往下走。
第二步:如果核心线程已经满,或者刚才 addWorker 失败,就尝试把任务放进队列。放队列之前还加了个isRunning(c)判断——线程池都关闭了,就别往里塞任务了。
第三步:如果队列也放不进去(比如队列已满或用了不存任务的队列),才会尝试addWorker(command, false),也就是新建非核心线程。这里的非核心线程没有保底,空闲超过 keepAliveTime 会被回收。
第四步:如果连非核心线程都建不了,也就是当前线程数已经到了 maximumPoolSize,那么只能走拒绝策略。
所以真实顺序是:核心线程 → 队列 → 非核心线程 → 拒绝。核心线程满了之后,优先把任务排队,而不是无脑扩线程。这个设计背后的逻辑是:线程数是昂贵资源,不能一有流量波动就疯狂建线程,先用队列把波峰缓存下来,尽力用已创建的线程慢慢消化。
队列都进不去再扩非核心线程,这个顺序让很多人栽过跟头。比如一个接口调用第三方服务,单次调用耗时 2 秒,核心线程数 10,队列大小 100,最大线程数 50。并发请求一上来,前 10 个请求占了核心线程,后面 100 个排队,再后面的请求才会触发创建新的非核心线程。如果你以为"最大 50 个线程应该能扛更多并发",那就错了,前 110 个请求都卡在队列里,响应时间必然是队列等待 + 2 秒处理时间。
2.2 任务队列的三类选择:直接影响非核心线程的触发时机
队列类型决定了线程池的行为模式,这一点经常被忽略。Java 里常用的有三类:
| 队列类型 | 是否存储任务 | 典型场景与影响 |
|---|---|---|
| SynchronousQueue | 不存储 | 任务直接交给工作线程,没有等待环节,适合需要低延迟、快速转交的场景 |
| LinkedBlockingQueue | 可无界可有界 | 默认无界时任务可以无限排队,maxPoolSize 参数基本失效 |
| ArrayBlockingQueue | 有界 | 背压能力明确,队列满之后才会触发非核心线程和拒绝策略 |
用 SynchronousQueue 的时候,每个 put 必须等到有 take 才能成功,也就是说没有一个线程空闲等着接任务,offer 就会失败,立刻走 addWorker 创建新线程。这种队列配合一个比较大的 maximumPoolSize,可以做到"任务来了基本不排队"。但代价是线程数会被顶得很高。
LinkedBlockingQueue 如果用无界队列,任务基本不丢,可一旦生产者速度持续高于消费者,队列会越长越大,内存占用持续上涨,而且你设置的 maximumPoolSize 和 keepAliveTime 几乎都变成了摆设,因为根本没有触发时机。这也是很多"配置了最大线程数但没生效"问题的根源。
ArrayBlockingQueue 是有界队列,有明确的容量上限。它是实际项目里推荐优先考虑的,因为可以精确控制任务的等待深度。配合合理的 maximumPoolSize,当队列满时还能临时扩一批线程帮扛压力。
我在实际项目里通常用有界队列,队列大小根据业务的"可接受等待时间"来估:单任务平均处理时间假设 100ms,希望一个请求在队列里的最大等待不超过 1 秒,那队列大小差不多就是 10 个任务量乘以线程数。当然这只是粗略思路,真实的容量需要结合压测数据迭代调整。
2.3 四种拒绝策略:从抛异常到调用者执行
当线程池里的线程已经到达 maximumPoolSize、队列也满了,再提交任务就会触发 RejectedExecutionHandler。JDK 提供了四个现成的实现,但它们的安全级别完全不同。
- AbortPolicy:默认策略,直接抛出 RejectedExecutionException。适合核心业务,起码你能通过异常感知到系统确实过载了。
- CallerRunsPolicy:不丢任务,把任务退回给调用者线程执行。这个策略在实际项目中经常被低估,它天然提供背压,调用者线程被占用,调用速度自然放慢。
- DiscardPolicy:静默丢弃,直接丢弃新任务,不让调用者感知。
- DiscardOldestPolicy:丢弃队列里最老的一个任务,然后尝试重新提交新任务。
从实现上看,AbortPolicy 的代码最简单:
public final class AbortPolicy implements RejectedExecutionHandler { public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString()); } }CallerRunsPolicy 是这四个里我个人最喜欢推荐的一种。它不会让任务凭空消失,也不会打断调用方。代价是调用线程本来要立刻返回,现在却要同步执行一个任务,这会让调用方本身变慢,但对整个系统而言反而是保护——它把压力的源头堵住了。
DiscardPolicy 和 DiscardOldestPolicy 要想清楚才能用。前者适合一些可以容忍丢失、由定时任务周期刷新的场景;后者适合队列里任务的时效性很强、旧的还没执行完就已经失去意义的场景。
需要特别提醒:拒绝策略触发一次通常就是过载信号,不要只抛异常就完事,最好在自定义 handler 里加监控上报,把拒绝次数、任务内容、当时线程池的运行指标打出来。日志里看到 threadPool.rejectCount 开始增长时,基本是容量规划的警告灯亮了。
3. Worker 线程的一生:从创建到销毁的全过程
3.1 Worker 为什么继承 AQS:独占锁与不可重入的含义
线程池里的工作线程不是裸的 Thread 对象,而是被包装成了一个叫 Worker 的内部类。每一个 Worker 持有一个线程,以及一个初始任务(firstTask)。
Worker 继承了 AbstractQueuedSynchronizer,也就是 AQS。很多人不理解:一个干活的工作线程,为什么要搞一个锁出来?
答案很简单,这个锁不是为了保护业务逻辑,而是为了标记"这个线程是不是正在干活"。线程池在 shutdown 或调整参数时,有时需要中断某些空闲线程。如果一个线程正在执行任务,中途被打断很可能导致数据不一致;而如果线程空闲在队列上等待,中断它让它退出就相对安全。
所以 Worker 用自己的 AQS 实现了一个不可重入的独占锁:
- 正常执行任务前会调用
lock(),任务结束在 finally 里unlock()。 - 线程池想中断空闲线程时,先尝试
tryLock(),拿不到锁说明线程正在干活,就不打断它。
这里有一个设计细节:为什么不直接用标准的 ReentrantLock?因为 AQS 语义里如果一个线程持锁后再次请求同一把锁,ReentrantLock 会成功重入,拿不到正确的"忙碌"信号。而 Worker 的自定义 AQS 实现中整个 runWorker 流程只允许锁被持有一次,多余的尝试直接返回失败。这样就天然区分了"正在执行任务"和"空闲等待任务"两种情况。
Worker 构造方法里还有一句:
Worker(Runnable firstTask) { setState(-1); // inhibit interrupts until runWorker this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); }初始化时 state 设为 -1,是为了防止线程在真正开始 runWorker 之前被人中断。如果 Worker 正在构造、线程还没启动,这时候有别的线程调了 shutdownNow 并抢着中断所有工作线程,这个 -1 状态能让中断行为不生效。等 runWorker 里真正要跑任务时,再调用unlock()会把 state 从 -1 恢复为 0。这种细节不读源码很难发现。
3.2 addWorker 的双重检查:状态与数量的并发边界
addWorker 是线程池里最核心的方法之一,它的任务是"按参数要求新建一个工作线程",但并发环境下必须做两件事:检查线程池状态是否允许新建、检查线程数量是否超限。
private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs >= SHUTDOWN && ! (rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty())) 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; } } ... }第一次检查针对状态:线程池已经 SHUTDOWN 以上时,正常情况下不允许再增加新线程。唯一的例外是 SHUTDOWN 状态且队列里还有任务,此时可以额外创建线程来消化队列剩余任务,这就是firstTask == null && !workQueue.isEmpty()的含义。
第二次检查针对数量:核心线程看 corePoolSize,非核心线程看 maximumPoolSize,如果已经达到上限就直接拒绝创建。CAS 成功后跳出循环,接下来才真正构造 Worker 对象、加入 workers 集合、启动线程。
外层 for 循环里的continue retry是我认为最精彩的部分。CAS 失败后重新读取 ctl,如果线程池状态已经变了,就从外层的状态检查重新开始,否则只重试内层数量判断。这种双层循环的设计,保证了状态校验和数量校验在并发场景下不出现"边检查边变化"的窗口。
实际操作中,addWorker 失败通常意味着两种情况:要么是线程数真的到了上限,要么是线程池状态已经不再接受新线程。看到 addWorker 返回 false,紧接着就会进入拒绝流程,这也是很多任务"莫名其妙被丢弃"的原因。
3.3 核心线程会死吗:getTask 与 keepAliveTime 的真相
第一个要纠正的认知是:核心线程并不保证永远存活。默认情况下确实不会因为超时被回收,但它同样会从队列里取不到任务时进入阻塞等待。而一旦设置了 allowCoreThreadTimeOut(true),核心线程空闲超过 keepAliveTime 后也会退出。
Worker 启动后的执行逻辑在 runWorker 里,核心是一个 while 循环,反复调用 getTask 取任务:
try { while (task != null || (task = getTask()) != null) { w.lock(); ... try { task.run(); } finally { w.unlock(); } ... } } finally { processWorkerExit(w, completedAbruptly); }getTask 是理解 keepAliveTime 的关键:
Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take();当线程数大于 corePoolSize,或者允许核心线程超时回收时,timed 为 true,工作线程会从队列里 poll 一个任务,如果 keepAliveTime 内没有任务进来,poll 返回 null,getTask 返回 null,循环退出,线程自然结束。
很多刚接触线程池的人以为 keepAliveTime 是"线程空闲多久就被杀掉",严格说应该是"空闲线程在取不到任务时等待了多久就自杀"。它和队列类型有很强的联动。如果你用 SynchronousQueue,那么只要核心线程都忙,后面每个任务都会创建新线程,当任务处理完了,多余的非核心线程在 keepAliveTime 时间一到就会回收。如果任务持续不断,线程数会一直顶到 maximumPoolSize,直到流量降下来才慢慢回收。
这里有个隐藏问题:如果核心线程数设得很大,而任务又不多,队列使用 take() 会导致这些核心线程全部阻塞在队列上。虽然这不消耗太多 CPU,但每个线程占着栈内存,数量一旦上万依然很可观。所以我一直建议不要盲目设置大核心线程数,宁可队列稍大、非核心线程做缓冲,也不要一上来就预留几百个线程睡觉。
还有一个非常经典的陷阱:任务队列用无界队列时,getTask 里的 poll 超时回收几乎不会触发,因为线程数永远不会超过 corePoolSize,timed 永远是 false,所有线程都在 take() 上无限等待。大白话讲就是 keepAliveTime 根本没机会生效。
4. 线程池状态机与优雅关闭流程
4.1 五种线程池状态:RUNNING 到 TERMINATED 的迁移规则
ThreadPoolExecutor 定义了五种线程池运行状态。它们不是随意定的名字,而是对应了不同的行为边界。
| 状态 | 含义 | 接受新任务 | 处理队列任务 | 中断工作线程 |
|---|---|---|---|---|
| RUNNING | 正常运行 | 是 | 是 | 否 |
| SHUTDOWN | 关闭中 | 否 | 是 | 否 |
| STOP | 紧急停止 | 否 | 否 | 是 |
| TIDYING | 收尾中 | 否 | 否 | 否 |
| TERMINATED | 已终止 | 否 | 否 | 否 |
状态迁移的路径是单向的,只能往后走,不能回退。RUNNING → SHUTDOWN 靠 shutdown();RUNNING 或 SHUTDOWN → STOP 靠 shutdownNow();SHUTDOWN 且队列为空、或者 STOP 后线程全退出,就进入 TIDYING;最后执行 terminated() 钩子方法,进入 TERMINATED。
为什么要有这么多种状态而不是一个布尔标记?因为关闭线程池不是瞬时行为,正在执行的任务需要给一个缓冲周期,队列里堆积的任务也可能需要被消化完。状态机存在的意义就是让"关闭"这件事变成分阶段、可控的过程。从 ctl 的高 3 位设计也能看出来,状态和线程数是绑定在一起的,比如线程池进入 TIDYING 的前提是 workerCount 已经变成 0,这两件事必须作为一个整体原子地观察和修改。
4.2 shutdown() 与 shutdownNow() 的差别:谁还在等,谁被打断
很多人把 shutdown 和 shutdownNow 混着用,觉得都是"关线程池",但实际行为差异非常大。
shutdown() 会把状态改为 SHUTDOWN,不再接受新任务,但队列里已有的任务会继续执行完。它是温柔关闭,能保证已经提交的任务不丢。如果线程池还有线程在 take() 上等待,shutdown 会让它们继续把队列取空,取空之后才陆续退出。
shutdownNow() 则把状态改为 STOP,它会中断所有工作线程,并返回当前队列里尚未执行的任务列表。只调用方需要自己决定这些未执行任务怎么办。注意这里的中断是"调用了 interrupt()",但如果任务里没有对中断响应,比如正在执行一个不处理 InterruptedException 的循环,线程实际还是会继续跑下去。这也是很多人误解的地方——shutdownNow 不是"强杀线程",只是发送了中断信号,接不接收还得看任务代码。
从源码上看,shutdownNow 里有个interruptWorkers(),它会对所有 worker 发起中断。但注意这个中断也会把正在执行任务的线程打断,所以如果任务对中断敏感,可能执行一半就退出。再看 Worker 里的 tryLock 机制,在 shutdown 场景里中断的是空闲线程;shutdownNow 场景里不再区分空闲与否,所有 worker 都会收到中断。
从使用经验来看,shutdownNow 返回的未执行任务列表一定要正确处理。常见做法是拿到这些 Runnable 之后,转交给另一个线程池或者持久化到消息队列里,等待后续补偿。否则下次数据对账就会发现一批任务凭空消失。
4.3 优雅关闭的正确姿势与常见误区
优雅关闭线程池在线上通常要分成三步:
第一步,调用 shutdown(),禁止新任务提交,让队列里的存量任务继续消化。第二步,调用 awaitTermination(timeout, unit),阻塞等待线程池真正终止。第三步,如果超时还没终止,再调用 shutdownNow(),强制中断残余任务,并对返回的未完成任务做补偿处理。
这个流程的本质是:先给存量任务一个"自然死亡"的窗口,只有在这个窗口内没完全结束,才启动强制的后续手段。网上说的"先 shutdown 再 shutdownNow"就是这个套路。
executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); List<Runnable> remaining = executor.shutdownNow(); // 补偿处理剩余任务 }上面这段有个细节需要注意:如果第一次 awaitTermination 超时说明可能已经有任务卡住了,我一般会再调一次 shutdownNow,然后对 remaining 做持久化补偿。但这段代码里两次调用都在同一段流程中可能看起来重复,实际应用中我会把第二次 shutdownNow 的结果妥善落库或者转发到备份队列。
还有一个高频误区:线程池的线程设置成非守护线程,Spring 容器关闭时如果没有主动关闭线程池,整个进程会一直卡着无法退出。很多应用部署时的停机时间过长,排查下来经常是某个线程池没有放在 destroy 逻辑里。
5. 参数配置、避坑清单与实际监控经验
5.1 线程数到底怎么定:从 CPU 密集到 IO 密集的估算思路
配置线程池参数,最难的是回答一个问题:corePoolSize 和 maximumPoolSize 到底设多少。
对 CPU 密集型任务,线程数一般建议是 CPU 核数 N 或者 N+1。加 1 的原因是让某个线程偶尔发生页缺失、IO 等待时,其他线程能补位。这个公式背后的原理很简单:CPU 密集型任务的耗时几乎都在计算,线程数超过核心数之后,多出来的线程只是在抢时间片,刷新了"线程切换时间占比"这个指标,吞吐量反而可能下降。
对 IO 密集型或阻塞型任务,真正经典的公式是:线程数 = CPU 核数 × (1 + 平均等待时间 / 平均计算时间)。这里等待时间包括远程调用耗时、数据库等待、锁等待等。举个例子:四核机器,任务平均执行 10ms,其中 2ms 在 CPU 计算,8ms 在等下游响应。那么线程数 = 4 × (1 + 8/2) = 20。
为什么这么算?因为这个任务在 10ms 的执行周期里只有 2ms 在占用 CPU,其余 8ms 线程闲着。想要把四核的 CPU 用满,需要 4 / (2/10) = 20 个线程才能让任务重叠起来。这个公式是我平时做容量评估的起点,然后通过压测往上调整。
队列大小我习惯配合可容忍的排队时延来定。假设每个任务平均处理时间是 10ms,希望队列等待不超过 2 秒,那队列里最多允许存 200 个任务。当然实际业务中任务处理时间会波动,所以我会把估算值再乘上一个安全系数,同时监控队列深度变化来不断校正。
5.2 一份常见问题与排查速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 线程数一直卡在 corePoolSize,maxPoolSize 没生效 | 使用了无界队列,任务永远不会触发非核心线程 | 检查 workQueue 类型,改用有界队列 |
| 任务频繁被拒绝 | 队列太小或 maximumPoolSize 太低 | 看拒绝次数与队列深度,调整参数或做容量扩容 |
| 线程反复创建销毁 | keepAliveTime 太短、流量波动大 | 调大 keepAliveTime,或设置 allowCoreThreadTimeOut(false) |
| 应用停机很慢 | 线程池没有优雅关闭 | 在容器销毁逻辑里调用 shutdown + awaitTermination |
| 内存持续上涨 | 无界队列堆积任务 | 改用有界队列,并监控队列积压 |
| 线程池任务执行异常却看不出原因 | 没有自定义 RejectedExecutionHandler | 加日志、加监控,至少记录任务信息和池状态 |
有一个排查经验值得单独提出来:线上如果出现线程数一直不增长、但队列在膨胀的情况,不要急着加 maximumPoolSize,要先去看队列类型。无界队列场景下加 maximumPoolSize 是无效操作,因为任务根本没有机会走到创建非核心线程的分支。
5.3 线上监控的三个轻量方案与阅读源码的建议
线程池监控我不太建议一上来就引入很重的监控平台,先做轻量级的活体监测就够了。最简单的方案是开一个定时任务,定期打印线程池的几个关键指标:活跃线程数、队列大小、已完成任务数、拒绝次数。
我用过一种比较顺手的方式,在封装线程池管理组件时,把 ThreadPoolExecutor 包一层,定时输出它的明细到日志,同时提供一个指标接口供监控系统拉取。核心指标有四个:getActiveCount()、getQueue().size()、getCompletedTaskCount()、getTaskCount()。这四个指标组合起来能看出很多事情。比如活跃线程数接近 corePoolSize、队列在持续增长,说明处理能力跟不上了;完成数不增长说明任务已经卡住。
再进一步,可以监控线程池生命周期中的关键事件。用 ThreadPoolExecutor 自带的钩子方法 beforeExecute、afterExecute 可以记录单任务执行时间,用来发现长尾任务。这两个钩子方法在源码里默认是空实现,留给子类覆盖。很多真实的性能问题就是从"某个任务执行时间特别长"开始的。
关于读源码,我建议先读 execute、getTask、runWorker、addWorker 这四个方法的完整代码,再去看 Worker 的内部实现。不要按文件一行一行背,而是带着问题去读:任务怎么进来、线程怎么出来、线程怎么处理队列、线程怎么退出。把这四个问题串起来,线程池的骨架就清楚了。之后再补充看 shutdown 和 tryTerminate 之类的状态流转方法,整个知识面就完整了。
按我个人经验,线程池的问题很少是单个参数导致的,更多是参数之间配置互相打架。看代码时关注 ctl 的高三位和低 29 位如何被各种方法读取和修改,就很容易理解为什么这里要 CAS、那里要双重检查了。真正用过一段时间、在线上排过几次故障之后,这些设计就不再是死记硬背的知识点,而变成了顺理成章的东西。