线程池这个东西网上教程一抓一大把各种面试题也把它当常客。但真到了项目里要你写一套能上生产的自定义线程池工具很多人还是发怵参数怎么定线程工厂要不要自定义拒绝策略选哪个监控怎么埋点什么时候该动态调参什么时候要优雅关闭。说实话我做了这么多年服务端开发光在这一点上踩过的坑两只手都数不过来。这篇博文主要讲我自己沉淀的一套自定义线程池工具从参数如何计算、线程工厂如何命名、拒绝策略如何选择到动态调参、优雅关闭、常见问题排查全部给可落地的代码和实战记录。适合刚接手项目的人、准备做微服务或中间件的开发者也适合那种线程池已经乱成一锅粥、想统一治理却又不知道怎么下手的团队。1. 放着Executors不用偏要自己封装线程池1.1 默认线程池的坑你大概率也踩过很多人一开始写代码图省事直接就是Executors.newFixedThreadPool(10)。这个快捷方法本身没什么错错在你把它当成万能药之后坑就一个个来了。newFixedThreadPool用的是无界的LinkedBlockingQueue。什么意思就是当核心线程全部忙不过来的时候新任务会无限往队列里堆。如果接口突然被刷爆10个线程处理不过来队列里可能瞬间积压几十万个任务内存直接涨上去轻则GC压力爆表重则OOM把进程打挂。我见过线上事故现象就是日志还在打印接口响应已经几十秒再一看JVM内存曲线直冲天花板。newCachedThreadPool更猛它用的是SynchronousQueue最大线程数是Integer.MAX_VALUE。任务一多线程数就跟着涨直到把系统资源吃干净。说难听点这种线程池在流量毛刺面前就是定时炸弹。还有一个容易被忽略的坑默认ThreadFactory创建的线程名字长这样——“pool-1-thread-1”。你根本看不出它属于哪个业务模块。线程池的数量一多线上排查的时候全靠线程名字猜这是谁的线程池效率低到想骂人。1.2 自定义线程池工具到底解决什么问题自己封装线程池的核心目的不是炫技而是把“线程池在项目里怎么用”这事变成一套统一规范。第一参数集中管理。所有线程池配置要么配置文件收口要么通过配置中心下发不会出现张三写一个死线程池、李四又写一个活线程池的情况。第二命名规范统一。每个线程池都按业务模块命名线上dump线程栈一眼就能看出是哪个服务哪个模块卡住了。第三可以埋监控点。线程池的活跃线程数、队列积压量、拒绝次数这些指标可以主动上报而不是出了问题再去猜。第四便于动态调参和优雅关闭。服务重启时能统一优雅关闭避免丢任务。说白了你封装的不只是一个类而是一套“线程池的SOP”。2. 核心参数其实不复杂关键是要算得明白2.1 七个参数逐个拆解别再看博客背参数了ThreadPoolExecutor的构造函数有七个参数corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime非核心线程空闲存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。很多人不理解“为什么线程池是先往队列放任务而不是先创建线程到最大数”。这是理解线程池的钥匙。任务提交时的执行顺序是核心线程没满直接创建核心线程执行核心线程满了优先把任务丢进队列队列也满了才会创建非核心线程直到maximumPoolSize最后实在扛不住了才触发拒绝策略。我用生活里的银行柜台来类比。核心线程就是我们平时开的柜台窗口队列就是大厅里的等候区非核心线程就是临时加开的窗口。人不多的时候先开固定窗口固定窗口都忙着新来的人去等候区排队等候区也坐不下了银行才临时加开窗口加开的窗口也全满了那就只能告诉客户“今天接待不了请改天再来”。2.2 corePoolSize 和 maximumPoolSize 的计算方法核心线程数到底设多少网上有“CPU密集型设为 CPU核心数1IO密集型设为 CPU核心数×2”的说法。这个说法适合起步估算但真要严谨一点IO密集型更推荐用公式核心线程数 CPU核心数 / (1 - 阻塞系数)阻塞系数在0到1之间IO密集型的阻塞系数通常在0.8到0.9左右。比如一台16核的机器阻塞系数按0.85估计算出来大概是106。当然这是个经验起点真正上线前还是要靠压测去校准。我给你举个实际例子。早前做一个网关项目机器是8核业务主要是调用下游HTTP接口平均响应时间在200ms左右属于典型的IO密集型。一开始按公式设了32个核心线程压测发现吞吐量不错但CPU还远没到瓶颈。后来逐步叠加到48个核心线程吞吐量提升了约四成CPU也稳在70%上下。这说明公式给的是参考肉还是得拿数据来试。maximumPoolSize通常设为corePoolSize的2倍左右但前提是队列容量要配合好否则线程数根本不可能涨到最大值。因为任务是优先进队列的队列容量如果设得特别大最大线程数就是摆设。2.3 队列怎么选容量怎么定常见队列有几个选择。SynchronousQueue不存任务任务来了必须马上交给线程处理适合“不想积压、快速失败”的场景。LinkedBlockingQueue是无界或有界都可以的链表队列如果设了容量上限这和ArrayBlockingQueue差不多都属于典型的“缓冲池”。实际项目里我绝大多数情况会用有界的LinkedBlockingQueue或ArrayBlockingQueue。队列容量怎么估算可以先按这个公式摸个底队列容量 ≈ 峰值QPS × 平均响应时间(秒) 一些多余缓冲举个例子接口峰值QPS是200平均响应时间是250ms那在途任务数大约是50可以做平滑同时给突发流量留部分缓冲队列容量可以先定在500左右。再用压测验证看任务是否积压、拒绝次数是否频繁。我必须提一个容易被忽略的点keepAliveTime只对非核心线程生效。默认情况下核心线程即使空闲也不会被回收。如果你希望流量低谷时连核心线程也释放资源可以调用allowCoreThreadTimeOut(true)这样核心线程空闲超过 keepAliveTime 也会退出适合那种夜间流量比白天低好几倍的场景。3. 自定义线程池工具的设计与落地3.1 一个够用的线程池工厂类长什么样我倾向于用简单的Builder风格封装线程池工厂而不是搞特别复杂的框架。下面是一个我项目里用过的模板按需调整即可public class ThreadPoolFactory { public static ThreadPoolExecutor createPool(String poolName, int coreSize, int maxSize, int queueCapacity) { return new ThreadPoolExecutor( coreSize, maxSize, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(queueCapacity), new NamedThreadFactory(poolName), new ThreadPoolExecutor.AbortPolicy() ); } public static ThreadPoolExecutor createPool(String poolName, int coreSize, int maxSize, int queueCapacity, RejectedExecutionHandler handler) { return new ThreadPoolExecutor( coreSize, maxSize, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(queueCapacity), new NamedThreadFactory(poolName), handler ); } }这个工厂类本身没有多少技术含量真正的重点是后面几个类。如果你团队里已经有现成的配置中心可以把 coreSize、maxSize、queueCapacity 这些参数都拉出来做成配置项这样修改线程池参数不用重新发版改配置再刷新即可。3.2 线程工厂给线程起个好名字是关键很多默认线程池的最大问题在于线程名字没有业务含义。我自己写的命名工厂大概是这样的public class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger seq new AtomicInteger(1); public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix -thread- seq.getAndIncrement()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, throwable) - log.error(线程 {} 抛出未捕获异常, thread.getName(), throwable)); return t; } }线程名字有业务前缀之后线上用jstack抓线程栈看到order-thread-12、push-thread-5第一眼就能定位是哪个模块的线程池出了问题。有个细节需要注意setUncaughtExceptionHandler非常关键。尤其对于execute()提交的任务如果任务内部抛出未捕获异常线程池不会把它打印出来很多线上问题就是这么无声无息消失的。有了这个Handler至少能把异常记录下来给排查留个线索。另一个细节setDaemon(false)。除非有特殊原因否则业务线程池不要随意设置成守护线程。要是设置成 daemon在服务关闭时可能直接被打断任务是来不及正常完成的。3.3 拒绝策略的选用别总是默认抛异常AbortPolicy是默认策略任务被拒绝时会抛RejectedExecutionException。它适合那些任务必须成功的场景但代价是可能在高峰期直接炸掉业务。CallerRunsPolicy是“调用者运行”策略被拒绝的任务不会丢而是回退给提交任务的线程去跑。这个策略我推荐用于异步通知、日志同步一类允许阻塞的场景因为它天然限制了提交速度又不会丢任务。但要注意提交任务的线程如果是HTTP请求线程任务被拒时HTTP线程会被迫去执行任务可能直接影响接口响应。DiscardPolicy和DiscardOldestPolicy都属于默默丢弃型看起来不抛异常实际业务数据可能就丢了绝大多数场景不建议用。我自己用得最多的是自定义策略。比如记录拒绝次数把被拒绝的任务发送到重试队列或者打日志后延迟重投public class MonitorRejectedPolicy implements RejectedExecutionHandler { private final AtomicLong rejectedCount new AtomicLong(0); private final String poolName; public MonitorRejectedPolicy(String poolName) { this.poolName poolName; } Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { long count rejectedCount.incrementAndGet(); log.warn([{}] 任务被拒绝累计拒绝次数: {}, poolName, count); // 此处可以接入告警或降级逻辑 } }这种自定义策略配合监控线程池是否接近瓶颈你在日志和指标里能清清楚楚看到。3.4 监控埋点没监控的线程池等于裸奔线程池里的很多指标已经通过ThreadPoolExecutor的API提供了关键是你有没有主动去采集。我常用的监控指标有这么几个getPoolSize()当前线程数、getActiveCount()活跃线程数、getQueue().size()队列积压量、getTaskCount()累计任务数、getCompletedTaskCount()完成任务数。队列积压量和活跃线程数是最值得盯的积压一直涨代表处理能力跟不上。一个简单的监控任务可以这样写public class ThreadPoolMonitor { private final ThreadPoolExecutor executor; private final String poolName; private final long warnQueueSize; public ThreadPoolMonitor(String poolName, ThreadPoolExecutor executor, long warnQueueSize) { this.poolName poolName; this.executor executor; this.warnQueueSize warnQueueSize; } public void report() { int active executor.getActiveCount(); int poolSize executor.getPoolSize(); int queueSize executor.getQueue().size(); long completed executor.getCompletedTaskCount(); long taskCount executor.getTaskCount(); if (queueSize warnQueueSize) { log.warn([{}] 队列积压: {}, 活跃线程: {}, 完成数/任务数: {}/{}, poolName, queueSize, active, completed, taskCount); } } }你可以用一个定时任务每10秒调一次report()也可以接入Micrometer直接打到监控系统。一开始没有监控工具也没关系先打日志后面再逐步做可视化总比什么都看不见强。4. 动态调整参数与优雅关闭的实践4.1 线程池参数是动态可调的线程池的corePoolSize和maximumPoolSize并不是创建之后就不能改。setCorePoolSize()和setMaximumPoolSize()就是干这个用的。什么时候需要动态调整典型的场景是促销活动或大促前流量预估要翻好几倍你可以在活动开始前把核心线程数和最大线程数调上去并适当缩短队列容量避免积压太多活动结束后再调回来避免资源浪费。给你一个手感参考executor.setCorePoolSize(64); // 原来32临时调到64 executor.setMaximumPoolSize(128); // 原来64临时调到128 executor.setKeepAliveTime(60, TimeUnit.SECONDS); // 非核心线程空闲60秒回收但是调整有个边界条件maximumPoolSize不能小于corePoolSize反过来corePoolSize也不能大于maximumPoolSize否则会抛IllegalArgumentException。另外如果你在配置中心里管理参数需要留意setCorePoolSize的副作用将核心线程数调小时多出来的核心线程不会立即销毁而是等它们执行完当前任务才退出将核心线程数调大时线程池也不会立刻把线程创建齐而是等任务提交时逐个创建。4.2 shutdown 和 shutdownNow区别比你想象的大shutdown()是拒绝新任务然后等已提交任务全部执行完线程池自然退出。shutdownNow()是拒绝新任务同时给工作线程发中断信号正在执行的任务会被中断返回一个等待执行的任务列表。你想想如果任务里写的是“一旦有中断就立刻抛异常退出”那shutdownNow()就会非常“暴力”。反过来如果任务压根不响应中断shutdownNow()也没有办法立刻终止它所以这个名字里的“Now”是相对的。实际项目里怎么优雅关闭我的做法是三步走executor.shutdown(); if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { ListRunnable dropped executor.shutdownNow(); if (dropped ! null !dropped.isEmpty()) { log.warn(还有 {} 个任务未执行已取消, dropped.size()); } }awaitTermination是等待线程池进入终止状态超时后如果还没停完再用shutdownNow强制处理剩余任务。这套流程在Spring项目里可以放在PreDestroy或DisposableBean的destroy()方法中服务重启时自动执行。4.3 统一管理所有线程池而不是散落各地如果你项目里已经有多个线程池散落在不同代码里我建议你做一个线程池注册表把所有线程池实例汇总到一个管理器里统一提供获取方法、监控上报和优雅关闭入口。Component public class ThreadPoolManager implements DisposableBean { private final MapString, ThreadPoolExecutor poolMap new ConcurrentHashMap(); public void register(String name, ThreadPoolExecutor executor) { poolMap.put(name, executor); new ThreadPoolMonitor(name, executor, 200).start(); } public ThreadPoolExecutor get(String name) { return poolMap.get(name); } Override public void destroy() { poolMap.forEach((name, executor) - { executor.shutdown(); try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }); } }有了统一注册表哪个业务模块在线程池管理上有问题查一个入口就能看全。这一点在团队协作里价值很大你不太可能指望每个人都能自觉维护线程池的完整生命周期。5. 常见问题与排查技巧实录5.1 任务提交了线程数却迟迟不涨正常吗这是很多人接触线程池后问得最多的一个问题。明明把maximumPoolSize设到了100但活跃线程一直只有核心线程数那么多任务还在排队。这里面有一个常见的误解线程数要升到最大必须先让队列满。只要队列没满线程池就认为排队能解决不会新建非核心线程。所以你看到的现象在逻辑上其实是“符合设计”的并不一定是故障。但如果你想尽快处理任务而不想干等队列填满有两个思路。一个是用SynchronousQueue它不缓冲任务任务一来就必须开线程处理这样线程数会迅速趋向最大另一个是把队列容量调小给非核心线程更多“出场”的机会。5.2 队列一直在堆积接口延迟越来越高怎么办现象是任务积压原因通常是两个方向要么入口流量超过了处理能力要么线程数量配置不合理。先从数据看问题。看getActiveCount()是否已经接近maximumPoolSize如果活跃线程数还没到顶说明线程池还有余力是队列容量太大把任务都吸住了这时候可以适度调小队列容量如果活跃线程数已经顶到maximumPoolSize队列还在涨说明真扛不住了需要调大maximumPoolSize和corePoolSize或者做业务降级。还有个小技巧调大线程数之前先确认瓶颈在不在CPU。如果CPU已经打满单纯增加线程数反而会放大上下文切换开销整体性能可能不升反降。线程数不是越大越好这个度只能靠压测去摸。5.3 线程池与 ThreadLocal 结合时的脏数据问题线程复用是线程池的本性但这也带来了一个隐患如果线程里用了ThreadLocal任务A往里面塞了一个用户ID任务B执行时可能直接读到任务A的值造成数据串号。这在跑批、多租户场景中非常致命。所以线程池里的任务如果涉及ThreadLocal我强烈建议形成两个习惯进入任务时显式set离开任务时在finally里remove。你也可以在执行任务时包一层Runnable装饰统一清理。public class ThreadLocalCleanTask implements Runnable { private final Runnable delegate; public ThreadLocalCleanTask(Runnable delegate) { this.delegate delegate; } Override public void run() { try { delegate.run(); } finally { ThreadLocalContext.clear(); } } }5.4 线程池之间相互等待导致死锁有一种死锁很隐蔽任务A在线程池里又向同一个线程池提交了任务B然后A在等B的结果。如果此时线程池里的线程全都在执行这种“老子等儿子”的任务队列里还躺着任务B那么线程池就再也没有空余线程去执行B死了等在那里。解决办法有两个方向要么把父子任务放到不同的线程池里要么使用CallerRunsPolicy作为兜底让父任务在极端情况下自己执行子任务。无论选哪个都要在代码评审时重点检查“在线程池任务里再往线程池提交任务”这种模式。5.5 常见问题速查表现象可能原因优先处理手段线程数一直不涨队列未满未触发创建非核心线程调小队列容量或改用SynchronousQueue队列积压持续增加处理能力不足或入口流量过大压测确认CPU水位调大线程池参数任务被拒绝线程池和队列都打满看拒绝次数调整参数或自定义拒绝策略线程名字混乱未使用自定义ThreadFactory统一线程命名规范ThreadLocal值串号线程复用导致变量残留任务前set任务后remove关闭时任务丢失直接调用shutdownNow先shutdown再awaitTermination最后shutdownNow最后说点题外话如果你是在一个还没治理过线程池的项目里做这件事不要急着一次性把所有线程池全部替换掉。最稳妥的方式是先把新代码的统一入口打开让新创建的线程池走这套工具然后挑一两个核心业务模块做试点验证监控数据、拒绝策略、关闭流程是否合理。等经验成熟了再逐步把老旧代码里的线程池迁移过来。我在实际项目中还养成了一个习惯每个线程池的配置改动后压测几轮把线程数、队列积压量、拒绝次数记录下来。时间长了手里就有一批各个业务模块的“线程池健康基线”再有人问“参数设多少合适”直接翻历史数据比任何理论估算都有说服力。线程池本身不复杂复杂的是你在真实流量面前有没有观察它、保护它的意识。