
1. 线程池这东西为什么总能把人整不会先聊点实际的。线程池在Java后端里属于那种天天用、一深问就露馅的知识点。面试背八股的时候人人都会说corePoolSize、maximumPoolSize、workQueue、RejectedExecutionHandler可真上了线遇到一次高峰流量触发了拒绝策略或者一次故障排查发现线程数诡异飙升很多人当场就懵了。我见过太多这样的案例某服务平时QPS不高线程池跑得好好的结果大促流量一上来接口直接报RejectedExecutionException一堆请求被打回。运维把线程池参数截图发过来一看——核心线程设了10最大线程设了200队列用的无界LinkedBlockingQueue。懂行的一眼就看出问题在哪了maximumPoolSize设成200但队列是无界的这不等于最大线程数永远用不上吗请求全堆在队列里线程数永远只到10延迟一路飙到超时然后超时线程继续积累最后内存也跟着吃紧。这种问题不是个例是很多团队的共性问题。所以这篇就把线程池的核心参数掰开揉碎讲清楚结合我实际排查过的线上故障把那些90%的人踩过的坑一个个指出来。内容面向正在使用线程池做异步处理、批量任务、接口并发优化的开发同学不管你是刚写两年的新人还是带团队的老人这篇里应该都有能对上号的东西。2. 七个核心参数每个背后都是一次事故教训2.1 corePoolSize线程池的常驻人口核心线程数是最容易拍脑袋定的参数。很多人的做法是抄别人项目的配置或者随便填一个看得顺眼的数字。但核心线程数本质上是你在正常情况下希望池子里常驻多少个线程来处理任务它直接影响的是资源占用和响应速度的平衡。我个人的经验是核心线程数需要结合两个东西一起看任务到达的速率和单个任务的平均耗时。这里有一个简单的估算思路如果你的系统每秒大约到达100个任务每个任务平均需要50ms处理时间那理论上需要的线程数约等于100 * 0.05 5再留一些缓冲比如1.5到2倍取10左右。这里的计算逻辑就是Littles Law的简化版即系统里同时在处理的任务数等于到达速率乘以平均服务时间。不过这只是理论值实际还要考虑CPU核数。如果是CPU密集型任务核心线程数定在CPU核数加一附近通常比较合理如果是IO密集型任务比如大量调用远程接口、读写数据库线程在做IO等待时CPU是闲置的这时候核心线程数可以适当放大常见做法是CPU核数的两倍甚至更高。关键在于你要先搞清楚你的任务是吃CPU还是吃IO这个搞反了参数怎么配都是别扭的。2.2 maximumPoolSize峰值时期的临时工最大线程数是线程池在队列也放不下的时候额外扩容出来的线程上限。这里有个常见的误解很多人以为线程数会直接从核心数一路加到最大数其实不是。线程池的扩容顺序是先让核心线程跑满然后任务进队列队列满了之后再创建非核心线程去处理。理解这个顺序极其重要。我见过一个真实事故某团队把corePoolSize设为4maximumPoolSize设为32队列容量设为8。按照正确的流程当并发任务到来时前4个任务被核心线程处理接下来的8个任务进入队列当队列也满时线程池才会把线程数从4扩展到32。但团队里有人误以为线程数会直接跳到32导致他们在评估系统并发上限时出现了严重偏差最终在流量高峰时任务大量积压。这里要特别提醒maximumPoolSize决定了你在极端情况下最多能有多少线程同时干活但线程不是越多越好。线程本身要占内存每个线程的栈空间默认1M左右虽然实际申请是按需的但开销仍然存在而且线程多了之后CPU上下文切换成本会急剧上升可能反而导致吞吐量下降。所以maximumPoolSize的设定一定要结合你机器的资源上限和任务特性来评估。2.3 keepAliveTime非核心线程的生存时长当流量回落后超出核心线程数量的那些线程不会立刻销毁而是要等一段时间这段时间内没有新任务进来它们才会被回收。这个参数就是keepAliveTime。这个参数看起来不起眼但它对系统稳定性有直接影响。设得太短线程频繁创建销毁性能损耗明显设得太长高峰期创建的线程在流量回落后还占着资源不放。我习惯的默认值是30到60秒但这个值要根据你的业务波动周期来调。如果业务有明显的秒级或分钟级脉冲流量比如定时任务整点触发那么keepAliveTime设短一些更合适如果是持续性较长的高峰建议设长一点避免高峰内的微波动导致线程反复创建销毁。值得一提的是JDK里还有一个allowCoreThreadTimeOut开关开启后核心线程也会在空闲超时后被回收。这个开关适合那种低峰期极长、资源又比较紧张的场景但开启后要接受一个事实低峰期线程池里可能一个线程都没有。2.4 workQueue真正决定线程池性格的缓冲地带任务队列的选择是整篇内容里最值得单独说的部分因为它直接决定线程池在高并发下是优雅降级还是当场崩溃。2.4.1 LinkedBlockingQueue无界队列的隐患无界队列最大的问题我前面已经用事故案例点过了当任务生产速度长期大于消费速度时队列会无限制地增长最终导致内存溢出。而且无界队列还有个隐性后果——maximumPoolSize形同虚设因为队列永远不满线程池根本不需要扩容到最大线程数。很多团队用无界队列是因为省事觉得反正先进先出最多任务等一会儿。但在高并发场景下这个等一会儿可能变成等很久而且伴随着内存压力。如果任务里还引用了大对象或者数据库连接那这些资源也会被积压的任务长期占用问题会被进一步放大。2.4.2 ArrayBlockingQueue有界队列的正确用法我强烈建议生产环境一律使用有界队列。ArrayBlockingQueue需要指定容量这样线程池的行为就变成核心线程跑满 → 最多再堆积设定数量的任务 → 满了之后创建非核心线程 → 非核心线程也跑满 → 触发拒绝策略。这种模式的好处是系统行为可预期。举个例子corePoolSize10maximumPoolSize30queueCapacity100。当任务量超过10 100 110时线程数开始向30扩张当任务量超过10 100 20 130时30个线程都在跑且队列满新来的任务就会被拒绝。你可以在事前就算出系统的承载上限然后针对性地做容量评估和监控告警。2.4.3 SynchronousQueue直接提交的适用场景SynchronousQueue不存任务它直接把任务交给线程处理。如果没有空闲线程就会立刻尝试创建新线程直到达到maximumPoolSize然后触发拒绝策略。这个队列适合那种任务必须马上处理、不能等的场景但前提是你知道自己在做什么因为它对maximumPoolSize的压力很大很容易创建大量线程。我通常只在两种情况下用它一是任务本身非常轻量、执行时间很短且要求低延迟二是配合自定义的拒绝策略做抛弃最旧任务之类的降级处理。2.5 ThreadFactory给线程起个好名字能救命这个参数被很多人忽略但它在排查问题的时候真的能救命。默认的线程工厂创建出来的线程名字是pool-1-thread-1这种你在线程dump里根本看不出来这是哪个业务模块的线程。正确的做法是自定义ThreadFactory在线程名里带上业务标识比如order-async-thread-1。这样当你用jstack查看线程状态、或者用Arthas排查线程阻塞的时候一眼就能定位到是哪个池子出了问题。顺带还要设置daemon标志和异常处理器UncaughtExceptionHandler避免线程因为未捕获异常而静默死亡。2.6 RejectedExecutionHandler最后一道防线拒绝策略是当线程池和队列都满载时对新的任务采取的措施。JDK内置了四种策略AbortPolicy默认直接抛异常、CallerRunsPolicy提交任务的线程自己执行任务、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃队列里最旧的任务。我见过不少团队直接把默认的AbortPolicy当成了唯一选项结果线上流量稍大就开始报错。实际上CallerRunsPolicy在大多数业务场景下是更好的选择因为当线程池忙不过来时让调用线程来执行任务既不会丢弃任务还能通过调用线程被阻塞这个副作用天然形成一种背压减缓任务提交的速度。DiscardPolicy和DiscardOldestPolicy看起来省事但除非你的任务丢了也无所谓比如某些实时性极强、只关心最新数据的场景否则建议不要用因为排查问题时你会发现任务凭空消失是非常恐怖的一件事。3. 核心参数应该怎么定一套可以直接抄的配置方法3.1 先问自己三个问题配置线程池参数之前先别急着填数字。我每次做线程池调优第一步永远是先回答三个问题第一个问题任务是什么类型的是CPU密集型、IO密集型还是混合型这决定了你是以核数为基准还是以IO等待时间为基准来估算线程数。第二个问题任务的峰值速率是多少你预期的最大QPS或任务到达速率是什么量级这决定了你需要多大的队列和多大容量的线程数。第三个问题如果线程池满了业务上你能接受什么行为是抛异常让上游感知并重试还是调用方自己执行还是允许丢弃一部分非关键任务这三个问题的答案基本能把参数范围圈定剩下的才是微调。3.2 一套相对通用的计算流程假设你的机器是4核8G任务是典型的IO密集型比如调用第三方接口平均耗时200ms线上预估峰值任务到达速率为每秒50个。单个线程每秒大约能处理 1000 / 200 5 个任务。要达到每秒50个的处理能力理论线程数就是10。考虑到IO等待期间线程会阻塞实际线程数可以再上浮一些比如取15到20作为核心线程数。因为IO密集型任务在线程阻塞期间不占CPU多个线程并行能显著提升吞吐量。maximumPoolSize的设定要结合你最坏情况的容忍度。假设你允许任务在队列里最多等待2秒那么队列容量大约可以是峰值速率乘以2即100到120。设核心线程是15、队列容量是100那么当任务量超过115时线程开始向maximumPoolSize扩展。如果机器4核跑IO密集型任务我建议最大线程数控制在30左右不宜超过50否则线程上下文切换开销会抵消并发优势。keepAliveTime在IO密集型任务下建议设60秒以上因为IO任务通常带有突发性短时间的空闲未必意味着流量彻底回落。ThreadFactory和拒绝策略按前面说的办ThreadFactory带上业务名拒绝策略用CallerRunsPolicy。如果你的业务允许丢弃非关键任务再单独考虑DiscardOldestPolicy。3.3 一个生产可用的配置示例我用一个实际项目的配置来举例代码逻辑如下ThreadPoolExecutor executor new ThreadPoolExecutor( 15, // corePoolSize 30, // maximumPoolSize 60L, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue(120), // 有界队列 new NamedThreadFactory(order-async), new ThreadPoolExecutor.CallerRunsPolicy() );这里核心线程15、最大线程30、队列120。也就是说系统在任务数不超过15 120 135时只需要核心线程工作超过135后线程开始扩容直到30个线程全部在跑如果任务数超过30 120 150则触发CallerRunsPolicy由提交任务的线程自己执行。注意CallerRunsPolicy这里有个隐蔽的坑如果调用线程本身就是线程池里的工作线程那它去执行这个任务时会占用一个工作线程的量相当于处理能力被占用了。这种递归执行在某些极端场景下可能导致死锁比如任务线程在等待某个异步结果而该结果的生成又依赖这个线程池。遇到这种情况需要格外小心必要时要在任务执行入口加一层保护逻辑。4. 实操过程从线上故障到参数调优全记录4.1 故障现场还原说一个我实际经历过的案例。某天下午一个订单处理服务突然开始频繁报警核心表现是接口响应时间从平均80ms飙升到3秒以上同时部分请求直接报RejectedExecutionException。团队立刻拉取监控发现线程池的活跃线程数长期打满在10队列里堆积了数万个任务且还在持续增长。查看代码后发现线程池用的是无界LinkedBlockingQueuecorePoolSize10maximumPoolSize200。问题链条很清楚任务生产速度大于消费速度无界队列积压无限增长线程数永远是10响应时间持续恶化。更隐蔽的是由于队列无界maximumPoolSize200完全没被触发线程池像温水中被煮的青蛙一样一点一点被拖垮。4.2 排查与修复过程第一步我先通过jstack查看线程状态确认线程都卡在什么调用上。结果发现大部分线程阻塞在数据库查询上说明任务不是CPU密集型而是IO密集型而且有一个下游接口响应很慢把线程都拖住了。第二步把队列改成有界ArrayBlockingQueue容量先定为200。corePoolSize从10调整为20因为IO密集型任务需要更多并发来掩盖等待时间maximumPoolSize保持合理范围设为50。这里为什么把maximumPoolSize从200降到50因为在4核机器上50个线程跑IO密集型任务已经能让CPU利用率接近饱和200个线程只会徒增上下文切换开销甚至可能把CPU拖垮。第三步线程工厂改成带订单业务标识的命名方便后续排查线程归属。拒绝策略改为CallerRunsPolicy避免任务直接丢弃同时通过调用线程阻塞来反推上游降速。另外还加了一个监控面板实时采集activeCount、queueSize、completedTaskCount和taskCount这几个关键指标。4.3 调优后的表现与心得调整上线后接口响应时间恢复到100ms以内拒绝策略在流量脉冲高峰时会触发一小部分CallerRunsPolicy执行但整体系统不再出现线程被打满、任务无限积压的情况。更重要的是监控面板上线后团队终于能实时看到线程池的健康状态再也不会等到系统报警才知道出了问题。从这个案例里我体会最深的一点线程池排障最重要的是先看队列长度和活跃线程数这两项指标的关系。如果活跃线程数长期打满但队列在涨说明消费能力不足如果活跃线程数没打满但队列在涨说明核心线程配置太小任务堆积了却没触发扩容。这个判断逻辑比瞎调参数有用得多。5. 90%的人踩过的坑我帮你逐条列清楚5.1 无界队列看似很大的最大线程数这个坑我在前面反复提到因为它真的害了很多人。你以为设置了maximumPoolSize200就有了200个线程的并发能力实际上无界队列的存在让你的线程数永远只停留在核心线程数。这不仅仅是性能问题更是一个认知偏差——基于错误配置做容量评估后果是灾难性的。5.2 核心线程数过大导致频繁线程切换有人觉得线程数越多越好直接把corePoolSize设成200。结果业务量没上来的时候池子里200个线程大部分时间空闲不仅白白占用内存而且这些空闲线程还会周期性地被唤醒检查任务增加不必要的CPU开销。线程不是免费的午餐每一百个空闲线程背后都是实实在在的系统资源。5.3 线程池和任务类型不匹配CPU密集型的任务用了大线程池IO密集型的任务反而用默认的小线程池这俩都是问题。CPU密集型任务线程数超过CPU核数太多时线程之间互相抢占CPU时间片整体吞吐量不升反降IO密集型任务线程数太少时大量的时间浪费在等待IO响应上CPU利用率低得可怜。匹配的核心就一句话你的线程是在等CPU还是在等IO5.4 静态线程池扛不住动态流量很多业务有一个隐蔽的痛点请求有明显的周期性峰值比如上午十点和下午三点的抢单高峰或者定时任务整点触发。静态配置的线程池如果按照均值来配高峰期必挂如果按照峰值来配低峰期资源浪费又很心疼。这个问题的解法不是把参数调来调去而是做一个动态调整的机制。实践中可以定期监测队列长度和任务处理延迟当队列积压超过阈值时动态调高corePoolSize或maximumPoolSize当队列空转且线程空闲时再逐步调低。这个思路在开源组件里也有成熟实践比如很多框架提供线程池动态调优能力原理都差不多。5.5 拒绝策略选错导致的任务静默丢失DiscardPolicy是静态代码检查最容易被放过的风险点因为语法上没有任何错误运行期也不会报任何异常但任务就是没了。想象一下用户提交了一个订单异步通知的任务线程池满了任务被静默丢弃用户那边什么反馈都没有直到业务对账时才发现通知没发出去。这个坑的隐蔽性极强建议对核心业务任务一律禁用静默丢弃的拒绝策略。5.6 线程池进行耗时操作却没有超时机制线程池里跑的任务如果自身没有超时控制那么一旦遇到下游服务hang住线程就会被长期占用很快拖垮整个线程池。我见过一个案例一个线程池被某个第三方接口的长时间无响应拖住了30多个线程其他业务任务全部陷入等待。所以线程池的任务内部一定要设置合理的超时时间另外配合future.get(timeout)也是常用的保护手段。6. 线程池监控与动态调优把主动权握在自己手里6.1 必须盯着的四个核心指标线程池不能配完就不管了。我建议在监控系统里至少采集这四个指标第一个是activeCount活跃线程数它告诉你当前有多少线程在处理任务。如果长期等于corePoolSize且队列在持续增长说明你的核心线程数配小了。第二个是queueSize队列堆积量它是最直观的压力信号。正常情况下队列应该围绕0小幅波动如果它持续上涨说明消费速度跟不上生产速度。第三个是completedTaskCount和taskCount通过taskCount的总增速你可以知道任务的到达速率再结合activeCount和平均处理耗时就能判断线程池的容量余量还有多少。第四个是rejectedCount拒绝次数这个指标一出现就说明你的线程池已经到极限了需要立即关注要么扩容要么降级。拒绝次数如果频繁出现绝对不是偶尔波动那么简单背后往往是容量规划出了问题。6.2 常见监控姿势我常用的监控方式有两种一种是侵入代码在ThreadPoolExecutor外面再包一层提交任务前后记录指标定时上报到监控系统。另一种是直接用Micrometer这类指标采集库把线程池指标暴露到Prometheus或类似组件中配合Grafana做可视化面板。后者的侵入性更低推荐优先使用。在实际操作中你会遇到一个很气人的问题很多监控工具默认采集不到某个自定义线程池的指标因为线程池是手动new出来的没有走Spring的线程池管理。解决的方法很简单自己把指标通过MeterRegistry注册进去Gauge.builder(thread.pool.queue.size, executor, ThreadPoolExecutor::getQueueSize) .register(registry); Gauge.builder(thread.pool.active.count, executor, ThreadPoolExecutor::getActiveCount) .register(registry);6.3 简易动态调参的落地思路动态调优没必要一上来就上重框架。先说一个轻量的思路你可以在核心业务代码里开启一个后台任务定期检查队列长度和活跃线程数然后根据预设规则调整corePoolSize。ThreadPoolExecutor本身就支持setCorePoolSize和setMaximumPoolSize这些方法所以动态调整的基础能力是现成的。举例来说假设规则的逻辑是这样如果最近一分钟队列长度持续超过队列容量的80%说明需要扩容那么每30秒就把corePoolSize往上加5直到达到maximumPoolSize上限或新增线程的收益消失表现为处理延迟不再下降。反过来如果队列长度持续保持低位CPU利用率也不高就每隔一段时间调低corePoolSize把多余线程收回来。关键点在于调整前后必须对比效果。动态调优不是拍脑袋让线程数上下浮动每一步调整都要有监控数据做支撑你要能回答调整后延迟降了吗队列积压缓解了吗CPU利用率有没有异常飙升如果没有监控做辅助动态调参就是开盲盒。7. 排查线程池问题的几个土办法7.1 jstack看线程在干什么jstack是排查线程池问题的第一工具。命令很简单jstack 然后看到的是每个线程的堆栈。你需要重点看带线程池名字的线程它们的状态是RUNNABLE、BLOCKED还是WAITING。如果大量线程卡在同一个数据库或网络调用上说明下游是瓶颈如果线程上看不到任务执行说明任务都堆积在队列里没被取走。配合前面说的自定义ThreadFactory线程名字里带了业务标识之后jstack的输出会变得非常友好你一眼就能分清哪些线程是订单业务的哪些是消息推送的。7.2 队列长度和活跃线程数一起看单独看一个指标容易误判。比如活跃线程数很低有人就觉得线程池很闲其实可能是队列已经堆满新任务在等待。最靠谱的判断方法是活跃线程数 队列长度的组合。如果两者之和接近你的线程池容量上限说明系统正在接近瓶颈如果两者都很低但请求又很慢那问题可能根本不在线程池而在下游依赖。7.3 利用ThreadPoolExecutor自带的方法做自检ThreadPoolExecutor本身提供了getQueue().size()、getActiveCount()、getCompletedTaskCount()等方法你可以直接在压测脚本里或者临时代码里打印这些值。排查问题时不一定要上复杂的APM工具先自己打印出来看看往往能快速定位方向。8. 实操心得几个我一直沿用的配置原则配置线程池这些年我总结了几条比较实用的原则不一定适合所有业务但大多数场景下可以作为参考。第一有界队列是底线。不管什么业务只要不是明确知道任务量极低且无峰值就不要用无界队列。无界队列会把问题藏起来直到以更难看的方式爆发。第二拒绝策略优先考虑CallerRunsPolicy。丢了任务出线上事故的代价远大于调用线程阻塞一下的代价。你完全可以通过监控来感知调用线程是否有阻塞异常但永远无法感知一个静默消失的任务。第三maximumPoolSize不是越大越好。线程池性能曲线整体是个倒U形线程数超过某个阈值后吞吐量反而下降。这个阈值取决于CPU核数和任务类型你可以通过压测来找拐点。第四线程池参数必须和监控配套上线。没有监控的线程池调优就是盲人摸象。你调了一个参数不知道它对系统造成了什么影响下次再调还是靠猜。最后单独说一点线程池是团队公共组件改动参数影响面很大。每次调整前先写清楚调整的原因、预期的效果、回滚的方案别一个人悄咪咪改了就跑。我见过太多因为顺手调了一下线程池参数引发的生产事故调参本身不难难的是把影响面控制住。9. 从线程池参数看系统设计思维很多人觉得线程池就是几个数字配置一下的事情但我更愿意把它看成系统设计的一个缩影。线程池的容量、队列、拒绝策略本质上是在表达一种系统策略你希望系统在压力面前如何表现是优雅地排队等待还是果断拒绝保护自身还是调用方一起分担压力。这种思维方式会延伸到很多设计决策上。比如消息队列的消费端要不要做流控缓存穿透时要不要加限流微服务之间要不要做熔断。它们的底层逻辑都是一样的资源是有限的流量是波动的系统必须在保稳定和保吞吐之间找到那个平衡点。回到线程池本身没有一套万能的配置能适配所有业务。我能给出的最中肯的建议是把每个参数的语义彻底搞清楚把监控指标盯起来让每一次调参都有数据做支撑。做到这三点你对线程池的理解就已经超过绝大多数人了。