线程池这东西,Java面试十次有八次会问,工作里十个线程池有七个参数是从网上抄的。我刚开始写Java那会儿也是这样,corePoolSize、maximumPoolSize、workQueue、handler背得滚瓜烂熟,interview的时候能把“线程池的七大参数”倒着背出来,可真到线上排查问题的时候照样满头问号——为什么任务积压了就是不发散?为什么核心线程设置成10个,跑起来一直是1个?后来把ThreadPoolExecutor源码从头到尾翻了几遍,又在生产环境观察了各类业务线程池的指标波动,才算真正把这套机制串起来。
这篇不是想复制一遍官方注释,而是从“线程池到底在解决什么问题”讲起,再把参数、队列、拒绝策略、常见坑逐个拆开揉碎,最后聊点参数配置的经验。无论你是准备面试还是被线上线程池坑过,应该都能在里面找到一点能直接拿去用的东西。
1. 先搞明白线程池到底在解决什么问题
1.1 从手动创建线程的痛说起
先想一个最原始的场景:请求来了就new Thread(...).start(),任务跑了就完事。这种写法在并发量小的时候没毛病,但并发一上来,问题立刻暴露。
第一,线程创建和销毁是有开销的。JVM创建一个线程,涉及到操作系统分配内核线程、分配栈内存(默认1MB)、注册到线程调度器,频繁创建销毁对GC和CPU都是不小的负担。打个比方,你不可能每一次面试都临时租个办公室,大概率是长期租一间会议室,随用随进。
第二,并发数不可控。没有上限地new Thread,一旦请求洪峰来了,几百个几千个线程同时跑,CPU疯狂上下文切换,内存也扛不住,直接OutOfMemoryError: unable to create new native thread,服务当场躺平。
第三,缺乏统一的任务管理与调度。你想优雅停掉一批任务?想限制同时执行的任务数?想做个任务队列让高峰期排队?手写new Thread全都要自己实现,而且极容易出bug。
线程池把这三件事一次解决了:复用线程、控制并发规模、通过队列缓冲任务。这就是它存在的意义。
1.2 ThreadPoolExecutor的七大参数,别只背定义
Java标准的线程池实现是ThreadPoolExecutor,它的构造参数有七个,网上八股文都快背烂了。但很多人背完定义依然不知道怎么配,问题出在只记了名词,没理解参数之间的协作关系。
表格先给出来,后面我挨个讲:
| 参数 | 作用 |
|---|---|
| corePoolSize | 核心线程数,默认常驻存活,即使空闲也不回收(除非设置allowCoreThreadTimeOut) |
| maximumPoolSize | 最大线程数,线程池允许创建的最大线程数量 |
| keepAliveTime | 非核心线程空闲存活时间;如果allowCoreThreadTimeOut=true,也作用于核心线程 |
| unit | keepAliveTime的时间单位 |
| workQueue | 任务队列,核心线程忙不过来时,任务先塞到这里 |
| threadFactory | 线程工厂,决定线程名前缀、是否daemon,强烈建议自定义 |
| handler | 拒绝策略,任务太多队列也满线程也满时的处理方式 |
参数之间不是孤立的。核心线程数决定“常备兵力”,最大线程数是“战时可扩编的上限”,任务队列是“缓冲区”,拒绝策略是“缓冲区也满了的兜底方案”。
1.3 别直接抄Executors的快捷方法
Executors类提供了一堆现成方法,比如newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor,很多人图省事直接用它。
但这里面坑很大。newFixedThreadPool用的队列是LinkedBlockingQueue,默认容量是Integer.MAX_VALUE,也就是说队列几乎无界。任务疯狂往里塞,核心线程Reject了也不会创建更多线程(因为队列永远没满),最大线程数形同虚设。newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE,SynchronousQueue又不会积压任务,请求一多线程数直接奔着几千上万去了。
真实项目中,我个人的习惯是直接用new ThreadPoolExecutor(...)手动配置,把ThreadFactory也一起实现了。这样参数是显式的,每个线程池承担什么业务、允许什么行为,一眼就能看清。
2. 核心机制原理:参数之间如何配合
2.1 一个任务从提交到执行的完整闭环
理清线程池执行逻辑,最简单的方式就是跟随一个任务的“视角”走一遍完整闭环。假设我们配置了corePoolSize=2、maximumPoolSize=4、队列容量=3,线程池刚启动,任务A到E先后提交。
任务A进来,发现当前工作线程数=0,小于corePoolSize,直接创建一个核心线程去执行。任务B同理。
此时两个核心线程都在忙,任务C进来了。线程数已经等于核心线程数,任务不会立刻再创建线程,而是放进队列。任务D、E也是进队列排队。
任务F进来时,队列已经满了(容量3)。此时线程数=2,小于max=4,触发扩容,创建一个非核心线程(线程3)去执行任务F。任务G进来,队列满,再扩容创建线程4。
任务H进来,队列满、线程数=4已经到顶,所有线程都在忙,走拒绝策略。
这个流程就是ThreadPoolExecutor最核心的判断逻辑:先看核心线程,满了再看队列,队列满了再看能不能扩到最大线程数,扩不了就拒绝。记住这个顺序,绝大多数的参数配置问题都能理清。
注意:任务进队列,而不是立刻创建新线程,是刻意的设计选择。队列的本质是“缓冲”,它让线程池在突发流量下不会瞬间把线程数拉到峰值,而是给线程利用率和响应速度之间留了一个动态调节区间。先用缓冲把任务接住,能消化的消化,消化不了再扩兵,这种“先排队再扩编”的思路,远比无脑拉满线程数要稳。
2.2 为什么“先放队列,再扩线程”而不是直接扩线程
很多人第一次看到这个执行逻辑时会疑惑:既然队列满了才扩容,为什么不在核心线程满了之后立刻创建新线程?线程数上去了处理速度不是更快吗?
这个问题要分场景看。假如你不设队列、或者队列特别小,线程池会非常敏感,任何短时流量波动都会触发线程扩容。比如核心线程2个,突然来了几十个请求,线程数立刻拉到最大,但很快流量降下来,这些线程又在keepAliveTime内空闲销毁。线程的创建和销毁本身就是成本,高频扩缩容会让系统的线程抖动非常剧烈,整体性能反而下降。
加入队列后,线程池面对突发流量有了“缓冲带”。任务先排队,用户请求不会直接被打回去,而是等待核心线程逐步消化。只有持续的高流量让队列真正“溢出”,线程池才会逐步扩容到最大值。这是对任务调度的一种平滑处理。
另外,队列的容量大小直接决定了“缓冲区”的深度。队列配得越大,任务等待时间越长,线程扩容越“迟钝”;队列配得太小甚至为0,线程池又几乎没有缓冲能力。配置的时候,这两者必须联动考虑。
2.3 线程池状态与线程数,为什么藏在一个int里
ThreadPoolExecutor里有个非常巧妙的设计:用一个AtomicInteger ctl同时保存线程池的运行状态(runState)和工作线程数(workerCount)。
其中高3位用来存runState,低29位存workerCount。之所以这么干,是为了让这两个状态可以原子地一起更新。比如一次CAS就能把“线程池变成STOP状态”和“线程数重置为0”的操作合在一起,避免分布式锁或者多步同步带来的状态不一致。
线程池的状态包括:RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。SHUTDOWN后不再接收新任务,但会继续处理队列里的已提交任务;STOP会直接中断任务并清空队列;TIDYING时任务清零正在执行terminated()钩子方法;TERMINATED是最终结束状态。
日常使用中,我们最常看到的是RUNNING状态。当调用shutdown()方法,线程池进入SHUTDOWN,处理完队列里已有的任务后才会真正结束。这也是为什么“优雅停机”需要时间,不可能调一个方法线程池立刻消失。
3. 从源码看工作流程:execute()那几步到底干了什么
3.1 execute()的四个分支
前面讲的执行闭环,对应到源码里就是execute()方法。把核心逻辑简化一下,大概是这个样子:
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); // 1. 工作线程数小于核心线程数,直接新增核心线程 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2. 线程池还在RUNNING,尝试入队 if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); // double-check:入队后线程池被SHUTDOWN了,要回滚并拒绝 if (!isRunning(recheck) && remove(command)) reject(command); // 线程没崩,但没有工作线程,就补一个空任务的Worker else if (workerCountOf(recheck) == 0) addWorker(null, false); } // 3. 入队失败(比如队列已满),尝试增加到非核心线程 else if (!addWorker(command, false)) { // 4. 扩容失败,拒绝 reject(command); } }第一步看核心线程是否饱和,饱和了才走队列;入队成功之后还有个double-check,避免入队后线程池刚好被关掉或者线程数异常为0,出现“任务放进了队列但没人消费”的情况;入队失败才尝试加非核心线程,加不进去就拒绝。
这里面最妙的是addWorker(command, true)和addWorker(command, false)的布尔参数,它标记是否为核心线程。核心线程和非核心线程的差别,主要体现在闲置回收策略上:普通情况下,非核心线程空闲超出keepAliveTime会被回收,核心线程则不会。但这个差别不在addWorker里,而在后面Worker从队列取任务的环节中。
3.2 Worker干活与线程复用的真相
线程池里的线程被封装成了Worker对象。每个Worker内部持有一个Thread和一个firstTask,firstTask可能为空,表示这个线程启动之后,直接从队列里取任务。
Worker的run方法会循环调用runWorker(this),核心流程简化如下:
try { while (task != null || (task = getTask()) != null) { // 加锁防止shutdownNow之类的中断影响 // 执行任务前会执行beforeExecute等钩子 task.run(); // 执行完清理 } } finally { processWorkerExit(w, completedAbruptly); }一个Worker执行完firstTask后不会退出,而是通过getTask()再去阻塞队列里拿下一个任务。如果队列里暂时没有任务,它会被workQueue.take()或者workQueue.poll(keepAliveTime, unit)阻塞住。take是无限期阻塞,poll带超时时间——核心线程用take,非核心线程用poll,这就是核心线程常驻、非核心线程空闲超时销毁的根本区别。
这个循环解释了一个常见疑惑:为什么线程池里的线程数量不多,却可以处理成千上万的任务。因为线程是常驻的工人,任务是流水线上的货物,工人干完一件会接着拿下一件,而不是干完一个就报废。
getTask()返回null时Worker就会退出,最典型的情况是:当前线程数已经大于核心线程数,且队列里没有任务了,非核心线程阻塞超时,poll返回null,线程自然结束。整个退出逻辑最后还会统计“已完成任务数”,这些指标可以通过ThreadPoolExecutor的getCompletedTaskCount()等接口拿到。
3.3 从执行逻辑看“最大线程数形同虚设”的几个场景
源码看明白了,有些网上常见的问题就瞬间清晰了。
场景一:队列用的是无界队列,比如new LinkedBlockingQueue<>(),workQueue.offer(command)永远成功,那么第三步addWorker(false)永远不会走到。即使maximumPoolSize设置成1000,实际线程数最多也就是corePoolSize,最大线程数永远用不上。之前很多老项目踩过这个坑,线程池写死成20,流量翻倍了也不扩容,排查半天发现队列是无界LinkedBlockingQueue。
场景二:队列容量太大,比如配了10000。极端情况下任务积压到月底都消费不完,线程数也一直停留在corePoolSize不会扩。从监控面板看,线程池的活跃线程永远是核心线程数,queue size却在持续增长。这种情况要警惕“假健康”:线程数正常、CPU不高,但任务处理严重延迟。
场景三:核心线程数设置太小,任务大部分时间都在排队,新增的任务又持续不断,导致队列永远满、线程数接近maximumPoolSize、拒绝策略频繁触发。这种情况就要优先考虑调大corePoolSize或优化单个任务的执行耗时。
4. 阻塞队列怎么选:别见到LinkedBlockingQueue就无脑用
4.1 常见阻塞队列的特性对比
队列在线程池里的角色是“积压任务的缓冲区”,但不同队列的脾气完全不同。我整理了一个对比表:
| 队列 | 是否有界 | 特点 | 适用场景 |
|---|---|---|---|
| LinkedBlockingQueue | 可指定容量,默认无界 | 基于链表,吞吐较高 | 默认选择最多;必须显式设容量,避免无界风险 |
| ArrayBlockingQueue | 有界 | 基于数组,容量固定,FIFO | 适合对等待任务数有严格上限的场景 |
| SynchronousQueue | 无容量,不存储任务 | 每个插入必须等待一个删除操作 | 适合“直接交付”场景,不缓存任务 |
| PriorityBlockingQueue | 无界 | 按优先级出队,不是FIFO | 任务分优先级的特殊场景,需要自定义Comparator |
| DelayQueue | 无界 | 延迟到期才能取出 | 延迟任务、定时任务场景 |
很多人对SynchronousQueue的理解有误区,觉得它是一个“大小为0的队列”,是一个空的容器。其实它根本不是用来存储任务的,而是一个“交接手递手”工具。提交任务给SynchronousQueue,必须等一个空闲线程来取走,否则提交操作就会阻塞。所以newCachedThreadPool配合SynchronousQueue,配合Integer.MAX_VALUE的最大线程数,会产生“来一个任务就建一个线程”的效果,适合大量短时、高频的任务。但也很容易把线程数拉爆,线上谨慎使用。
PriorityBlockingQueue和DelayQueue都是无界队列,搭配线程池使用时,maximumPoolSize基本失效。因为队列永远不会满,不会触发扩容。但这两个队列的特点是“任务按优先级或延迟出队”,在任务本身有级别差异或需要延后处理的场景里很有价值,比如延迟重试任务。
4.2 拒绝策略:四种内置策略与两种实战思路
任务量超过线程池处理上限,最终会走到拒绝策略。ThreadPoolExecutor内置了四种RejectedExecutionHandler:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy | 默认策略,直接抛出RejectedExecutionException | 可能中断业务请求,需要调用方处理异常 |
| CallerRunsPolicy | 不抛异常,谁提交谁自己执行这个任务 | 调用线程被阻塞,相当于把压力传回调用方,形成天然回压 |
| DiscardPolicy | 默默地丢弃新任务 | 任务丢失,无感知,危险 |
| DiscardOldestPolicy | 丢弃队列中最早的任务,然后重新提交当前任务 | 可能丢弃关键任务 |
这四种策略里,我建议绝大多数场景用AbortPolicy或CallerRunsPolicy。AbortPolicy最规范,任务被拒绝就应该明确抛出来,让上层感知到系统过载,而不是悄无声息丢任务。CallerRunsPolicy则适合流量削峰的场景,提交线程自己消化一部分任务,避免任务直接暴毙,但要注意调用线程会被占用,可能降低接口响应速度。
DiscardPolicy和DiscardOldestPolicy我个人几乎不用。丢任务这件事,太容易埋雷了,尤其业务方根本不知道自己的请求已经被丢弃,排查起来极难定位。如果非得用,也建议在自定义handler里记录一条WARN日志,方便事后追踪。
自定义拒绝策略也很简单,实现RejectedExecutionHandler接口即可。我常用的做法是:在rejectedExecution里把任务信息写入Redis或Kafka,配合一个定时任务重新入队,相当于给线程池加了一个“重试通道”。这样既不会硬抛异常打断业务,也不会静默丢失任务。
经验提醒:线上线程池的拒绝日志一定要打出来,并且带上队列长度、当前线程数、活跃线程数、核心线程数这些关键指标。否则出现“大量请求被AbortPolicy打压”时,你连从哪个线程池拒绝的都定位不出来。
5. 常见问题与排查技巧实录
5.1 线程数一直在涨,日志里却没几条执行记录
有个朋友之前在压测环境遇到一个现象:线程池的poolSize一路涨到maximumPoolSize,但日志里实际执行的任务数量却少得可怜,CPU也不高,线程都在干嘛?
排查之后发现,大部分线程卡在外部接口的等待上,比如一个HTTP调用超时设置成60秒,下游服务迟迟不返回,线程就一直BLOCKED在SocketRead上。线程池的任务是“看起来在执行”,实则是“执行一动不动”。这种场景下,线程池扩容再多也没用,瓶颈在下游接口或数据库连接。
排查方法:先jstack看一眼线程栈,如果大量线程在java.net.SocketInputStream.socketRead0或者数据库socket读上,基本可以断定IO等待占了绝大部分时间。此时要解决的是下游超时时间、连接池大小、缓存策略,而不是继续调大maximumPoolSize。
5.2 队列容量显示为0,但任务还是堆积
有次排查一个消息消费线程池,监控面板显示queue.size一直为0,activeCount也上不去,但消息积压却在不断增加。看线程池配置,核心线程数明明设了10个。当时很多同事一脸懵。
后来dump线程栈发现,10个线程里有大半处于LockSupport.park状态,等待一个公用的资源锁——那个锁是一个非线程安全的第三方SDK,开发同学用synchronized给方法加了锁。结果就是:线程池里的线程“全都活着”,但只有1个能进临界区干活,其他9个全部排队等锁。队列里没有额外任务,是因为消息在内存里排队等着拿锁。这种情况下,线程池参数再合理也没有,问题的根子在锁竞争。
这种问题不罕见。以前我也写过“线程池里的活线程看着多,但实际上都在Blocked队列里排队等锁”这种代码。排查一定要先看线程栈,别只盯线程池本身的指标。
5.3 execute还是submit?异常为什么会“消失”
使用submit()提交任务时,任务执行过程中抛出的异常不会直接打印到日志,而是封装在返回的Future对象里。如果你没有调用future.get(),异常就被静默吞掉了,日志里看不到任何报错,任务却悄悄挂掉。
这点非常坑。生产环境出现过“任务好像没执行”,但日志一片空白,就是因为调用了submit然后没有get,异常被吞得一干二净。解决方案:
- 如果你不需要返回值,更推荐用
execute()提交任务,异常会直接抛给线程池的UncaughtExceptionHandler。 - 必须用submit时,一定要
future.get()获取结果,并catch住ExecutionException。 - 线程池创建时设置
ThreadFactory时,给线程统一设置一个setUncaughtExceptionHandler,至少保证异常有记录。
我在项目里一般这么处理:自定义一个线程工厂,给所有池内线程设置UncaughtExceptionHandler,统一记录异常日志;提交任务时尽量用execute;如果涉及需要返回值的业务,使用submit并严格处理Future的异常分支。
6. 参数配置:把线程池配到“不翻车”的通用思路
6.1 核心线程数和最大线程数的估算方法
线上配置线程池参数,没有绝对公式,但我比较常用的起步思路是这样的:
- CPU密集型任务:核心线程数 ≈ CPU核数 + 1。原因是CPU密集任务几乎不等待,线程数超过CPU核数之后,多出来的线程主要是在抢CPU时间片,反而增加上下文切换开销。
- IO密集型任务:核心线程数 ≈ CPU核数 * 2,或者更高。因为IO等待期间CPU是空闲的,可以多放一些线程去处理其他任务。
- 混合型任务:把任务拆成CPU密集和IO密集两块,分别交给不同线程池,各配各的参数。
- 关键还要看下游的承受能力。如果任务会调用外部接口、数据库、MQ,线程池配得再大也没用,下游撑不住照样超时。这时候不是无脑加大线程数,而是先摸清下游最大支持多少QPS,再反推线程数。
不要完全照搬公式。线程数只是个起点,最好是上线后进行压测,根据队列积压、线程活跃度、RT数据动态调整。
6.2 动态调整线程池与优雅关闭
ThreadPoolExecutor在运行期间是支持动态调整参数的。通过setCorePoolSize(int)和setMaximumPoolSize(int)可以在不重启应用的情况下调整线程数。这个能力很实用,比如线上压测发现核心线程数不够,可以直接通过运维接口下调大,不用发版。
还有一点容易被忽略:如果我们把corePoolSize调小到比当前线程数还小,那些多出来的线程不会立刻被回收,而是要等它们空闲下来,超过keepAliveTime之后才会逐渐退出。所以线上动态缩容通常不是即时生效的,要看线程是否繁忙。
线程池关闭也有讲究。shutdown()是平滑关闭,线程池不再接收新任务,但队列中已提交的任务会继续处理完毕;shutdownNow()则会尝试中断所有正在执行的任务,并清空队列返回尚未执行的任务列表。业务系统推荐优先用shutdown(),给正在跑的任务一个收尾时间。
我也习惯在JVM关闭钩子(ShutdownHook)里统一调用线程池的shutdown(),避免服务下线时任务被中断。细节虽小,线上没少踩这种坑。
6.3 监控先行:线程池也需要“体检”
配置线程池只是第一步,上线后的监控才是长期稳定运行的保障。我一般会为每个线程池起一个独立且有业务含义的线程名前缀,比如order-async-pool,然后通过Spring的ThreadPoolTaskExecutor或ThreadPoolExecutor的自定义Metrics采集,把以下指标暴露给监控系统:
- 当前线程数(poolSize)
- 活跃线程数(activeCount)
- 队列积压数量(queue.size)
- 已完成任务数量(completedTaskCount)
- 被拒绝任务数量(Rejected Execution Count)
这五个指标基本够用。其中queue.size增长率和completedTaskCount的增速,能非常直观地判断线程池是否在“干活”;拒绝计数一旦出现非零,就要立刻告警。我们当时在线上就是因为监控Alarm及时发现了某个业务线程池拒绝异常,才避免了业务大规模受损。
配置参数这件事,本质上是一个动态调节的过程。项目初期可以先用经验值兜底,但最终判断一定来自数据。多观察监控、多分析线程状态、多记录每次调参前后的效果,时间长了,你就能慢慢找到自己业务场景下的“最优参数区间”。
我个人对线程池的体会是:它不是一个配置完就一劳永逸的组件,更像一个持续调节的“流量阀门”。你在面试时能把七大参数和拒绝策略背得滚瓜烂熟,远不如在线上的监控面板里多盯几周这些指标的变化来得实在。动手调一次,踩一次坑,比你背十篇八股文都有用。