线程池这三个字,做Java的人谁都会说,但真正到了线上被压垮的那一刻,多数问题都出在"我以为我懂"上面。JDK自带的线程池体系——核心就是ThreadPoolExecutor和它衍生出来的一堆工厂方法——几乎是每个后端系统都绕不开的组件。这篇文章我打算把这些参数、队列、拒绝策略、状态流转这些东西从源码层面拆开讲一遍,再结合我自己实际踩过的坑,给你一套可以直接拿去用的线程池分析方法。
不管你是刚写了两三年Java的开发者,还是要给团队定线程池规范的技术负责人,这篇文章都适用。我不会只给你念一遍注释,而是把"为什么这个参数会导致这个结果"讲清楚——因为只有搞懂机制,你才敢在真实流量下动这些参数。如果只是背结论,下次换个场景你还是会懵。
1. 六个参数背后,是线程池的完整生命周期
1.1 先别急着new,搞懂任务进来之后走的是哪条路
很多同学对ThreadPoolExecutor的理解停留在"它就是一个池子,里面有线程,任务丢进去就能跑"。这句话不算错,但恐怕不够用。你至少得知道:一个新任务提交进来,线程池的决策顺序是这样的——先看当前运行的线程数是否小于核心线程数,如果小于,直接创建一个核心线程去跑这个任务;如果大于等于核心线程数,任务就会被丢进工作队列里等待;队列也满了,再看线程数是否小于最大线程数,如果小于最大线程数,就创建非核心线程去处理任务;如果连最大线程数都满了,就触发拒绝策略。
这段流程我在源码里读过很多次,写出来很简单,但实际排错时绝大多数问题都出在这个顺序上。举个例子:你的核心线程数是10,最大线程数是20,队列容量是100。这时候瞬间来了500个请求,前10个会立刻被核心线程处理,第11个到第110个会进入队列,第111个到第130个会触发创建临时线程,剩下370个会触发拒绝策略。注意了,临时线程不是在队列满之后马上创建,而是在队列被填满之后才创建的。这个顺序决定了:如果你把队列设置得特别大,那核心线程数以外的线程可能永远都不会被创建,系统看起来有20个线程的配置,实际核心线程一直在扛。
为了更直观,你可以把线程池想象成一个快餐店。核心线程是固定的厨师,队列是柜台上的订单盒子,最大线程数是你能通过临时招人来扩充的厨师上限。客人来了,有空闲厨师就直接做;没空闲厨师就把订单放进盒子里等着;盒子里也塞满了,才考虑招临时厨师;临时厨师全忙,只能告诉客人"做不了"。订单盒子的大小,直接决定了你什么时候才愿意招人。
1.2 线程池的五个状态流转
ThreadPoolExecutor内部定义了五种状态:RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。它们不是Java语法层面的枚举,而是用int变量ctl的高3位来表示的。RUNNING表示线程池正常运行,能接受新任务,也能处理队列里的任务;SHUTDOWN表示调用了shutdown()之后的状态,这时候不再接受新任务,但队列里已有的任务还要继续执行完;STOP表示调用了shutdownNow()之后的状态,不仅不接受新任务,还会中断正在执行的任务,并且把队列里还没执行的任务清空返回给你;TIDYING是收尾状态,表示线程池里已经没有工作线程了,正在执行terminated()钩子方法;TERMINATED是最终状态,terminated()执行完毕之后才进入。
搞懂这几种状态非常有用。实际开发中常见的坑是:任务执行到一半,想把线程池停掉。如果你图省事调了shutdownNow(),正在执行的任务会被InterruptedException打断,如果你没有在任务里正确处理中断信号,数据可能就写到一半。而shutdown()会等所有任务结束,但队列里如果有大量积压任务,等待时间可能是无法预估的。
1.3 参数计算思路:不是拍脑袋,是算出来的
核心线程数和最大线程数到底设置多少,是我被问得最多的问题。这里没有万能答案,但有一个基础的推算思路可以参考。
如果是CPU密集型任务,线程数一般设置为CPU核数加一左右,因为每个任务都在拼命占用CPU,线程比核数多太多反而会因为上下文切换降低吞吐。如果是IO密集型任务,比如大量网络请求、文件读写、数据库操作,线程在大部分时间里都在等待IO返回,这时候CPU是闲的,就可以让线程数超过核数。常见公式是:核心线程数约等于 CPU核数 × 2 再加一点缓冲。具体可以根据IO等待时间的占比来估算,例如任务执行时间中,IO等待占了大概80%,那理论上的最优线程数大约就是 CPU核数 × (1 + 0.8 / 0.2),也就是CPU核数 × 5 左右。这个推导方式不是绝对真理,但比瞎猜靠谱得多。
队列容量怎么定?我建议按"积压任务的响应时间容忍度"来算。假设你的系统要求某个接口在2秒内必须响应,而单个任务的平均处理时间是100毫秒,那在极端情况下,队列里最多能容忍大约20个任务堆积。如果核心线程数是10,那队列容量就定在200左右比较合理。当然这是简化模型,实际还要考虑任务到达速率、下游能力波动等,但这个思路至少能让你把参数从"猜数字"变成"算数字"。
2. 任务提交后,ThreadPoolExecutor到底怎么排队
2.1 execute()的隐藏逻辑:为什么要看三次线程数
我建议你有空把ThreadPoolExecutor的execute()方法源码读一遍,这个方法不长,但信息密度很高。流程上它做了三次检查:第一次,如果工作线程数小于corePoolSize,就尝试用这个任务开启一个新核心线程;第二次,如果任务能成功加入工作队列,会再检查一次线程池状态和当前线程数,防止线程池已经关闭或者线程已经全部死掉,必要时会启动一个空跑线程去处理队列里的任务;第三次,如果队列加入失败,说明队列满了,尝试开启一个新临时线程,如果临时线程也开不了,就走拒绝策略。
为什么是三次检查?这里其实埋了一个很经典的安全设计:任务入队成功并不代表它一定能被执行,如果入队成功后线程池刚好被关闭,或者所有线程刚好都异常退出,那这个任务就会永远躺在队列里。所以addWorker的过程会反复确认状态。很多人调试线程池问题,只盯着线程数变化,忽略了状态变化导致的任务滞留,这就是排查方向错了。
2.2 队列选型:这是线程池最容易进坑的地方
工作队列的类型直接决定了线程池在不同负载下的行为。JDK里常用的有几种。
LinkedBlockingQueue是基于链表的无界或有界队列,Executors的newFixedThreadPool默认用的就是它。关键点是:如果不传容量,默认是Integer.MAX_VALUE,相当于无界队列。这意味着任务永远不会触发拒绝策略,但也意味着队列可以无限积压,内存可能被活活撑爆。
ArrayBlockingQueue是基于数组的有界队列,必须设置容量。它的特点是公平性可以配置,公平模式下吞吐量会下降,但能避免线程饥饿。适合你明确知道队列里最多能等多少任务的场景。
SynchronousQueue是一个不存储元素的队列,每一个put都必须等着一个take。线程池用它时,任务不会被排队,而是直接交给线程处理;如果没有空闲线程,就会创建新线程。Executors的newCachedThreadPool默认用的就是它。这个队列的语义很极端——线程数可以无限膨胀到Integer.MAX_VALUE,非常危险。
PriorityBlockingQueue是支持优先级的无界队列,可以用来让某些紧急任务插队。但要注意,使用它时任务必须实现Comparable接口,而且要清楚"插队"只是队列出队的顺序变了,如果线程一直在忙,紧急任务依然要等。
ScheduledThreadPoolExecutor内部用的DelayedWorkQueue也是无界队列,专门用来处理定时任务。这里也有一个容易忽略的问题:定时任务的周期计算是从上一个任务开始时间算的,如果上一个任务执行时间超过了周期,下一个任务并不会立刻补上,而是会等当前任务执行完再按周期调度,实际频率会低于你配置的值。
我用一张表总结一下这几种队列的特点,方便你做决策。
| 队列类型 | 是否默认有界 | 吞吐特性 | 典型使用场景 |
|---|---|---|---|
| LinkedBlockingQueue | 默认无界 | 高 | 任务量相对可控的后台处理 |
| ArrayBlockingQueue | 有界 | 中等 | 需要明确背压的削峰场景 |
| SynchronousQueue | 无界(不存储) | 高 | 任务实时性要求极高,能接受线程膨胀 |
| PriorityBlockingQueue | 默认无界 | 低 | 需要按优先级处理的任务 |
| DelayedWorkQueue | 无界 | 偏低 | 定时调度场景 |
选队列的核心原则我在实践里总结成了一句:只要不是特别极端,都用有界队列。宁可触发拒绝策略丢任务,也别让内存被积压任务吃掉。因为拒绝策略是你能看见的异常,而内存溢出是整个应用一起崩溃,代价完全不同。
2.3 拒绝策略真的"拒绝"了吗
当队列满了、线程数也到上限了,新任务只能走RejectedExecutionHandler。JDK内置了四种策略。
AbortPolicy是默认策略,直接抛出RejectedExecutionException。这是个显式的失败信号,适合那些任务不允许丢失、宁可报警也不默写的场景。我以前的项目里就有一个导出任务用了默认策略,任务被拒绝时MQ消费者会收到异常并重试,反而比静默丢弃更安全。
CallerRunsPolicy比较特殊,它不会拒绝任务,而是把任务拿回到调用execute()的线程里执行。这个策略的好处是天然实现了限流:调用方线程被任务拖住,就没办法继续高速提交新任务了。坏处是如果你在一条关键链路里同步调用了这个方法,调用线程自身的响应时间会飙涨。我一般在不想丢任务、又不想另外搞重试机制的场景下用它。
DiscardPolicy直接把任务丢掉,不发异常。DiscardOldestPolicy呢,先丢掉队列里最旧的一个任务,再尝试提交新任务。这两个策略看起来省事,但会让开发人员根本感知不到系统正在过载,数据被静默丢弃的后果往往比报错更严重。所以我个人非常不建议在没有监控的情况下使用这两个策略。
3. Executors提供的四种线程池,哪几个是真方便,哪几个是躺坑
3.1 四种现成线程池速览
Executors类提供了一组工厂方法,newFixedThreadPool、newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool是其中最常用的四个。这些方法一行代码就能创建线程池,开发效率确实高。但也正因为方便,很多人没看它们底层的参数就直接用了,结果线上出现了完全没想到的问题。我先列出来它们的本质参数,再逐个分析坑在哪。
newFixedThreadPool:核心线程数等于最大线程数,队列用无界的LinkedBlockingQueue。newSingleThreadExecutor:核心和最大线程数都是1,队列也是无界的。newCachedThreadPool:核心线程数为0,最大线程数是Integer.MAX_VALUE,队列用SynchronousQueue,线程空闲60秒后被回收。newScheduledThreadPool:核心线程数由参数决定,最大线程数是Integer.MAX_VALUE,队列是DelayedWorkQueue。
如果你只是写个本地测试或者一次性脚本,随便用一个没问题。一旦上了生产环境,这四个方法我建议你警惕地看待。
3.2 FixedThreadPool:无界队列是个定时炸弹
newFixedThreadPool看起来很稳妥,线程数固定,不会无故膨胀。但它默认配的是一个没有容量上限的LinkedBlockingQueue。当任务提交速度超过处理速度时,任务会无限堆积。我曾经在一个文件处理服务里看到过这种配置:核心线程4个,任务量平时每秒几十个,可某天上游接口超时重试,提交速度暴涨,队列以每秒数千条的速度增长,几十分钟后堆内存就被塞满了,整台机器频繁Full GC,最后直接OOM。
这个问题的本质不是线程数设置错了,而是无界队列给了系统一个"可以无限缓冲"的幻觉。真正到了高峰期,缓冲的不是流量,是灾难。所以遇到需要线程数固定的场景,我建议手动new ThreadPoolExecutor,把队列指定为有界的ArrayBlockingQueue,并配一个明确的拒绝策略,宁可被拒也不要无限堆积。
3.3 CachedThreadPool:线程数上限是硬伤
newCachedThreadPool的意图是让线程数随负载自动伸缩,空闲线程会被回收,响应也快。问题在于它的最大线程数是Integer.MAX_VALUE。在高并发瞬时流量下,它会创建出成千上万个线程,每个线程都要占用栈空间和系统句柄。一个线程默认栈大小可能就有1MB,1万个线程就是接近10GB的虚拟内存开销;更重要的是,操作系统能创建的线程数是有极限的,超过之后会直接抛出无法创建新线程的异常,线程池和业务系统一起完蛋。
我的建议是:如果你确实需要按需伸缩,就手动指定一个合理的maximumPoolSize上限,哪怕只是限制在几百,也比无限膨胀安全得多。毕竟绝大多数业务的并发峰值是可以预估的,没必要把赌注押在线程数的无限增长上。
3.4 SingleThreadExecutor与Scheduled的适用边界
newSingleThreadExecutor本质上就是一个线程数始终为1的线程池,它帮我们保证任务串行执行,这在某些需要严格顺序的场景里很实用。但要注意,如果这个唯一线程在执行任务时挂了,线程池会重新创建一个线程继续执行后续任务,而异常是吞掉的,不会自动恢复之前的上下文。另一个容易被忽略的点是:如果你提交的任务里有阻塞操作,比如同步调第三方接口没设置超时,那后面所有的任务都会卡住,整个链路停摆。串行化带来的好处和代价都很明显,用之前一定要确认任务里没有不可控的阻塞。
newScheduledThreadPool适合周期性和延迟任务,比直接自己开一个Timer要稳定。它的问题有两个:一是最大线程数同样非常大,虽然没有CachedThreadPool那么极端,但在任务调度间隔设置错误时也可能膨胀;二是如果某个定时任务执行时间超过调度周期,线程池不会并发执行同一个任务,而是会等待上一个任务结束,这个细节在实际使用中很容易误判任务触发频率。
4. 线程池的监控与异常处理,别等项目挂了才想起来
4.1 默认线程工厂的坑:线程名不可辨、异常静默
用Executors创建线程池时,默认使用的是DefaultThreadFactory。这个工厂创建的线程有个特点:线程名都是以"pool-数字-thread-数字"这种格式命名。线上排查问题时,你从jstack里只能看到一堆pool-1-thread-3、pool-2-thread-5,完全无法定位这些线程属于哪个业务模块。更麻烦的是,默认的线程工厂对异常的处理非常安静。
我强烈建议你自定义ThreadFactory,哪怕只是把线程名改成有意义的名字,比如"order-async-worker-"加数字。这样不仅排查问题方便,很多监控系统也能按线程名前缀做聚合统计。自定义工厂里还可以顺手设置线程是否为守护线程。我个人的习惯是业务线程池一律用非守护线程,防止主线程结束时任务被强行中断。
4.2 任务异常丢失与Future拦不住
线程池里执行的任务,如果抛出RuntimeException,而这些任务又是用execute()提交的,那这个异常会被线程池内部捕获并写入到线程的未捕获异常处理器里,默认情况下没有任何日志输出。你会看到线程池里的线程继续跑下一个任务,但那个失败的任务就像没有发生过一样。这是我认为线程池最危险的一点——不是报错,而是不报错。
解决办法有几个。最简单的,是用submit()提交任务,拿到Future后显式调用future.get(),它会把任务里的异常包装成ExecutionException抛出来。但Future.get()是阻塞的,在不需要同步等待结果的场景里,这么写反而违背了异步提交的初衷。更好的方案是给ThreadPoolExecutor设置一个UncaughtExceptionHandler,或者重写afterExecute()方法,在这里统一捕获并记录异常日志。
我自己的实践是:在线程池外面包一层,把每个任务都做成一个带日志跟踪的包装Runnable,在包装类的catch块里记录任务相关参数和完整异常堆栈。这样即使任务失败,也有足够的上下文可以反查。
4.3 通过钩子方法拿到执行统计
ThreadPoolExecutor提供了beforeExecute、afterExecute和terminated三个钩子方法。beforeExecute会在任务执行前被调用,afterExecute会在任务执行后被调用,无论任务是否抛异常都会走进来(前提是不用execute提交的普通任务,submit提交的异常不会走afterExecute的异常分支)。这三个方法给了我们一个非常好的切入点,可以在不改业务代码的前提下,统计任务耗时、记录任务队列积压情况。
我常常在afterExecute里记录每个任务的执行耗时,加上线程池当前的活跃线程数、队列大小,一起打入业务日志的上下文。这样做的好处是,线上出现慢任务时,你可以直接看出是任务本身慢,还是线程池资源不够导致排队。这两者的排查方向完全不同,但如果没有这个统计,你只能靠猜。
4.4 线程池动态调参:setCorePoolSize与setMaximumPoolSize的时机和限制
ThreadPoolExecutor不是创建之后就钉死的,它提供了setCorePoolSize和setMaximumPoolSize来动态调整参数。很多人不知道的是,setCorePoolSize调大之后,线程池会立即尝试创建新的核心线程,即使当前没有新任务进来,它也会补充线程。而调小corePoolSize时,线程池不会立刻杀掉多余的线程,只会让它们等待keepAliveTime结束后慢慢回收,或者等当前任务执行完后再回收。setMaximumPoolSize也有一个隐藏的约束:新的maximumPoolSize不能小于当前核心线程数。如果你先后把corePoolSize调成10、maximumPoolSize调成10,现在想变成核心5、最大20,直接调会有问题,得先调整顺序,避免违反这个隐含校验。
动态调参最大的价值在于应对突发的流量高峰。我自己的习惯是:系统里留一个配置文件或者接口,可以在运行时调整线程池参数;加流量高峰的时候,先观测任务耗时和队列深度,判断是线程数不足还是下游变慢,再决定是扩线程数还是改队列容量。当然前提是你必须有一套监控指标,否则调参就是盲人摸象。
5. 一次线上线程池事故复盘:参数是动态的,坑是静态的
5.1 事故现象与初步排查
某天一个内部系统突然报接口超时告警,调用方反馈说大量请求超过5秒没有返回。我第一反应先去看了应用的GC日志和CPU使用率,发现CPU不算特别高,但线程池的等待任务数一直在涨。进一步看监控面板,发现核心线程一直跑满,队列深度从几百一路冲到几万,而拒绝策略居然没有触发——因为队列使用的是无界队列。
这个现象指向一个很典型的结论:不是任务处理不完了,而是队列把压力全部藏起来了。接口超时的原因是调用方等不到响应,可线程池本身并没有报错,因为队列还能继续往里塞,只有内存快要顶不住的时候系统才会给出反应。
5.2 定位链路:从日志、堆栈到计数器
我需要找到到底是什么任务往线程池里塞了这么多内容。然后用jstack抓了一份线程堆栈,从线程名找到对应的业务线程池,再从调用栈里看到任务入口,最后根据任务里带的业务标识,去日志里查这个任务的调用频率和耗时分布。
排查下来,问题出现在一个重试机制上。上游服务调用失败后会重试,而重试逻辑里没有做退避,失败越频繁,重试越快,重试又继续失败,形成了恶性循环。线程池本身没有做任何错误,它只是在忠实地排队,排队的人越来越多,真正处理任务的线程却只有一个下游依赖能扛住的量。
5.3 事后调整方案与验证
改了三处。第一处是重试机制增加指数退避和最大重试次数,从源头掐掉无效任务。第二处是把无界队列换成了容量为200的有界队列,并配了CallerRunsPolicy,让调用方在队列满时自己承担一部分压力,而不是让线程池无限积压。第三处是给线程池加了监控,包括队列深度、活跃线程数、拒绝次数,并整理了告警阈值。
调整后反复压测和观察,接口超时率降到了0.1%以下,队列深度基本稳定在两位数以内,拒绝次数为零。这次事故给我的体会很深:参数本身没有对错,关键是你有没有把每一个参数背后的行为都考虑清楚。队列大,用的是内存换时间;线程多,用的是系统资源换时间;拒绝策略,是你对任务丢失的最后一道态度。
最后一句话送给看到这里的同行:无论你是用Executors的现成方法,还是手动配置ThreadPoolExecutor,都要记住,线程池不是一个容器,它是一个有策略的资源调度器。你对它的每一个设定,最终都会在这些高并发瞬间被放大成线上事故,或者被化解于无形之中。