面试官问“线程池拒绝策略怎么选,才不会丢任务”时,他真正想听的其实不只是背出四种策略的名字,而是看你在真实业务里有没有被“丢任务”这件事扎过心。线程池的拒绝策略,本质上是一个“容器满了以后,新来的活儿怎么处理”的问题,处理不好就是线上偶发数据缺失、订单状态不更新、日志悄悄消失。这篇文章我把线程池从提交任务到触发拒绝的完整链路拆开讲清楚,四种内置策略的坑、自定义拒绝策略的写法、还有几种实战里验证过的兜底方案,一次说透。
先说一个很多人在面试时容易答偏的点:拒绝策略不是线程池满了才触发的,而是“队列也满了”才会触发。线程池处理任务的优先级是:核心线程数满了,任务进队列;队列满了,任务开非核心线程;如果非核心线程也满了,才会走到拒绝策略。这个顺序搞错,后面全乱。
1. 先搞清楚线程池的三个容量参数,拒绝策略才有讨论的前提
聊拒绝策略之前,必须先把ThreadPoolExecutor的构造参数嚼碎。很多同学背得滚瓜烂熟,但真到了面试官追问“你的队列长度怎么定的”就卡壳。
1.1 核心线程数、最大线程数、阻塞队列三者的配合逻辑
ThreadPoolExecutor最常用的构造方法有七个参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。这里最核心的是前三个加队列。
任务提交后的流转路径是这样的:
- 当前线程数小于corePoolSize时,新任务会直接创建核心线程执行,不会进队列。
- 当前线程数大于等于corePoolSize时,新任务会尝试放进阻塞队列,队列放得下就排队等着。
- 队列满了,才会创建非核心线程(直到达到maximumPoolSize)。
- 线程数已经到maximumPoolSize,队列也满了,这时候再提交任务,才会触发拒绝策略。
这个路径里有个经常被误解的关键点:队列长度直接决定了“什么时候开始扩容线程”。如果你的核心线程是5,最大线程是10,队列容量是100,那么实际上前100个超出核心线程的任务都会被塞进队列,非核心线程可能压根没机会创建。只有当队列也装不下了,第101个任务进来才会创建新线程。这种配置适合任务量大但每个任务执行速度快的场景,把线程数压得比较低,靠队列缓冲。
反过来,如果你希望高并发时快速扩容线程而不是排队等,那就得把队列容量设小,比如用SynchronousQueue,它不存任务,来了就得有线程接,否则直接走拒绝策略。
1.2 四种内置拒绝策略是哪些,分别是什么表现
内置的拒绝策略都实现了RejectedExecutionHandler接口,一共四个:
- AbortPolicy:默认策略。队列满了直接抛RejectedExecutionException,任务当场丢失,调用方会收到异常。
- DiscardPolicy:静默丢弃。不抛异常,新任务悄悄没了,调用方完全感知不到。
- DiscardOldestPolicy:丢最老的。把队列头部的等待任务扔掉,把新任务放进去。
- CallerRunsPolicy:谁提交谁执行。任务不会丢,但会在提交任务的线程里直接跑,线程池不接管。
面试时报菜名很简单,但真正的分水岭在于:你知道每个策略在什么场景下用,以及为什么默认策略会丢任务。
1.3 默认的AbortPolicy为什么最容易埋雷
默认的AbortPolicy最直接的问题就是抛异常。如果调用方没有捕获RejectedExecutionException,任务会丢失,线程还会被打断。就算捕获了,你也只是知道“这次提交失败了”,但任务本身没有任何补偿机制。
说个我踩过的真实场景:某系统的报表导出功能,用户点一次导出就向线程池提交一个异步任务。某天高峰期同时有大量用户操作,线程池的队列被塞满,紧接着新任务触发了AbortPolicy,异常被打进日志,用户页面上只看到“导出失败”。这个失败的导出任务没有任何重试,用户只能再点一次。如果导出的是前一交易日的数据,再点一次可能数据已经变了,结果跟用户预期就对不上了。
所以“不丢任务”这件事,从来不是选一个策略就完事,而是要先想清楚:任务丢了以后,业务上能不能接受,调用方能不能感知,要不要补偿。
2. 内置策略的坑逐个说清楚,选型前先看这一节
2.1 AbortPolicy:异常抛给谁,由谁来兜底
并发包里默认就是它。源码逻辑非常简单:直接调用handler.rejectedExecution,里面throw new RejectedExecutionException。表面看是“让调用方知道任务被拒”,但坑在于很多调用方根本不会try-catch异步提交的代码。线程池提交任务有两种方式,execute和submit,它们对异常的处理不一样。
- execute是异步的,任务执行过程中的异常会被线程的UncaughtExceptionHandler处理,调用方拿不到。如果execute时触发了AbortPolicy,异常倒是直接抛在调用方线程里,能被catch到。
- submit把任务包装成FutureTask,异常会被封装到Future里,调用方如果不去调future.get(),吞掉异常非常容易。
所以用了AbortPolicy,必须保证提交任务的地方有明确的异常处理逻辑。如果你在for循环里批量提交任务,其中一个任务触发了拒绝策略抛异常,循环直接中断,后面还没提交的任务也不提交了。这种情况不是丢一个任务,是丢一批任务。
2.2 DiscardPolicy:静默丢失,排查线上的噩梦
DiscardPolicy的实现比AbortPolicy还简单,直接return,什么都不干。任务被丢掉时连个日志都没有。
这种策略看着最省事,但实际上是最危险的。因为“静默丢弃”意味着问题不会暴露在上线初期,而是会含蓄地在某个流量波峰出现。我接手过一个老项目,线程池用了DiscardPolicy,线上间歇性出现数据对不上,查了好几天才发现是高峰时段订单回调任务被静默丢弃了。后来做了两件事:一是换掉DiscardPolicy,二是给线程池加上监控指标,把队列积压数和拒绝次数暴露出来,这才把问题从“玄学”变成“可观测”。
2.3 DiscardOldestPolicy:丢老任务,但老任务可能是最不该丢的
DiscardOldestPolicy的设计思路是“腾位置给新任务”,把队列头部的任务poll出来扔掉,然后重新尝试把新任务塞进队列。这个策略在任务有“时效性”的场景下有点道理,比如你维护的是最新价格推送,老价格没意义,新价格更重要。
但绝大多数业务场景里,队列头部的任务是等待时间最长的,往往是用户最早发起的请求。如果把最早的任务丢掉,用户感知就是“我明明最先点的,怎么一直没反应”。另一个更坑的地方是,DiscardOldestPolicy丢弃任务时不做任何告警,你得自己重写实现加日志。
2.4 CallerRunsPolicy:唯一不丢任务的内置策略,但小心拖垮调用方
CallerRunsPolicy的逻辑是:线程池满了,不拒绝,而是让提交任务的那个线程自己来跑这个任务。比如你的主线程往线程池提交了一个任务,线程池满了,那么这个任务会回到主线程里同步执行。
这个策略最讨喜的地方就是一定不丢任务,就算线程池满了,任务也会在调用方线程里继续执行,只是变成了同步阻塞。但这里有个隐藏风险:如果是超高并发场景,线程池已经打满了,说明系统负载已经很高了,此时再让调用方线程同步执行一批任务,调用方线程就会被拖住,可能引发连锁的接口超时和线程堆积。
我用过一个实际案例来说明它有多微妙:某网关服务用CallerRunsPolicy做异步上报流量的兜底。低峰期没问题,高峰期线程池满了之后,网关线程开始同步执行流量上报逻辑,上报逻辑本身有网络IO,一慢就把网关线程全部卡住,最终导致整个网关无法响应新请求。后来我把上报任务的拒绝策略改成“先存本地文件,再异步补推”,这才把网关线程解放出来。
3. 真正不丢任务的方案:自定义拒绝策略与补偿机制
内置四种策略,严格来说只有CallerRunsPolicy能做到“不丢”,但它有拖垮调用方的风险。那么在实际项目里,怎么设计一个既不丢任务、又不让系统雪崩的方案?核心思路就一条:线程池处理不了的,交给线程池之外的机制去处理。
3.1 自定义RejectedExecutionHandler的推荐写法
自定义拒绝策略的大致方向是:在拒绝处理器里,把任务暂存到一个可靠的地方,然后启动一个新的线程或者在后台定时任务去慢慢消费。
我推荐的一种写法是把“拒绝的任务”转交到一个自定义的“备胎线程池”,备胎池的线程数可以很小,队列可以很大,或者干脆只用一个后台单线程配合一个无界队列。这样主线程池打满时,备胎池还能顶一会儿。
public class BackupRejectedHandler implements RejectedExecutionHandler { private final ExecutorService backupExecutor; public BackupRejectedHandler(ExecutorService backupExecutor) { this.backupExecutor = backupExecutor; } @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 记录日志,方便追踪拒绝发生的时间和频率 System.out.println("线程池已满,任务转入备胎执行器,当前拒绝时间:" + LocalDateTime.now()); // 转交备胎线程池,保证任务不丢 backupExecutor.execute(r); } }这种方案的妙处在于主线程池被打满时,备胎池成了缓冲层。任务仍然在另一个线程池里排队,不会立即回落到调用方。备胎池的线程数不用多,1到2个就行,但它的核心线程数如果是0,需要特别注意keepAliveTime的设置,不然备胎线程会被回收,导致备胎池也慢慢失去处理能力。
但备胎池也只是“延迟了撞墙的时间”,如果备胎池也被打满,问题依然会回来。所以更可靠的方案是把任务交给一个真正的存储层,比如数据库、本地磁盘、或者消息队列。
3.2 用消息队列承接溢出任务,异步补偿终态一致
实际电商、支付、订单类系统里,最稳的兜底方案不是线程池内部解决,而是把溢出的任务序列化成消息发到MQ。MQ本身有持久化能力,消费端可以慢慢拉取,既不丢数据,也不会打爆线程池。
流程大致是:
- 业务线程向线程池提交任务。
- 线程池拒绝时,自定义handler把任务转成消息体,发送到MQ。
- MQ消费者从队列拉取消息,重新提交给线程池执行,或者直接执行。
- 执行成功的消息确认消费,失败的进入重试。
这个方案把“线程池容量不足”的问题,从线程池内部转移到了外部存储。MQ的积压能力和持久化特性天然适合这种场景。只要MQ本身没有故障,任务就不会丢。
不过这个方案也有成本:任务得能序列化,执行逻辑得保证幂等。因为消息可能被重复消费,重复执行不能产生副作用。比如一个更新订单状态的任务,如果重复执行两次,第二次必须检测到状态已变更就不再处理,否则会出现状态回退或者重复扣减。
3.3 数据库兜底:把任务落表,定时任务扫描补偿
如果项目里没有引入MQ,用数据库表做兜底也完全可行。核心思路是:拒绝时把任务关键信息写入一张task表,状态标记为pending,后台定时任务每个几秒扫一次,把pending的任务重新投递到线程池。
public class DbRejectedHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof TaskWrapper) { TaskWrapper task = (TaskWrapper) r; // 将任务信息保存到数据库 TaskRecord record = new TaskRecord(); record.setBizId(task.getBizId()); record.setTaskType(task.getTaskType()); record.setStatus("PENDING"); taskMapper.insert(record); } } }扫描补偿的定时任务要注意几点:
- 数据量大时不要一次性扫全表,按分页和状态索引来扫。
- 补偿逻辑需要幂等,重试前先更新状态为processing,成功后再置为done。
- 定时任务的间隔和单批处理数量要根据业务量调整,避免补偿任务本身又成为新的瓶颈。
我见过一个团队用这种方案处理消息推送任务,线上跑了半年,落库补偿的任务占全部任务的不到万分之一,数据一条没丢。这个方案的可控性比MQ更高,因为数据看得见摸得着,出问题直接查数据库就行。
3.4 进阶:结合Spring的ApplicationContext事件机制做异步兜底
还有一个小众但很优雅的方案:自定义拒绝策略里发布一个Spring事件,监听器里异步存储任务,再通过定时扫描恢复。这种方案的优势在于业务代码里不需要显式依赖MQ或者数据库,线程池只需要把任务发布成事件,后续怎么处理由监听器决定。
public class EventRejectedHandler implements RejectedExecutionHandler { private final ApplicationContext applicationContext; public EventRejectedHandler(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { applicationContext.publishEvent(new TaskRejectedEvent(r)); } }这个方案把拒绝处理和业务处理解耦得比较干净。监听器里可以做落库、发MQ、记日志,甚至组合多种策略。但它的缺点是调试链路比较长,事件是异步的,一旦监听器本身出问题,排查起来比直接看代码要费劲。适合在架构上已经大量使用事件驱动的团队采用。
4. 场景选型对照:核心业务、非核心业务、瞬时峰值怎么选
不管面试还是实战,最终都要落到“你这个业务场景,到底选哪个”。我把常见场景整理成一个对照表,可以直接拿去参考。
| 业务类型 | 典型场景 | 推荐策略 | 理由 |
|---|---|---|---|
| 核心交易链路 | 订单状态变更、支付回调 | CallerRunsPolicy + 重试 | 任务绝对不能丢,同步执行最多慢一点 |
| 最终一致性任务 | 积分同步、消息推送 | 自定义策略 + MQ落盘 | 允许异步补偿,不能接受静默丢 |
| 非核心辅助任务 | 日志清理、统计报表 | DiscardOldestPolicy | 旧任务价值低,新任务优先 |
| 瞬时分页/导出任务 | 批量导出、群发通知 | 自定义策略 + 数据库兜底 | 高峰溢出需要补偿,落库可控 |
| 实时性要求高 | 实时行情推送 | 自定义策略 + 丢弃最旧并告警 | 新数据优先,但要能观测到丢弃量 |
一个核心原则:能容忍延迟但不能丢失的任务,优先考虑CallerRunsPolicy或外部存储兜底;能容忍丢失但不能阻塞的任务,才考虑Discard系列。很多业务把顺序搞反了,导致核心数据丢失或者接口被拖垮。
4.1 核心交易链路:为什么不建议直接用CallerRunsPolicy
前面说了CallerRunsPolicy不丢任务,但核心交易链路的接口本身往往有超时时间。用户下单的请求如果在线程池饱和时被拖去同步执行回调处理,假设回调处理耗时2秒,接口超时是1秒,用户侧直接超时,然后触发前端重试,结果造成重复下单。
所以核心交易链路我一般建议“拒绝策略只兜底,不要让调用方线程被拖死”。做法是:主线程池用AbortPolicy,但外层包裹一个try-catch,catch到RejectedExecutionException后,把任务转交给MQ或者一个隔离的补偿线程池。这样调用方线程能快速返回,任务也不会丢。
try { threadPool.execute(task); } catch (RejectedExecutionException e) { mqProducer.send(task.toMessage()); }这个写法的好处是简单的场景不需要引入自定义handler,代码里肉眼可见兜底逻辑。缺点是每个提交点都要写try-catch,代码会显得啰嗦。如果提交点很多,推荐还是统一封装在自定义handler里。
4.2 非核心业务:如何优雅地丢任务还留后路
日志、统计、清理这类任务,业务方确实能接受丢一部分。但“丢”也要丢得明明白白。直接上DiscardPolicy会让运维完全无感知,风险太高。我推荐用自定义策略做一个“带监控的丢弃”。
public class MonitorDiscardHandler implements RejectedExecutionHandler { private final AtomicLong discardCount = new AtomicLong(0); @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { long count = discardCount.incrementAndGet(); // 每隔100次丢弃打印一次日志,避免刷爆日志 if (count % 100 == 1) { System.out.println("当前累计丢弃任务数:" + count); } } }这样任务照样丢,但丢弃次数是可见的。上线后如果发现discardCount增长异常,可以及时调整线程池参数,而不是等到业务反馈“数据怎么又不对了”才去排查。
4.3 瞬时峰值场景:用队列长度和拒绝次数做自适应调节
很多系统的流量是突发性的,双11大促、秒杀、活动开奖,平时线程池的利用率可能只有5%,一到峰值直接打满。这种场景下,靠固定参数配置很难兼顾“平时不浪费线程”和“峰值不丢任务”。
可以做一个简单的动态调整:在线程池外面包一层动态配置中心,通过监控队列积压数量和拒绝次数,自动调整corePoolSize和maximumPoolSize。比如队列积压数连续30秒超过阈值的80%,就把maximumPoolSize往上调一档;如果拒绝次数出现,就把队列容量调大或者启用兜底策略。
动态调整方案面试时提出来非常加分,说明你有全局的系统设计思维,而不只是停留在“选哪个策略”的层面。不过也要注意,动态调整线程数不能太频繁,每次调整线程池都会重新创建线程,有开销,建议用固定步长和冷却时间。
5. 实操中容易被坑的细节:execute与submit、队列选择、核心线程超时
关于拒绝策略,还有一个高频考点是execute和submit的区别。很多人在面试时把这部分答漏了,导致整个回答的深度差一个档次。
5.1 execute和submit,被拒绝时的表现完全不一样
execute是Executor接口的方法,参数是Runnable,没有返回值。如果触发了AbortPolicy,RejectedExecutionException会直接抛在调用方的线程里,调用方能明确感知到。
submit是ExecutorService接口的方法,返回Future。它把Runnable包装成FutureTask再提交。触发AbortPolicy时异常同样是抛在调用方线程里,但Future在之后执行过程中如果抛异常,被封装在Future里,你调用get()的时候才抛ExecutionException。如果不调get(),异常就吞掉了。
所以用submit提交任务时,你抓RejectedExecutionException可能抓不到,因为异常可能在Future.get()时才冒出来。这种差异在面试中经常被追问,也是线上问题排查最容易踩的一个坑。
我见过一个开发,线程池配置了AbortPolicy,用submit批量提交了100个任务,循环外层没有try-catch,代码写着写着就把这个坑踩了。后来他改成先检查线程池状态再提交,加上拒绝策略兜底,才算稳定。
5.2 队列选型直接决定拒绝策略触发的频率
阻塞队列的选择影响线程池的扩容节奏和拒绝触发点。
- ArrayBlockingQueue:有界队列,容量固定,适合需要控制排队数量的场景。
- LinkedBlockingQueue:如果不设容量,就是无界队列,任务永远不会触发拒绝,只会越堆越多,最终OOM。很多OOM事故都是用了无界队列,线程池永远不会拒绝,任务全积压在内存里。
- SynchronousQueue:不存任务的队列,来一个任务就必须有一个线程去处理,没有空闲线程就会立即触发拒绝。实际上这个队列配合corePoolSize和maxPoolSize,可以实现“线程数从小快速扩展到最大”的效果。
- PriorityBlockingQueue:支持优先级的无界队列,不会拒接,适合有任务优先级区分的场景,但同样有OOM风险。
队列选型很重要,但大家经常忽略一个关联因素:队列满了之后,线程数才会往上扩,所以队列越长,线程数越不容易触发扩容,系统整体更偏向“排队处理”;队列越短,线程数越容易打满,系统更偏向“并发处理”。这个偏好应该跟业务对延迟的容忍程度保持一致。
5.3 核心线程会不会超时回收
ThreadPoolExecutor的核心线程默认不会因为空闲而被回收,除非设置了allowCoreThreadTimeOut(true)并且设置了keepAliveTime。这个参数很多人不知道,但它对拒绝策略的影响是间接的:如果核心线程可以超时回收,长时间低峰后线程数可能降到0,下一次任务提交时需要重新创建线程,会有冷启动开销,导致首个任务延迟偏高。
如果你在低峰期提交一个任务,恰好线程池里的线程都已经被回收了,那么这个任务要等待新线程创建完成后才能执行。如果线程创建时间较长,用户感知就是“怎么突然慢了”。这在面试里可以作为加分细节来说。
6. 常见问题速查表:拒绝策略实战排查清单
最后把我在实战中经常遇到的一些问题整理成速查表,方便大家排查时直接对照。
问题一:任务被拒了,但日志里没有任何异常
大概率用了DiscardPolicy,或者用了submit且没有获取Future的异常。先看线程池的handler配置,再检查提交代码。
问题二:明明配置了最大线程数,但线程数一直涨不上去
检查阻塞队列。如果用的是无界队列,任务永远不会触发扩容,线程数会维持在corePoolSize。
问题三:CallerRunsPolicy生效后,接口延迟飙升
调用方线程被同步执行的任务拖住了。建议把CallerRunsPolicy换成自定义策略,或者把兜底逻辑移到独立线程池/MQ。
问题四:拒绝策略能打印日志,但任务最后还是丢了
说明handler里只是记了日志,没有做持久化或转交。自定义handler里必须把任务交给可靠存储,否则代码里写再多的日志也救不了数据。
问题五:频繁触发拒绝策略,系统负载却不高
可能是线程池参数配置不合理,corePoolSize太小而队列太小。动态调整参数前,先看监控里的线程利用率和任务平均执行时间,把参数调到匹配实际负载。
问题六:重启之后拒绝期间的任务全部消失
说明兜底方案只存在内存里。如果任务很重要,必须写入磁盘或MQ,重启后可以从外部存储恢复。
| 问题现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 无日志无异常丢任务 | DiscardPolicy或submit吞异常 | 查handler配置,查Future |
| 线程数不扩容 | 无界队列,未触发扩容 | 检查队列容量 |
| 接口延迟飙升 | CallerRunsPolicy拖住调用线程 | 换独立兜底线程池 |
| 任务丢失且无补偿 | handler只打日志未存储 | 加DB/MQ持久化 |
| 拒绝频繁但负载低 | 参数不合理 | 动态调整线程池参数 |
| 重启丢失 | 兜底仅内存 | 落盘或MQ |
7. 最后几个实战调试的建议
我这里补充几个排查与验证线程池行为时的调试技巧,都是亲自试过有用的。
一个是通过设置JVM参数-Djava.util.concurrent.ForkJoinPool.common.parallelism的方式去观察线程池行为是行不通的,线程池的调试主要靠加日志。我给线程池写了一个监控包装类,定期打印当前线程数、活跃线程数、队列积压数、已完成任务数和拒绝次数。这些指标组合起来看,比单独看任何一个都有价值。
第二个技巧是测试拒绝策略是否生效时,不要每次都跑到真实业务高峰期。写一个单元测试,构造一个核心线程数1、最大线程数2、队列容量1的线程池,用CountDownLatch卡住正在执行的任务,然后连续提交5个任务,观察哪些任务被执行、哪些被拒绝、拒绝处理器怎么响应。这个测试方法在面试前的准备阶段也很实用,能帮你把抽象的策略行为变成具体的代码直觉。
第三个建议是生产环境的线程池参数不要拍脑袋。先拿到历史监控数据里“任务到达速率”和“单任务平均执行时间”,再用一些小公式估算。任务到达速率为每秒200个,处理能力为每秒100个,线程池容量配置得再大也只是把问题往后推,终归会被打满。与其指望拒绝策略,不如先优化任务本身的执行时间,或者做任务合并,减少线程池的负担。
线程池拒绝策略的选型,本质上是对“任务重要程度”和“系统容错能力”的匹配。没有任何一个策略是万能的,但只要有清晰的兜底机制和数据可观测性,丢任务这件事就能从“事故”变成“可处理的事件”。