最近重构项目,复盘了线上好几次莫名卡顿、任务丢失、CPU 飙高的问题,最后排查下来,80% 都是线程池使用不当导致的。
说实话,线程池的知识点不算难,面试的时候基本都能背出来核心参数、拒绝策略、工作原理。但真正落到业务代码里,很多人包括我自己早期开发,都是图方便直接Executors.newXXXThreadPool()一把梭,或者随意配置核心参数,线上出问题才追悔莫及。
这篇文章不聊基础八股文,不扯源码逐行解析,只分享工作中真实遇到的、能直接导致线上故障的线程池坑,以及对应的落地解决方案。看完至少能帮你避开绝大多数线程池线上翻车场景。
一、最大的坑:直接使用 Executors 静态方法创建线程池
这是新手最容易犯,也是线上最常见的问题。
很多业务代码里随处可见这种写法:
// 错误示范 ExecutorService executor = Executors.newFixedThreadPool(10); ExecutorService singleExecutor = Executors.newSingleThreadExecutor(); ExecutorService cacheExecutor = Executors.newCachedThreadPool();
阿里开发手册明确禁止这种写法,但还是有很多人偷懒照用。
我们拆解下真实风险:
1.newFixedThreadPool / newSingleThreadExecutor
底层队列是LinkedBlockingQueue,无界队列。
一旦任务处理速度跟不上提交速度,任务会无限堆积,队列持续膨胀,最终OOM 内存溢出。之前线上有个定时任务,高峰期一秒提交上千任务,队列疯狂扩容,不到半小时服务直接挂掉。
2.newCachedThreadPool
核心线程数 0,最大线程数 Integer.MAX_VALUE,空闲线程 60 秒回收。
高并发场景下会疯狂创建线程,操作系统线程数打满,导致CPU 上下文切换爆满、服务假死。
正确做法:手动 new ThreadPoolExecutor 构造线程池
根据业务场景自定义核心参数,绑定有界队列,从根源杜绝无限堆积、无限创建线程的问题。
// 正确业务写法 ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("business-pool-%d").build(), new ThreadPoolExecutor.AbortPolicy() );
这里提一句:线程池参数没有绝对的万能公式,IO 密集型、CPU 密集型、混合业务的参数配置完全不同,必须结合自身业务压测数据调整,不要照搬网上的参数。
二、忽略线程池拒绝策略,线上任务静默丢失
很多人配置线程池时,拒绝策略直接默认,或者随便选一个,根本不关注业务适配性。
默认拒绝策略是AbortPolicy,队列满、线程数打满后,直接抛出RejectedExecutionException异常。
但还有更坑的两个策略:
1.DiscardPolicy:直接丢弃任务,不抛异常、不打印日志,业务完全无感知,任务悄无声息丢失,排查问题极其困难。
2.DiscardOldestPolicy:丢弃队列最旧的任务,执行新任务,会导致有序业务逻辑错乱、数据批次混乱。
之前做订单异步通知业务,早期用了 DiscardPolicy,高峰期大量回调任务被丢弃,用户收不到通知、商家对账异常,排查了整整一天才定位到是拒绝策略的问题。
业务落地建议:
1. 核心业务(订单、支付、数据同步):绝对不用丢弃策略,建议自定义拒绝策略,任务拒绝后存入 MQ/本地缓存,做重试兜底;
2. 非核心业务(日志统计、非关键埋点):可使用 CallerRunsPolicy,让提交任务的主线程自己执行,削峰兜底;
3. 所有拒绝场景必须打印详细日志 + 监控告警,包含任务参数、队列容量、线程数、拒绝时间。
三、线程池无隔离,核心业务被非核心业务拖垮
这是中大型项目最容易出现的架构问题。
很多项目全局只用一个公共线程池,所有任务:订单处理、日志打印、文件导出、消息推送、数据统计全部共用。
一旦某一个非核心任务(比如大批量 Excel 导出)耗时暴涨、线程阻塞,会占满整个线程池的所有线程。
直接后果:核心订单业务无线程可用、任务排队超时、接口大量报错。
这就是典型的业务不隔离,一损俱损。
解决方案:按业务维度拆分线程池
1. 核心业务池:专门处理订单、支付、交易等核心链路,配置较高优先级、独立队列,资源充足;
2. 非核心业务池:处理日志、统计、导出、埋点等非强一致业务;
3. 定时任务独立线程池,和业务线程池完全隔离;
4. 耗时阻塞任务(IO、网络请求)单独池,避免占用快速执行的业务线程。
拆分之后,哪怕导出任务打爆非核心线程池,也完全不会影响核心交易链路的稳定性。
四、线程池线程不命名,线上排查日志直接懵逼
这个问题不算故障,但极度影响排查效率。
默认线程池的线程名都是pool-1-thread-1、pool-1-thread-2这种默认格式。
线上日志、堆栈报错、线程dump信息全部是统一命名,一旦出现线程阻塞、死锁、超时问题,根本分不清是哪个业务的线程池出的问题,只能逐个注释排查,浪费大量时间。
规范做法:所有自定义线程池必须指定线程名称前缀
推荐用guava ThreadFactoryBuilder快速构建,简单高效:
ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("order-business-pool-%d") .setDaemon(false) .build();
命名之后,线程 dump、日志堆栈可以一眼定位业务场景,排查效率直接翻倍。
补充一点:核心业务线程不建议设置为守护线程,避免服务关闭时任务被强制中断,导致数据不一致。
五、忘记线程池关闭,导致服务优雅下线失败
很多同学只关注线程池怎么用,完全不关注怎么关闭。
Spring 项目服务重启、灰度发布时,经常出现:服务已经下线,但是后台线程还在跑任务,导致数据重复处理、接口幂等失效、数据库脏数据。
根源就是:自定义线程池没有随 Spring 容器销毁而关闭。
静态创建的线程池、自定义全局线程池,不会被 Spring 自动管理,服务销毁时不会主动 shutdown。
落地解决方案:交给 Spring 托管,实现优雅关闭
将线程池声明为 Bean,Spring 容器销毁时会自动执行 shutdown 方法,同时可以配置等待任务执行完成的超时时间。
@Bean("orderThreadPool") public ThreadPoolExecutor orderThreadPool() { return new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(), new ThreadPoolExecutor.AbortPolicy() ); } // 服务销毁时优雅关闭 @PreDestroy public void shutdownPool() { if (!orderThreadPool.isShutdown()) { orderThreadPool.shutdown(); try { if (!orderThreadPool.awaitTermination(3, TimeUnit.SECONDS)) { orderThreadPool.shutdownNow(); } } catch (InterruptedException e) { orderThreadPool.shutdownNow(); } } }
这个配置可以完美解决服务重启时任务残留、重复执行的问题,适配绝大多数线上业务场景。
六、最后总结一下工作中的线程池使用原则
写这篇文章的初衷,是发现很多开发者对线程池的理解只停留在面试层面,落地代码全是问题。结合线上踩坑经验,总结几条永久受用的开发原则:
1.坚决杜绝 Executors 静态创建线程池,全部手动自定义参数;
2. 所有线程池必须使用有界队列,杜绝 OOM 隐患;
3. 拒绝策略按需适配核心/非核心业务,禁止无脑丢弃任务;
4. 业务按优先级、场景做线程池隔离;
5. 线程必须自定义命名,方便线上问题排查;
6. 自定义线程池必须手动优雅关闭,适配服务上下线;
7. 核心线程池必须配置监控(队列积压、线程活跃数、拒绝次数)。
线程池看似简单,但却是后端稳定性的重中之重。很多线上诡异的性能问题,深挖到底,基本都是这种基础组件使用不规范导致的。