线程池这玩意儿我估计每个写并发代码的工程师都跟它打过交道。我第一次被它坑是在处理一批图片转码任务的时候图省事用了现成的工具类创建线程池结果线上直接内存溢出排查了一下午才发现是队列无界堆积导致。从那以后我把线程池的参数和执行逻辑从头到尾翻了几遍今天就把最关键的东西一次讲清楚线程池的各个参数到底在控制什么任务提交之后又是按什么顺序被处理的。这篇文章适用所有要写并发代码的人不管你是刚接触线程池还是已经被参数调优折磨过几轮耐心看完应该能少踩几个我踩过的坑。1. 线程池要解决的核心问题以及参数为什么难调1.1 线程池背后管理的三样东西线程池本质上做的是两件事复用线程缓冲任务。Java里每次new一个Thread再start涉及操作系统层面的线程创建、栈空间分配、上下文切换资源准备在高频提交任务的场景下开销相当可观。线程池的思路就是把一批线程养在池子里反复用让任务以“排队”的形式进入由空闲线程来取。很多人背了一堆参数含义但始终配不好核心原因是没有把线程池看成一个动态系统。我们用个生活化的类比线程池就像一家餐厅的后厨。核心线程数是固定编制的厨师每天准点到岗阻塞队列是门口等位的排队区最大线程数是高峰期限时雇来的临时帮厨拒绝策略就是实在接待不了时挂出的“今日客满”牌子。你单独告诉厨师要烧几个菜、排队区能站多少人、临时工什么时候走听起来都简单但组合起来不同客流量下的表现完全不一样。1.2 参数之间是联动关系不是孤立配置项ThreadPoolExecutor的构造参数包括corePoolSize、maximumPoolSize、workQueue、keepAliveTime、threadFactory、handler。很多教程把它们每个单独讲一遍但实际配置时必须当作整体来思考corePoolSize决定常态吞吐maximumPoolSize决定峰值能力workQueue决定两者之间的缓冲策略handler决定极端情况下的行为。它们之间最关键的联动关系是只有当核心线程数被占满并且队列也放不下任务时才会去创建新的非核心线程。很多人把队列和最大线程数的关系理解反了导致线程数一直上不去任务却越积越多。这里有一个很多文档都含糊带过的点线程池并不是“核心线程满了就立刻扩容”而是优先尝试把任务塞进队列。只有当队列offer失败也就是队列已满时线程池才会判断是否能继续创建新线程。这个顺序如果记错了后面所有的调优思路都会跑偏。2. 六个核心参数逐个拆解含义、选型思路与误用场景2.1 corePoolSize核心线程数核心线程是线程池的“保底劳动力”默认情况下即使空闲也不会被回收它们就蹲在池子里等待任务。核心线程数设置的依据要分场景来看CPU密集型任务核心线程数参考CPU核心数1原因是有个线程在线程阻塞或偶尔让出CPU时能顶上又不至于因为过多线程导致频繁上下文切换。IO密集型任务核心线程数参考CPU核心数×2甚至更大因为IO操作时线程大部分时间在等待CPU空着更多线程能让CPU在等待间隙去处理别的工作。但注意这个公式只是起点不是定论。实际还得配合队列来看如果你把corePoolSize设成8队列容量设成100那么前8个任务会直接由核心线程跑后面的任务全部进队列缓存。如果核心线程一直在忙队列里的任务就会慢慢等。线上真实的黄金参数往往是通过压测测出来的而不是一套公式算出来的。还有一个很多人不知道的细节核心线程数可以在运行期通过setCorePoolSize动态调整。这意味着你可以在流量高峰前手动调大让线程池提前养好一批线程而不是等到任务进来了再一个个创建。2.2 maximumPoolSize最大线程数最大线程数是整个线程池能持有的线程个数上限包括核心线程和非核心线程。它决定了这个池子在最极端情况下能同时手忙脚乱地处理多少任务。它和队列的关系要尤其注意只有当阻塞队列已经满了线程数也还没到最大值时线程池才会去创建新的非核心线程。我见过一个典型配置错误maximumPoolSize设成200却配了一个容量为1000的队列。结果就是队列永远装得下非核心线程根本不会被创建那个200形同虚设。线程数上限的调整必须和队列容量放在一起权衡。如果队列给了很大的缓冲最大值设得再高也只是一个数字摆设。2.3 workQueue阻塞队列的类型选择阻塞队列决定任务排队的方式常见选择有三类队列类型实现类特点与风险有界队列ArrayBlockingQueue有明确容量上限队列满后触发扩容或拒绝可控性最好无界队列LinkedBlockingQueue任务可以无限堆积线程数永远不会达到最大值风险是内存溢出直接交接SynchronousQueue不缓存任何任务一个提交任务必须马上有线程接否则就创建线程到上限无界队列是最容易引发事故的选择。很多人图省事直接用LinkedBlockingQueue没有任何容量限制表面上线程数不用调实际上只要任务提交速度超过处理速度任务就会无限堆积最终把堆内存打爆。Executors工具类里的newFixedThreadPool和newSingleThreadExecutor默认用的就是无界队列这也是我极力不推荐在生产环境直接用它俩的原因。SynchronousQueue则是另一个极端它不存任务提交的任务必须有人立刻接手。这个队列配合很大的maximumPoolSize可以实现比较激进吞吐策略但如果没有快速处理能力它会立刻触发大量线程创建和拒绝策略对资源抢占非常猛。2.4 keepAliveTime空闲线程的存活时间keepAliveTime只对非核心线程发挥作用当一个非核心线程空闲时间超过这个值它就会被回收掉。这个参数相当于“临时帮厨的合同期限”高峰期过了没活儿干就让人走。更小众但极其有用的配置是allowCoreThreadTimeOut把它设为true之后核心线程在空闲超过keepAliveTime之后也会被回收。这个配置非常适合那种流量波动极大、低峰期不想养太多空闲线程的场景。但有个坑allowCoreThreadTimeOut一旦开启线程池可能在某段时间一个线程都不剩下次任务突然涌入时需要从零开始建线程响应会有个短暂的延迟。2.5 threadFactory给线程起个容易认的名字这算是最容易被忽略的参数却是排查问题时的大救星。默认的DefaultThreadFactory生成的线程名是pool-N-thread-M一旦服务里有多个线程池在运行打印出来的线程名根本分不清是哪个池子的。正确做法是自定义ThreadFactory给线程池起个带业务含义的名字比如image-compress-thread。这样当你在日志里看到某个线程持续卡住或CPU飙高时一眼就能定位到是哪个业务模块的线程池出了问题。另外还能顺带设置是否为守护线程、统一设置线程优先级让你对线程的生命周期有更强的掌控。2.6 handler四种拒绝策略到底怎么选当队列已满、线程数也达到最大值时新任务会被交给拒绝策略处理。JDK自带四种策略行为适用场景AbortPolicy直接抛出RejectedExecutionException默认策略适合能接受异常报警的场景CallerRunsPolicy由提交任务的线程自己执行该任务流量压垮线程池时把压力回抛给调用方DiscardPolicy静默丢弃该任务几乎不推荐丢了也不知道DiscardOldestPolicy丢弃队列里最旧的一个任务再提交当前任务适用于数据有时效性的场景我个人的习惯是如果不需要严格保证不丢任务优先选CallerRunsPolicy因为它至少能让调用线程亲自干活起到天然限流的作用同时任务不会丢。AbortPolicy适合你把线程池当闸门用、希望超出能力直接报警的场景。DiscardPolicy我建议有多远离多远生产环境静默丢任务几乎都是事故源头。有一点要谨记拒绝策略触发说明系统已经过载它不是一种正常处理手段真正该做的是在触发策略之前就通过监控发现问题提前调整参数或分流而不是事后靠拒绝策略兜底。3. 任务提交后的执行顺序一次源码级推演3.1 从execute()方法看完整的四步流程线程池执行任务入口是execute()方法整个判断流程用一句话概括先看核心里有没有位置有就创建线程直接跑没有就尝试放队列队列满了再看能不能创建新线程到最大值还不能就执行拒绝策略。写成源码逻辑大致是下面这样public void execute(Runnable command) { 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); } // 第三步队列已满尝试创建非核心线程 else if (!addWorker(command, false)) // 第四步线程数已达最大值执行拒绝策略 reject(command); }整个过程里最关键的是第二步的offer操作。注意这里用的是offer而不是put这个调用是非阻塞的队列有位置就立刻返回true没位置就立刻返回false不会傻等。正是因为用了offer线程池才能快速地依次判断“入队成功还是失败”失败才进入扩容逻辑。如果用put整个execute就会被阻塞在队列写入上线程池的扩缩容机制就彻底失效了。一旦入队成功线程池还会做一次double-check重新确认线程池还在运行状态以及工作线程数是否为零。如果池子已经被关闭而任务恰好入队成功线程池会尝试把任务从队列里移除并拒绝如果核心线程数为零且没有存活线程那必须addWorker(null, false)创建一个线程去消费队列里的任务。这段recheck逻辑很多人没注意到但它处理了并发环境下的竞态窗口。3.2 执行顺序中两个容易被绕晕的边界条件第一个边界问题是核心线程池满后到底是先入队还是先创建非核心线程答案是先入队。只有当队列已满addWorker才可能执行。这个顺序直接决定了“核心线程数队列容量最大线程数”三者各自的作用范围。你可以这么理解队列容量和核心线程数一起构成了线程池的“常规容量”最大线程数是“应急容量”常规容量用尽才会启用应急容量。第二个边界问题是工作线程数等于零时即使队列不为空也不会有人去执行队列里的任务。这个情况一般出现在线程池创建后从没提交过任务或者核心线程数被设为0、允许线程超时回收之后。此时如果突然有任务入队成功需要线程池主动创建一个“没有具体任务的线程”去队列里取活干。这也是为什么很多配置了allowCoreThreadTimeOut的线程池重启后首次提交任务会感觉“迟钝”——因为线程要从零开始拉起。3.3 用一个小实验验证线程池的执行顺序光看源码容易晕我有个自创的小实验能直观看到线程池的执行顺序。构造一个corePoolSize1、maximumPoolSize3、队列容量2的线程池任务里打印当前线程名和任务编号循环提交6个任务观察线程名变化ThreadPoolExecutor pool new ThreadPoolExecutor( 1, 3, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(2), new NamedThreadFactory(demo-pool), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i 1; i 6; i) { final int taskId i; pool.execute(() - { System.out.println(任务 taskId 由线程 Thread.currentThread().getName() 执行); Thread.sleep(100); }); } pool.shutdown();预期结果是这样的任务1由核心线程demo-pool-thread-1执行任务2和任务3进入队列任务4和任务5会触发创建两个非核心线程来执行任务6到达时队列已满且线程数已达最大值3被拒绝策略处理。如果你把队列容量改成10那所有任务都会排进队列maximumPoolSize3的设置就完全不起作用了只会有1个线程在慢慢消费。跑一次这个实验你对执行顺序的理解会比看十篇文章都深刻。4. 参数组合与场景化配置从理论到实战4.1 CPU密集型任务的线程池配置CPU密集型任务的典型特征是线程几乎全程占用CPU比如加解密、图像滤镜计算、复杂的数学运算。这类任务的线程数如果超过CPU核数太多反而会因为上下文切换导致吞吐率下降。一个稳妥的起步方案是corePoolSize和maximumPoolSize都设在CPU核数1队列用一个小容量有界队列比如容量64或根据任务量和处理速度算出来的值。这里的“1”不是玄学是为了应对个别线程偶发缺页中断或锁等待时还能有一个替补线程顶上去。需要注意的是此时maximumPoolSize如果设得很大只会徒增线程竞争和切换成本没有任何收益。4.2 IO密集型任务的线程池配置IO密集型任务的线程大部分时间在等待网络响应、磁盘读写或远程接口返回比如调用第三方服务、读写文件、数据库查询。CPU在等待期间是闲置的这时候适当提高线程数可以让CPU更充分地被利用。业界常用公式是线程数 CPU核数 × (1 平均等待时间 / 平均计算时间)。比如一次任务耗时500ms计算部分只占50ms等待/计算比例是9四核机器配40个线程都不夸张。实际设置时可以把核心线程数按公式结果来maximumPoolSize再往上提20%到50%队列容量按“积压任务数上限”来设定一般经验值是核心线程数 × 单位时间任务吞吐 × 允许排队秒数。4.3 混合型任务与动态调优的取舍真实业务很少有纯粹的CPU密集型或IO密集型大部分是混合的。比如一个异步推送服务任务里既要做一些轻量计算又要等待下游响应。这种场景不能硬套公式建议的做法是先用中等初始值上线比如核数×3。通过监控采集每个任务的计算时间和等待时间再去调整线程数与队列容量。在高峰期前用setCorePoolSize和setMaximumPoolSize动态扩容低峰期再降回来而不是重启服务改配置。另外强烈建议给线程池关键参数做配置化不要写死在代码里。把corePoolSize、maximumPoolSize、queueCapacity、keepAliveTime放到配置中心出现问题可以即时修改实时生效这一步在很多团队里被当成“不紧急”的事直到线上出故障才发现改参数要重新发布白白拉长了故障恢复时间。5. 高频故障与排查实录5.1 无界队列引发的内存溢出当年我遇到的图片转码故障就是这么发生的任务提交速度远大于图片压缩处理速度用的又是默认的无界队列结果几百万个待处理任务全堆在内存里直接OutOfMemoryError。当时JVM的堆已经被队列占满GC濒临瘫痪连dump文件都差点没抓到。排查时第一眼就是去数字节码、看线程状态最后发现每个工作线程都在慢吞吞地做图片压缩后面跟着一个越来越长的队列。这个教训让我彻底记住了参数配置的核心原则生产环境一律不用无界队列因为你无法预测上游调用方的突发流量。就算需要一个大缓冲也建议用有界队列配上限上限到了触发拒绝策略或快速失败也好过整个服务被拖垮。5.2 允许核心线程回收引发的吞吐波动有一次做了一个定时任务系统开发者觉得低峰期没多少任务在跑把allowCoreThreadTimeOut开了keepAliveTime还设得很短。结果每次任务批量提交前线程池的线程数都归零了任务到达后要先现创建线程几万个任务的批量处理整体慢了好几倍。排查手段是看线程池的activeCount和poolSize的变化曲线发现poolSize在0和峰值之间反复跳变基本确定是allowCoreThreadTimeOut开的锅。对这种需要快速响应的系统核心线程就该常年养着如果真要省资源至少把allowCoreThreadTimeOut设成false只让非核心线程在空闲时被回收。5.3 拒绝策略选用不当导致任务静默丢失有一个推送模块用了DiscardPolicy结果流量高峰时大量推送任务被静默丢弃业务方完全没感知到直到用户反馈消息缺失才查出来。排查日志时发现线程池的taskCount和completedTaskCount差值巨大同时没有任何异常记录是很典型的静默丢弃特征。我见过的生产环境里大多数对可靠性有要求的场景都应该选CallerRunsPolicy。它虽然会让调用线程被拖住但至少把压力回放到调用方让调用方感知到速度变慢了从而自动限流而且任务本身不会丢失。5.4 线程池内的线程异常被吞掉的问题ThreadPoolExecutor创建的线程会帮我们把抛出到线程外的异常吞掉线程本身销毁后线程池还会再创建一个新的补上来。如果你依赖异常日志来发现问题很容易什么都找不到。解决方式是设置自定义的UncaughtExceptionHandler把异常打印到专门的日志文件或者在每个任务的catch块里显式处理并上报监控。这里还有个容易混淆的细节如果你的任务在Runnable里被try-catch包住那么线程异常不会走到线程池的UncaughtExceptionHandler里。所以严谨的做法是在业务任务的最外层自己catch Throwable做记录和统计这才是在线程池场景里捕获异常的正确姿势。6. 最后再分享几个我一直在用的排查和调优技巧现在我把线程池参数和顺序的配置经验总结成一套基本流程每一个重要线程池都配一个专属线程工厂名每个线程池暴露activeCount、queueSize、completedTaskCount和最大历史线程数等监控指标所有参数进配置中心运行时可以动态修改。有一个指标容易被忽略但非常关键largestPoolSize它记录线程池运行以来的最大线程数代表了你配置的maximumPoolSize在真实流量下是否被触及过。如果上线几个月后这个值一直远小于你设置的maximumPoolSize说明你的池子可能配大了或者队列容量缓冲了大部分压力。反过来如果频繁触及最大值且拒单次数不少就得分析究竟是线程数不够还是队列容量太小导致任务被过早推到扩容路径上。动态调整时我一般遵循“优先调队列容量其次调最大线程数最后调核心线程数”的顺序因为队列容量的调整影响最小线程数变化则可能引发CPU使用率的连锁反应。另外线程池参数的修改在生产环境要像发布代码一样逐步生效先调整小流量节点的线程池观察监控稳定后再全量这个谨慎度能帮你避开很多“调参翻车”的现场。最后一点建议如果你发现自己频繁在调线程池参数要意识到这通常只是表象。深层的任务量、单任务耗时、下游性能才是根因。把线程池调对只是保证系统在既定容量下稳定运行真正要解决的永远是任务本身怎么更快、怎么减少无效提交以及流量进来之前怎么做好削峰。把参数机制吃透能让你不被表象迷惑快速定位到树根而不是只盯着树叶剪。
阅读完成 · 觉得有帮助?