咱们做后端的朋友,大概率都被问过“线程池的执行流程”,面试八股里它排得上号,真正写业务代码时却总有同事把核心参数拍脑袋一填,队列和拒绝策略随便选个默认值。可线程池这东西,一旦流量上来,执行流程没吃透,线上就会出现任务疯狂堆积、线程数忽高忽低、拒绝异常刷屏这一连串问题。我在这上面栽过跟头,后来花了几个晚上把源码和实际场景对着捋了一遍,才算彻底搞明白。
这篇文章我不会只给你画一张流程图背答案,而是会把线程池从“任务提交”到“真正执行”的每一步都掰开揉碎,顺便把阻塞队列选择、拒绝策略、参数合理配置这些热搜榜上的关键词一并串起来讲。无论你用的是 Java 自带线程池,还是自己在 C++ 工程里用任务队列和条件变量实现线程池,底层那套执行流程的思路都是相通的。看完这一篇,你再遇到线程池相关的问题,至少能说出个所以然。
1. 线程池的整体结构与核心参数
1.1 线程池为什么值得单独研究
很多同事觉得线程池不就是“创建一个池子,往里面丢任务”嘛,这想法没错,但丢进去之后怎么分配、怎么排队、怎么拒绝,里面的门道比你想象中多。线程池存在的意义本质上是解决两个问题:一是控制并发线程数量,避免无限制创建线程导致系统资源耗尽;二是复用已经创建好的线程,减少线程创建和销毁的开销。
从执行流程的角度看,线程池内部其实就是一个生产者-消费者模型。提交任务的业务线程是生产者,池子里的工作线程是消费者,而阻塞队列就是中间的缓冲区。理解了这三个角色,你再看后面的执行流程就不会晕。很多老电影里仓库发货是一个道理:订单来了先看有没有空闲发货员,有就直接发;没有就看仓库有没有货位(队列)能暂存;货位也满了,再考虑临时加人;加人也有上限,实在不行就只能拒单。
1.2 五个核心参数逐一拆解
Java 的 ThreadPoolExecutor 构造函数里那六个参数(五个核心 + 一个线程工厂)别嫌多,每一个都在执行流程里起着作用。我把它们整理成一张表,方便直接对照:
| 参数 | 作用 | 执行流程中的位置 |
|---|---|---|
| corePoolSize | 核心线程数,即使空闲也保留的线程数量 | 流程中最先检查的阈值之一 |
| maximumPoolSize | 最大线程数,线程数量的上限 | 队列满了之后才会走到这里 |
| keepAliveTime | 非核心线程空闲后的存活时间 | 超过它就会被回收 |
| workQueue | 任务等待队列 | 核心线程忙时缓冲任务的缓冲区 |
| threadFactory | 线程工厂 | 创建新线程时的“生产线” |
| handler | 拒绝策略 | 线程和队列都饱和后的兜底方案 |
这里要特别强调一个容易误解的点:核心线程数并不是一上来就把全部线程创建好的,默认情况下线程池刚创建时一个线程都没有,看着任务提交了才会创建核心线程。你可以通过 prestartAllCoreThreads() 让线程池提前把核心线程都拉起来,这个在接口对首请求延迟敏感的时候非常有用。
1.3 线程工厂与被忽略的 prestartAllCoreThreads
线程工厂容易被忽略,但它直接影响问题排查效率。默认的 DefaultThreadFactory 创建的线程名字是 pool-1-thread-1,线上日志一多,你根本分不清这个线程是哪个业务线程池里的。我的习惯是项目里定义一个通用工厂,至少把线程名前缀传进去,比如 order-pool-thread-1、report-pool-thread-1,这样 dump 线程栈时一眼就能定位是哪个业务流程出了问题。
另外,prestartAllCoreThreads 这个方法知道的人不多,但很实用。像一些网关类线程池,如果等请求来了才慢慢创建线程,冷启动阶段并发一高,线程池会经历一个明显震荡,响应时间很难看。提前把核心线程预热起来,可以省掉那段时间的线程创建开销。线程创建本身也是有成本的,包括系统调用、栈空间分配等,虽然现代 JVM 已经优化了很多,但能避免的无谓开销还是尽量避免。
2. 线程池的完整执行流程:一个任务从提交到执行
2.1 源码视角的流程主线
我把 Java 的 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); } return; } // 第三步:队列满了,尝试新增非核心线程 if (addWorker(command, false)) return; // 第四步:新增线程也失败了,走拒绝策略 reject(command); }这段逻辑是理解线程池的重中之重。很多人以为队列满了就拒绝,其实队列满之后还有一步“尝试创建非核心线程”,只有这一步也失败了才会走到拒绝策略。反过来,只要核心线程没满,即使队列是空的,也会优先创建新线程来执行任务,而不是让任务先排队等着,这个特性在突发流量场景下会直接影响你的实时性。
2.2 结合案例拆解流程分岔
光看代码还是太抽象,我举个具体例子。假设一个线程池 corePoolSize=2,maximumPoolSize=5,workQueue 是容量为 3 的有界队列。我用 task1 到 task10 依次说明每个任务进去时会发生什么:
| 提交序号 | 当前状态 | 执行路径 | 结果 |
|---|---|---|---|
| task1 | 线程数=0 < 2 | 创建核心线程 thread1 | thread1 执行 task1 |
| task2 | 线程数=1 < 2 | 创建核心线程 thread2 | thread2 执行 task2 |
| task3 | 线程数=2 已满,队列空 | 入队 | 队列:[task3] |
| task4 | 核心线程满,队列有空间 | 入队 | 队列:[task3, task4] |
| task5 | 核心线程满,队列有空间 | 入队 | 队列:[task3, task4, task5] |
| task6 | 核心线程满,队列已满 | 创建非核心线程 thread3 | thread3 执行 task6 |
| task7 | 队列满,当前线程数=3 < 5 | 创建非核心线程 thread4 | thread4 执行 task7 |
| task8 | 队列满,当前线程数=4 < 5 | 创建非核心线程 thread5 | thread5 执行 task8 |
| task9 | 队列满,当前线程数=5 = 上限 | 走拒绝策略 | 默认抛出 RejectedExecutionException |
| task10 | 同上 | 走拒绝策略 | 同上 |
注意一个细节:task3 到 task5 虽然排进了队列,但队列里的任务不会主动执行,它们要等某个核心线程或非核心线程把当前任务执行完,空闲下来后才会从队列头部取任务继续跑。也就是说,核心线程一旦有空闲,队列里的任务才会被消费,这中间的时间差就是排队延迟。
我再补一个反常识的案例:如果 corePoolSize=2,maximumPoolSize=2,队列容量设成了 0,那么任务来了如果两个核心线程都忙,会直接尝试创建新线程,但明显 maximumPoolSize 已经到顶,于是直接触发拒绝策略。这种情况下线程池实际和“两个串行线程+无穷拒绝”差不多,业务上要尽量避免这种配置。
2.3 C++ 线程池流程的不同之处
C++ 标准库本身没有提供类似 ThreadPoolExecutor 的现成组件,所以工程里通常是自己用 vector 、queue<function<void()>>、mutex 和 condition_variable 搭一个简易线程池。执行流程上有一个明显区别:C++ 线程池的工作线程从初始化开始就会全部创建出来,然后统一阻塞在条件变量上等待任务;任务提交时直接往任务队列里 push,然后 notify 一个线程取任务执行。
这种设计导致 C++ 线程池没有“核心线程数不够先创建”的阶梯概念,线程数要么固定不变,要么只能通过动态创建和销毁去模拟。而且因为缺少内置的拒绝策略,当任务队列超过预设上限时,不能像 Java 那样直接调用 handler 兜底,需要自己在 push 之前判断队列大小然后走自定义逻辑。所以大家聊到“线程池的执行流程”时不要只记 Java 的那套,把思路抽象出来,不管是什么语言,本质上都是:提交任务 → 看有没有空闲工作线程 → 没有就排队 → 队列满就扩容或拒收。
3. 阻塞队列选择与参数配置
3.1 常见阻塞队列对比
阻塞队列在整套执行流程里承担的是缓冲角色,选择哪种队列直接决定了线程池在面对不同压力时的表现。我整理了开发中最常见的几类队列:
| 队列 | 是否有界 | 特点 | 典型使用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 基于数组,可指定公平性 | 系统资源敏感、需要严格控流 |
| LinkedBlockingQueue | 默认无界,可设容量 | 基于链表,吞吐量较高 | 默认的 Executors.newFixedThreadPool 使用无界版本 |
| SynchronousQueue | 不存储元素 | 每个任务必须直接交接给线程 | 适合任务多且执行快的场景 |
| PriorityBlockingQueue | 无界 | 按优先级取任务 | 需要任务优先级排序时 |
| DelayQueue | 无界 | 延迟到指定时间才能取出 | 延迟任务、定时任务场景 |
很多人直接使用 Executors 静态工厂创建的线程池,比如 newFixedThreadPool,底层默认用的是无界 LinkedBlockingQueue。这个队列表面上方便,实际上非常危险:任务提交速度超过消费速度时,队列里的任务会无限堆积,最终造成内存溢出。线上看到 JVM 堆内存异常增长,dump 出来一堆 Runnable 对象,十有八九就是踩了这个坑。
3.2 不同场景下如何选队列
选队列不能只看名字,得结合你的任务特征和可接受的最大排队等待时间。我做过的几个项目里,有一条比较实用的经验:如果任务是 CPU 密集型的,比如大量的纯计算,那队列长度不宜太长,因为任务执行本身不涉及外部 IO,堆积在队列里的任务得不到 CPU 执行只会白白占用内存;如果任务是 IO 密集型的,比如调用外部接口、读写数据库,那可以适当放宽队列长度,因为单个任务占用 CPU 时间短,排队等待通常不会造成明显的吞吐下降。
SynchronousQueue 也是很多人忽略的选择。它不存储任务,任务到达时必须立即交给某个工作线程,如果当前所有线程都忙,就会触发创建新线程的逻辑,直到达到 maximumPoolSize。这种队列适合那些要求任务不能被长时间延迟的请求型场景,配合较大的 maximumPoolSize,可以让线程池表现出“弹性扩容”的效果。但你心里要清楚,这会把流量压力直接转换为线程数量,线程过多时上下文切换成本很高,要结合机器 CPU 核数来控制上限。
3.3 队列容量对执行流程的直接影响
队列容量是一个经常被拍脑袋决定的参数,但它对执行流程的影响非常直接。假设 corePoolSize=4,maximumPoolSize=8,队列容量设为 100,正常情况下前 4 个任务会直接创建核心线程执行,后续 100 个任务进入队列等待,再往后才会创建第 5 到第 8 个线程。如果队列容量设为 10,那么任务提交到第 15 个左右时就开始创建非核心线程了,线程数量上升得更快。
这里有一个容易踩的细节:队列容量只有在任务瞬间积压超过它时才会触发非核心线程创建,但大部分场景下任务提交速率是波动的,队列往往不会真的塞满,因此你配置的 maximumPoolSize 实际上可能永远用不上。如果希望非核心线程更早参与处理,可以把队列容量调小,或者干脆用 SynchronousQueue,让它在队列“满”的那一刻就扩容。压测时观察线程数的实际曲线,你会发现这个参数对流程走向的影响比想象中大得多。
4. 拒绝策略与线程池关闭
4.1 四种内置拒绝策略详解
队列满了、线程数也到达上限,新任务就只能交给拒绝策略处理。Java 内置了四种策略,各有各的脾气:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛出 RejectedExecutionException | 默认,适合希望立刻感知过载的场景 |
| CallerRunsPolicy | 提交任务的线程自己执行该任务 | 适合调节流量,让业务方自己感受压力 |
| DiscardPolicy | 直接丢弃任务,无异常 | 适合允许丢失的任务 |
| DiscardOldestPolicy | 丢弃队列中最早的任务,再重新提交 | 适合追求最新任务的场景 |
CallerRunsPolicy 我单独说几句。它的特点是任务不会丢,但提交任务的线程会被迫停下来执行这个任务,比如某个业务线程本来在循环提交任务,结果自己被拉去跑任务了,提交速率自然会下降。这种策略相当于把“压力”反推回调用方,非常有意思。但我踩过一个坑:如果线程池被某个请求线程调用,而该任务里又接着调用了同一个线程池的另一个方法,就可能出现递归调用,把调用线程压垮。
4.2 自定义拒绝策略的正确姿势
内置策略不满足业务需求时,可以自己实现 RejectedExecutionHandler。这个接口只有一个方法,签名很简单,但你能做的事情很多。我写过一个方案:拒绝时先尝试把任务信息序列化到本地临时文件或消息队列,同时记录一条告警日志,相当于给任务加了一个“保险丝”。
public class CustomRejectedPolicy implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof FutureTask) { // 如果有真正代表业务请求的任务包装类,可以在这里转储 log.error("task rejected, pool={}, queue={}, active={}", executor.getPoolSize(), executor.getQueue().size(), executor.getActiveCount()); // 落库或发送告警 } // 注意不要轻易吞掉异常,要配合监控感知到问题 } }自定义策略的核心原则是:要么让任务不丢且可追踪,要么至少能触发监控告警。直接在里面打一行日志然后 return,实际上和 DiscardPolicy 没区别,线上问题会被悄无声息地吞掉,这种“假处理”比抛异常更可怕。
4.3 shutdown 与 shutdownNow 对执行流程的影响
线程池的生命周期也会影响执行流程。shutdown 方法调用后,线程池会停止接收新任务,已经提交到队列的任务继续执行完;shutdownNow 则会尝试中断正在执行的任务,并返回尚未执行的任务列表。这个区别在平滑关闭时非常关键。
我在一次灰度发布时遇到过这个情况:服务正在处理一批异步任务,运维直接把线程池 shutdownNow 掉,正在跑的任务抛了中断异常,队列里剩下的任务全被丢弃,业务数据漏了一大片。后来改成 shutdown + awaitTermination,给了任务一个缓冲时间,问题就解决了。代码如下:
executor.shutdown(); try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }注意 awaitTermination 的返回值要判断,如果等待时间内没有结束,再考虑强制关闭。这算是一个优雅关闭的标准模板。
5. 参数合理配置的思路
5.1 为什么没有万能公式
网上流传着“CPU 密集型就 N+1,IO 密集型就 2N”的说法,这里 N 是 CPU 核数。这个公式作为起点没问题,但你把它当万能答案就危险了。因为现代应用几乎没有纯 CPU 密集型或纯 IO 密集型的任务,一个任务可能前半段在等接口,后半段在本地做计算,再加上 JVM 里的垃圾回收、锁竞争这些因素,公式算出来的值只能作为初始参考。
我见过一个后端服务,机器是 8 核 16G,按照 IO 密集型的公式把线程池配成 16,结果压测时发现线程数到了 16 后 RT 不降反升,最后通过火焰图定位到这些线程都在竞争同一个数据库连接池,真正的瓶颈根本不是线程数不够,而是连接数被占满了。所以参数配置一定结合系统整体资源来看,线程池只是链路里的一环。
5.2 一条可落地的配置流程
我现在的习惯是:先估算任务单次执行耗时和任务提交速率,再用公式算初值,最后一定通过压测修正。具体分四步走:
- 拿到目标机器 CPU 核数 N,估算任务类型,偏 IO 的先用 2N,偏 CPU 的先用 N+1。
- 根据业务容忍的最大排队时间反推队列容量。比如任务平均执行 50ms,最多容忍排队 2 秒,那队列容量可以控制在 40 左右。
- maximumPoolSize 不要随意设成几百,需要根据压测观察系统负载,通常设置为核心线程数的 1.5 到 2 倍以内。
- 上线前后用递增并发去压测,观察线程活跃数、队列积压量、CPU 使用率,再逐次调整。
一个典型的配置示例长这样:
ThreadPoolExecutor orderPool = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new NamedThreadFactory("order-pool"), new ThreadPoolExecutor.CallerRunsPolicy() );我这里没有把线程名写死,而是用自定义的 NamedThreadFactory,后面排查线上问题时能省下不少时间。
5.3 动态调参与监控
线程池参数不是配完就不动了。ThreadPoolExecutor 提供了 setCorePoolSize 和 setMaximumPoolSize 方法,可以在运行时动态调整,这让“自动扩容”成为可能。我见过有些团队实现过一个简单的动态调参任务:定期检查任务积压量,超过阈值就调大核心线程数,空闲时间久了再调回来。
不管调不调,监控一定不能少。最基础的四项指标要盯住:当前线程数 getPoolSize、活跃线程数 getActiveCount、队列积压量 getQueue().size()、以及任务完成总数 getCompletedTaskCount。结合这些数据,才能判断线程池当前是健康、过载还是空转。我在生产环境见过一种很有意思的现象:线程池活跃线程数长期为 0,但任务队列一直不为空,说明线程数被配错了,任务都在排队没人消费,这种异常你不看监控根本发现不了。
6. 常见问题与排查技巧实录
6.1 线程数上不去 / 任务堆积排查
一种非常典型的现象是接口 RT 越来越高,线程池的队列积压肉眼可见地增长,但线程数却不涨。这时候先别怀疑代码,很可能你的线程池配置里 maximumPoolSize 被设置得很大,但队列容量也很大,任务还没把队列填满,根本走不到创建非核心线程那一步。前文说过,流程是先塞队列再扩线程,队列容量直接决定了线程扩容的触发时机。
还有一种可能性是核心线程数本身就已经等于最大线程数,所有线程都忙,执行流程卡在“队列满”之前的等待阶段。可以先用 jstack 抓一份线程栈看线程都阻塞在哪里,再用 jstat 或监控面板确认 GC 和内存情况。大部分时候这种问题都是配置和实际负载不匹配,不一定是代码 bug。
6.2 线程数超过核心线程数但队列还没满?
这个现象看起来很反常:队列明明还有空间,线程数却已经超过 corePoolSize 了。我一开始也困惑过,后来排查发现原来是线程池被多个地方共用,有人在线程池创建后又手动调了 setCorePoolSize,把核心线程数悄悄改大了。另外,当任务执行时间极短、提交速率极高的时候,前一个任务刚占用了线程资源,后一个任务提交时队列虽然有空位,但 addWorker 的逻辑仍然可能走扩容分支,因为此时队列 offer 的竞争窗口非常短。
说白了这个现象并不是执行流程出了问题,而是“看起来很矛盾”其实是时序上的竞争,真正的判断依据还是要靠日志和监控数据。排查这类问题建议把线程池的各项运行指标记录到框架里,不要每次出了问题才临时抓现场。
6.3 CallerRunsPolicy 导致接口耗时暴涨
CallerRunsPolicy 是一个看起来保平安但实际容易引发连锁反应的策略。它不会抛异常,任务也一定能执行,但它把任务丢回给调用线程,等于是让业务接口自身去处理积压。如果这个任务耗时比较长,调用线程就被“绑架”了,接口 RT 自然飙升。
我遇到过一个真实案例:晚上有批处理任务把线程池塞满了,之后进来的正常业务请求也被 CallerRunsPolicy 挡住,结果接口平均耗时从 50ms 涨到了 2 秒,上游服务直接打出了超时熔断。后来我在自定义拒绝策略里加了限流判断,拒绝时先返回一个过载标志,让业务方决定要不要降级,而不是让请求线程自己去排队执行任务。
6.4 线程池中的 ThreadLocal 污染问题
这个问题虽然不直接影响执行流程,但它会污染你在线程池里跑的任务结果。线程池里的工作线程是长期复用的,如果任务 A 往 ThreadLocal 里写了一个值,任务 B 复用了同一个线程,B 一进来就可能读到 A 留下的“脏数据”。尤其在用了像 InheritableThreadLocal 这类会跨越线程传递值的工具时,问题会放大,子线程从父线程拷贝的上下文指不定是哪个旧请求的。
解决思路有两条:一是任务入口处主动清理,二是给任务包一层装饰器,在 finally 里调用 remove。我之前封装过一个安全任务包装类,本质上就是:
try { task.run(); } finally { threadLocal.remove(); }这个小改动看着不起眼,但能省掉非常多线上诡异的脏数据 bug。做异步任务处理时,这应该成为一种默认防御手段。
写在最后的一点体会
聊到这里,线程池的执行流程基本已经铺开了:任务提交后先看核心线程是否撑得住,撑不住就进队列缓冲,队列满了再扩张线程,扩张到极限就触发拒绝策略。这条主线看着简单,但每一环都会因为参数配置、队列选择和拒绝策略的差异而产生完全不同的行为。我自己最大的收获是,以后碰到线程池问题不会再急着改参数,而是先顺着流程把任务当前到底卡在哪一步找出来。
最后分享一个小技巧:ThreadPoolExecutor 留了两个钩子方法 beforeExecute 和 afterExecute,你可以在里面记录每个任务的实际耗时、抛出的异常,甚至打点上报到监控系统。把这两个钩子用好了,你对线程池内部状况的感知能比别人多一层,排障效率也会高不少。