线程池这三个字做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。在高并发瞬时流量下它会创建出成千上万个线程每个线程都要占用栈空间和系统句柄。一个线程默认栈大小可能就有1MB1万个线程就是接近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都要记住线程池不是一个容器它是一个有策略的资源调度器。你对它的每一个设定最终都会在这些高并发瞬间被放大成线上事故或者被化解于无形之中。