1. 先想清楚:为什么非要用线程池,自己 new Thread 不香吗
先说一个我真实经历过的线上事故。某个服务半夜爆内存告警,堆内存被顶到近满,GC 持续飙高,我上去翻堆转储,发现里面有十几万个任务对象排队等着执行。顺着引用链查下去,定位到一个接口,每次请求进来都会 new 一个 Thread 去跑一段远程调用,请求量一大,线程数直接冲到上千。线程切换开销、栈内存占用、任务无限堆积,三管齐下把机器拖垮了。
那次事故之后我才真正理解,Java 并发编程里,线程池不是“可选优化项”,而是基础能力。它的核心价值并不是“多线程执行”,而是把线程这种昂贵的资源管起来:复用已创建的线程、控制并发数量、缓冲过量任务、隔离故障边界。一句话:线程池做的是“线程资源的调度和治理”,而不是简单地“开几个线程跑活”。
这个主题适合谁看?三种人最需要:
- 刚学完 Java 基础、开始接触并发编程的初学者,想把 ThreadPoolExecutor 搞透。
- 写了几年业务代码、一直在用 Executors 快捷方法,但没细究过内部机制的后端开发者。
- 在负责线上服务稳定性的人,想搞懂线程池参数失衡会引发哪些连锁故障。
这篇内容我尽量不写教科书式概念,按工程实战的思路拆解:核心参数之间怎么联动、任务从提交到执行经历了什么、快捷工厂方法到底有坑在哪、生产环境怎么配置才不容易出事、真实线上故障怎么排查。每个部分我都会交代背后的“为什么”,而不是丢一堆参数定义让你背。
2. 线程池的“三个核心 + 一个策略”,决定它到底怎么干活
很多人第一眼看 ThreadPoolExecutor 构造方法,7 个参数看着头大。但拆开来看,真正决定线程池行为模式的,是三个核心参数加一个工作队列。
2.1 corePoolSize、maximumPoolSize、workQueue 三方博弈
我把这三个参数的联动关系描述成“一个团队的扩张与收缩规则”,这样更好理解:
- corePoolSize 是正式员工编制。只要线程数没到编制,来一个任务就招一个正式工,哪怕之前招的人都在闲着。
- workQueue 是待办事项缓冲区。正式员工全在忙、新任务进来,先不招人,丢进缓冲区排队等着。
- maximumPoolSize 是总编制上限。缓冲区也满了,再进来任务,才开始招“临时工”,直到总人数达到上限。
这里有个关键点:临时工不是有任务就立刻招,而是队列满了才招。这个规则和很多人的直觉相反——不是先扩张线程再排队,而是先排队再扩张。为什么这么设计?因为线程的创建和销毁是有代价的,合理利用缓冲区可以吸收短期流量峰值,避免线程数剧烈抖动。
2.2 队列的选择直接决定线程池的脾气
队列类型不同,线程池的行为差异非常大。我把常用队列的表现整理成一张表:
| 队列类型 | 是否有界 | 行为表现 | 适用场景 |
|---|---|---|---|
| SynchronousQueue | 不存储元素 | 每个任务必须立刻交给线程处理,没有线程就创建,除非达到最大线程数 | 高吞吐、内部处理足够的场景,配合大 maximumPoolSize |
| LinkedBlockingQueue | 可无界 | 默认无上限,任务永远排队,maximumPoolSize 形同虚设 | 不适合生产环境,容易任务堆积 |
| ArrayBlockingQueue | 有界 | 队列满后触发线程扩张,达到上限后触发拒绝策略 | 生产环境最常用,可控性强 |
| PriorityBlockingQueue | 无界 | 按优先级出队,突破 FIFO | 带优先级排序的任务,但依旧是无界隐患 |
我个人的建议是生产环境一律用有界队列 ArrayBlockingQueue。无界队列最可怕的问题不是线程不够,而是任务无限堆积,堆内存被一堆等待执行的对象塞满,最后 OOM。而且 OOM 之前,线上表现是接口超时、GC 飙升,定位问题的时候你根本想不到是“队列无界”这种配置问题。
2.3 拒绝策略:线程池的最后一道防线
当线程数已经到 maximumPoolSize,并且队列也满了,新任务就会触发拒绝策略。ThreadPoolExecutor 内置四种策略,各有各的脾气:
- AbortPolicy(默认):直接抛 RejectedExecutionException,任务丢弃,调用方感知到异常。
- CallerRunsPolicy:不抛异常,由提交任务的线程自己执行这个任务。这个策略的隐含效果是“自动降速”——调用方的线程被占用去跑任务,新任务提交速度自然变慢。
- DiscardPolicy:静默丢弃,不抛异常不提示,最危险,任务丢了也不知道。
- DiscardOldestPolicy:丢弃队列头部的老任务,腾出位置放入新任务。
我在实际项目中,绝大多数场景选 AbortPolicy,然后在提交任务的地方捕获 RejectedExecutionException,做业务兜底。比如异步任务太重就把结果写入本地表、标记为待补偿,后续扫表重试。CallerRunsPolicy 适合那种“宁可慢、不可丢”的场景,比如银行对账任务,丢了就得查账补救,代价远高于慢一点。
2.4 线程工厂和拒绝策略:生产环境最容易忽略的两个细节
线程工厂(ThreadFactory)我几乎是强制建议自定义。默认工厂创建的线程名字是 pool-N-thread-M,线上出了问题,线程 dump 拉下来,全都长一个样,你根本区分不了哪个线程池出了状况。换成自定义工厂,给线程加上业务前缀,一眼就能对上。
自定义相关代码如下:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(1); private final String prefix = "order-async-pool"; @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, prefix + "-" + counter.getAndIncrement()); t.setDaemon(false); return t; } };这里强调一个细节:线程名里的业务前缀,这个习惯早期养成,后面排查线上问题会省非常多的时间。线程 dump 里看到一堆 order-async-pool-1、order-async-pool-2,配合日志里的线程名,你直接就知道是哪个链路、哪个线程池出了问题。
3. 从提交到执行:线程池内部完整工作流程和状态流转
很多教程里讲流程,画一堆箭头,看完还是记不住。我换一种方式,按“一次任务提交的完整路径”来讲,你跟着走一遍就清楚了。
3.1 单任务提交时的决策链
当你调用 execute(runnable) 提交一个任务时,底层实际是走一套 if-else 判断链:
- 当前线程数 < corePoolSize,直接创建核心线程执行任务,哪怕其他核心线程是空闲的。这套逻辑保证“核心线程满员之前不排队”。
- 当前线程数 >= corePoolSize,尝试把任务放入工作队列。入队成功,等待线程空闲后取出执行。
- 队列已满,且当前线程数 < maximumPoolSize,创建非核心线程立即执行任务。
- 队列已满,线程数也到 maximumPoolSize,走拒绝策略。
这段逻辑每次提交任务都会执行一遍,高频调用,所以判断有没有用锁?没有。线程池通过一个 AtomicInteger 的 ctl 变量同时表达线程数和工作状态,配合 CAS 算法,保证并发判断时状态不丢失。这也是线程池性能能扛住高频提交的底层原因。
还有个细节值得注意:第 2 步入队成功后,任务并不是马上执行,而是等待有空闲线程时从队列头部取出。如果核心线程都在干活,任务会一直待在队列里。线程永远不会去主动“找”队列里的任务,只有当某个线程执行完当前任务,才会从队列里拉下一个。线程池上下文切换的时机,就发生在“当前任务结束”和“取下一个任务”之间。
按 workflow 编码的角度来看,这个设计规避了频繁加锁带来的竞争开销,也让线程的利用率最大化——核心线程干完一个再接一个,中间没有空白期。
3.2 线程池的编号状态流转:一场生命周期管理
线程池有 5 个状态,我按“从生到死”的顺序来记:
- RUNNING:正常运行,可接收新任务,也可执行队列中的任务。
- SHUTDOWN:不再接收新任务,但已入队的任务继续执行。调用 shutdown() 进入。
- STOP:停止接收新任务,也不再执行队列里的任务,正在执行的任务会被中断。调用 shutdownNow() 进入。
- TIDYING:任务全部清空,线程数为 0,进入终止前的过渡态。
- TERMINATED:线程池完全终止,terminated() 钩子方法被调用。
在实际开发中,shutdown() 和 shutdownNow() 的区别必须刻在脑子里:
- shutdown 是优雅停机,先拒绝新任务,让老任务跑完。适合大多数场景,比如应用发布前需要排空队列。
- shutdownNow 是暴力停机,清空队列、中断正在跑的任务。会丢任务、会打断业务,除非你能接受这些代价,否则慎用。
我见过有人把 shutdownNow 当“更彻底的关闭方法”随便用,结果线上的异常对账任务被打断,数据漏了几个小时。正确做法是:shutdown 后调用 awaitTermination(timeout, unit),等待队列排空,超时后再考虑 shutdownNow 兜底。
3.3 execute 和 submit 的区别:看似都是提交,实际完全两码事
execute 接收 Runnable,没有返回值,异常抛给线程内部处理(如果不做兜底,异常信息会直接丢失)。submit 接收 Callable 或 Runnable,返回 Future 对象,可以通过 future.get() 获取执行结果或捕获异常。
有一个非常典型的坑:用 submit 提交一个会抛异常的任务,如果不调用 future.get(),异常会被吞掉。线程池会把异常封装进 Future 内部,但你一直不去取,异常就永远不见天日。表现就是任务失败了,日志里什么都没打,排查半天找不出原因。所以用 submit 必须配套 get(),或者至少用 Future 做一次异常兜底检查。
顺带提一句,submit 底层实现的 Callable 包装器,还处理了 Runnable 的结果包装问题。不过这些细节不用背,记住一句话:execute 适合“只管跑、不关心结果”的任务;submit 适合“需要结果或需要感知异常”的任务。
4. Executors 快捷池与手动创建的取舍:规范警告的到底是什么
我见过太多人图方便,直接用 Executors 的静态方法创建线程池。它确实省事,但里面埋了几个雷,踩中一个就够喝一壶。
4.1 newFixedThreadPool 和 newSingleThreadExecutor 的致命短板
这两个方法内部用的都是 LinkedBlockingQueue,而且没有指定容量,也就是无界队列。前面讲过,无界队列意味着 maximumPoolSize 永远不该被触发,线程数永远不超过核心数,但任务会无限堆积。
打个比方,食堂窗口只有 10 个(核心线程),排队的人不管来多少都让进食堂(无界队列),等 10 个窗口忙完之前,人越堆越多,食堂大厅迟早被站满——最终内存爆炸。
线上场景常见的表现:短期内请求洪峰进来,任务全堆在队列里,线程池的活跃线程一直不高,但 JVM 内存持续上涨,直到 OOM。堆转储出来,一眼就是“某个 LinkedBlockingQueue 塞了几百万个 Runnable”。
4.2 newCachedThreadPool 的隐患:线程数上限在哪里
CachedThreadPool 用的是 SynchronousQueue,没有核心线程,最大线程数为 Integer.MAX_VALUE,线程空闲 60 秒自动回收。听起来很完美,“按需创建、空闲回收”。但它有个隐蔽问题:线程数没有真正上限,如果任务提交速度持续大于处理速度,它会无限创建线程。
线程本身不是免费的——每条线程默认栈内存 1MB(64 位 JVM),1000 条线程就是 1GB 内存。加上线程切换的 CPU 开销,服务在这种状态下会迅速失控。我曾经在一个测试环境跑过压测,不过几万请求,CachedThreadPool 的线程数就冲到了 6000 多,机器直接卡死,SSH 都敲不动。
CachedThreadPool 适合的任务画像:短平快、执行时间极短的异步任务,比如发送一条告警通知。这种任务执行快,SynchronousQueue 的“一手交任务一手出结果”机制能正常流转。如果换成十几个重业务任务同时提交,线程数就会失控。
4.3 手动创建的标准姿势
综合上面的教训,生产环境我更建议直接手动 new ThreadPoolExecutor,所有的参数都由自己掌控。分享一下我常用的“保守型”配置模板:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 60L, // 非核心线程空闲存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), // 有界队列,容量按峰值估算 new CustomThreadFactory("biz-async"), // 自定义线程前缀 new ThreadPoolExecutor.CallerRunsPolicy() // 按业务场景选择策略 );这里核心线程数为什么选 8?为什么最大值选 16?需要结合服务器 CPU 核数来算,具体计算方式下一章单独讲,这里先记住原则:队列有界、线程有名字、拒绝策略有兜底。这三件事做到,线程池基本不会出大乱子。
5. 核心参数怎么调才不出问题——7 个参数逐个拆解
前两章把线程池的内部机制讲透了,这一章聊最实战的问题:参数到底怎么定。先定基调,没有“万能参数”,只有“适应当前业务的参数”。但我可以给出一套可操作的推演思路,你按这个思路去定参数,至少不会踩大坑。
5.1 两个黄金公式:CPU 密集型和 IO 密集型任务的吊打配置
任务类型不同,核心线程数的估算逻辑完全不同。
CPU 密集型任务,比如图像处理、加密解密、大量计算,线程数的经验公式是:CPU 核数 + 1。核数 + 1 的由来是防止某个线程因缺页中断等偶发原因挂起时,还有余量补位。加得更多反而有害——线程太多,CPU 时间片全消耗在线程切换上,真实计算吞吐反而下降。
IO 密集型任务,比如调用远程接口、读数据库、读写文件,这类任务大量时间在等待网络或磁盘响应,CPU 其实闲着。线程数的经验公式来自《Java Concurrency in Practice》:CPU 核数 × (1 + 等待时间 / 计算时间)。分母是“一整个任务里等待和计算的比例”,等待占比越高,线程数可以越多。
举个例子:4 核机器,任务平均耗时 500ms,其中计算耗时 100ms,等待耗时 400ms。那么理论最优线程数 = 4 × (1 + 400 / 100) = 20 个。这个公式算出来的数是“理论起点”,线上还要根据实际吞吐量微调,但起码比拍脑袋强得多。
5.2 队列容量的估算:不能只看线程数,要算积压窗口
队列容量怎么定?我的思路是:看你能忍受多久的延迟。假设核心线程 8 个,单个任务处理耗时 50ms,每秒处理能力约 160 个任务。如果线上正常流量是每秒 100 个任务,洪峰时每秒 300 个任务,持续 10 秒,那么洪峰期间多出来的任务量是 (300 - 160) × 10 = 1400 个。队列如果只开到 500,意味着第 3 秒就会触发扩线程和拒绝策略,导致大量请求被拒。
实际调参时,我倾向于把队列容量订到“洪峰任务量的 80% 左右”,留出 20% 给线程扩张的缓冲时间。同时配合监控,观察队列积压深度是否长期在高水位,持续高就要考虑扩容或优化任务执行时间,而不是盲调参数。
5.3 存活时间和 allowCoreThreadTimeOut 的配合
非核心线程的空闲存活时间默认 60 秒,意思是空闲超过 60 秒会被回收。大多数业务场景这个值够用。但有一种场景要注意:任务提交有明显的波峰波谷,比如整点任务、定时批处理、日报生成。如果波峰间隔超过 60 秒,非核心线程会在波谷被回收,下一个波峰又要重新创建线程,创建成本白白浪费。这种场景建议把 keepAliveTime 调大到 5~10 分钟,或者干脆配合核心线程一起用。
还有一个冷门但实用的方法:allowCoreThreadTimeOut(true)。开启后,核心线程空闲超过存活时间也会被回收。适合那种“长期低流量、偶发高流量”的服务,平时占用资源极少,高峰时再猛拉线程数。注意这个开关开启后核心线程可能完全归零,下一次任务进来时要重新建线程,首次请求延迟会高一些。
5.4 线程池调参最朴素的路线:压测 + 监控 + 复盘
参数没有一次定死的。我通常的调参流程是:
- 按上面的公式和业务画像定一版初值。
- 用压测工具(比如 JMeter 或 wrk)模拟两三倍峰值流量打进去。
- 观察两组核心指标:吞吐量(QPS/TPS)和线程池内部指标(活跃线程数、队列积压、拒绝次数)。
- 吞吐量上不去、活跃线程一直顶满,说明线程数偏小;队列长期高位、任务延迟涨得快,说明队列容量或线程数不够匹配。
- 记录每次调整后的数据,找到一个吞吐和延迟都稳定、且留有余量的组合。
一个常被忽略但很重要的原则:调参要一次只改一个变量。很多人一上来就同时改线程数和队列大小,出了效果也不知道是哪个参数起的作用,出了问题也不知道是哪个参数带来的。这种糊涂账后期运维的时候会非常难受。
6. 进阶玩法:钩子方法、动态调整和监控告警的实战价值
线程池的基本用法看完了,参数也能调了,但想在线上真正用稳、用好,还有三个进阶工具必须掌握。
6.1 钩子方法:beforeExecute 和 afterExecute
ThreadPoolExecutor 留了两个钩子方法,可以在每个任务执行前和执行后插入逻辑。我拿它们干过几件事:
- 记录每个任务的耗时和超时告警,发现某个任务执行超过阈值就打慢日志。
- 统一捕获任务执行过程中的未捕获异常,避免异常被吞掉导致排查困难。
- 统计线程池处理的累计任务数、失败任务数,对比提交数看有没有任务丢失。
钩子方法的使用方式是通过匿名子类覆盖。示例如下:
ThreadPoolExecutor executor = new ThreadPoolExecutor(...) { @Override protected void beforeExecute(Thread t, Runnable r) { long start = System.currentTimeMillis(); // 记录到 ThreadLocal,供 afterExecute 使用 executionStart.set(start); // 也可以在这打印日志,记录线程名 + 任务标识 } @Override protected void afterExecute(Runnable r, Throwable t) { long cost = System.currentTimeMillis() - executionStart.get(); if (cost > slowThreshold) { // 打印慢任务告警日志 } if (t != null) { // 统一记录异常堆栈,不会丢异常 } } };这里有个细节点很关键:afterExecute 的 t 参数,只有 execute 提交方式的异常才会传进来。submit 提交的异常被 Future 吃掉了,这个参数拿不到。所以如果用了 submit,需要额外在任务内部兜底处理,或者在 future.get() 处统一捕获。
6.2 setCorePoolSize 和 setMaximumPoolSize:运行期动态调整
线程池的参数并非定死不变的。两个 setter 方法可以在运行期动态调整核心线程数和最大线程数。这为“动态伸缩”提供了基础能力。
实际场景举个例子:大促开始前,通过配置中心下发指令,把核心线程数从 10 调到 30;大促结束,再调回 10。整个过程服务不重启,线程池内部会通过中断空闲线程或者创建新线程的方式逐步适配新配置。
需要注意细节:setCorePoolSize 调小时,线程池只会中断那些“空闲超过 keepAliveTime”的线程,正在执行任务的线程不会被粗暴打断。所以调小后的线程池收缩,需要等一段时间才能完全生效,不是瞬间完成的。
6.3 线程池的核心监控指标和告警配置
线上线程池如果处于亚健康状态,往往不是立即报错,而是表现为性能劣化。要尽早发现问题,监控指标必须提前做好。我重点盯的五个指标:
- 活跃线程数 getActiveCount():持续接近 maximumPoolSize,说明线程池长期满载。
- 核心线程数 getCorePoolSize():确认配置是否符合预期。
- 队列积压 getQueue().size():持续走高,说明消费速度跟不上生产速度。
- 已完成任务数 getCompletedTaskCount():结合时间维度算吞吐量趋势。
- 拒绝次数(业务侧统计 RejectedExecutionException 的次数):有拒绝说明系统已经在丢任务了,属于严重告警。
这些指标可以定时采集,上报到监控系统。我习惯的告警阈值设置方式:
| 指标 | 建议阈值 |
|---|---|
| 活跃线程数 / 最大线程数 | 持续 10 分钟超过 80% |
| 队列积压 | 超过队列容量的 60% 且持续 5 分钟 |
| 拒绝次数 | 任何一次非零立即告警 |
| 任务平均耗时 | 超过基线值 150% 持续 3 分钟 |
监控的意义不是“出事了才知道”,而是“快出事之前能预判”。活跃线程连续高水位,大概率接下来就是队列积压、任务延迟上升,提早扩容或优化任务逻辑,就可以把事故掐死在苗头阶段。
6.4 线程池隔离:一个池子管所有任务,最容易互相拖累
最后提一个架构层面的经验:线程池按业务隔离。我见过一些项目图省事,所有异步任务共用一个线程池,结果某条链路的慢任务把整个池子的线程全占了,其他不相关的业务也跟着超时。这就是线程池层面的“故障扩散”。
合理的做法是:核心支付链路、异步通知、日志上报、数据批处理,各分各的线程池,池子之间不共享线程。隔离带来的代价是线程池数量变多,配置更复杂,但它换来的稳定性在线上故障时价值极大,这笔投入我认为永远值得。
7. 排查实录:我在项目中真实遇到的 5 个线程池问题
前面讲了很多预防性的经验,这一章专门讲“坏了怎么查”。以下问题都是我在真实项目里踩过或者帮别人排查过的,每个都附上当时的现象和定位思路。
7.1 无界队列引发的 OOM
现象:服务运行一段时间后,内存逐渐涨满,GC 频繁且回收效果极差,最终 OOM。
排查过程:这种问题我一般直接抓堆转储。打开 MAT 或者 jmap 导出的 hprof 文件,优先看“支配树”,发现某个 LinkedBlockingQueue 实例持有了上百万个对象,然后顺着对象的引用链,发现是 Executors.newFixedThreadPool 创建的线程池队列不受限,任务全部堆积在里面。
解决方案:换成有界队列,并配合拒绝策略做业务兜底。修复后同样流量下内存水位稳定,不再持续上涨。
我个人的一个心得:Java 应用 OOM 之前的状态比 OOM 本身更值得关注。GC 频繁、CPU 飙高、接口变慢,这些前兆指标如果早接入监控,可以在真正 OOM 之前几十个小时就发现问题。
7.2 核心线程“不干活”之谜
现象:配置了 10 个核心线程,业务量大时活跃线程却只有 2~3 个,任务大量堆积。
排查过程:第一反应是参数没生效,但打印线程池配置后确认 corePoolSize 是 10。进一步查线程状态,发现大量线程处于 WAITING 状态,等着从队列里取任务。再查任务提交代码,发现根本原因不是线程池参数问题,而是业务代码里连续提交了一大批任务,每个任务内部又加了锁,后续任务全被阻塞在锁等待上。
这里要修正一个误区:线程池“核心线程不干活”,不代表线程池配置有误,也可能是任务本身阻塞了。排查时要顺着线程 dump 看 WAITING 线程的锁等待信息,定位到是哪一个锁、哪一段代码导致的。
7.3 newCachedThreadPool 线程数失控
现象:服务卡死,局势像上面压测时那样,线程数冲到了 7000 多。
排查过程:本地 jstack 拉线程 dump,grep 出线程名前缀,发现全是同一类线程,再溯源代码,找到 Executors.newCachedThreadPool,确认是短时间大量任务提交且每个任务耗时较长,SynchronousQueue 又不存储任务,导致疯狂建线程。
处理方式:按任务类型重新评估——短任务保留 Cached 模式但加信号量限流;长任务改成固定线程池 + 有界队列。这个问题在代码评审阶段比较容易拦下,提醒看代码的人重点关注“耗时长、提交量大”的任务对线程池类型的适配性。
7.4 任务异常后无日志,排查毫无头绪
现象:某个定时任务偶尔失败,但服务日志里完全看不到异常堆栈。
排查过程:翻代码发现用的是 submit,且后续没有调 future.get()。submit 会把异常封装进 Future,不 get 就永远看不到。这个也是前面提过的典型坑。
解决方案:规范写法,提交任务后统一把 future 放到一个容器中,另起一个线程负责 get() 检查结果,发现异常就打告警。或者配置一个全局的 afterExecute 兜底钩子,配合线程池内部的未捕获异常处理器 Thread.setDefaultUncaughtExceptionHandler。
7.5 线程池生命周期未管理,应用重启时任务丢失
现象:应用每次发布重启,手工触发的异步任务大半丢失,业务数据对不上。
排查过程:日志发现重启瞬间有大量任务还没执行,被 Shutdown 中断或直接丢弃。查代码,项目里线程池是静态变量,正常执行是没有问题的。而应用发布是 kill 后重启,没有走优雅停机流程,线程池没机会调用 shutdown 并等待排空。
解决方案:针对异步任务系统接入优雅停机机制,监听停机信号,先设线程池为不接收新任务,再 awaitTermination 等待队列排空,超时后再强杀。另外从架构层面,把重要的异步任务改为“先落库、再派发”的模式,任务状态持久化,服务重启后可以重新扫描未完成任务,这样即使进程被强杀也不丢业务数据。
排查这类问题我最想说的一句话:线程池问题很少是“线程池本身坏了”,更多是“用线程池的方式出了问题”。所以排查时不要死盯着 ThreadPoolExecutor 的源码,顺着调用链往上游找到任务提交方和执行方,通常是更快定位的一条路。
关于线程池,我最后再分享一个小经验:给它配齐“名字、边界、兜底、监控”这四件套。线程名有业务含义,队列有界,拒绝有善后策略,运行有指标观测。做到这四点,线程池这个工具基本就不会在线上给你“惊喜”。后续如果项目里引入了微服务框架、分布式任务调度这些能力,线程池作为底层执行单元,它的稳定性会直接影响上层业务的可靠性,值得花时间打磨清楚。