☰
线程池拒绝策略详解:四种内置策略与生产环境选型实战
2026/10/20 6:37:15 网站建设 项目流程

面试官抛出"线程池的拒绝策略有哪些"这个问题时,表面上是考你背没背过那四个策略的名字,实际上是想通过这个问题判断你对线程池这个组件到底有没有"使用过、踩过坑、思考过"。我在面试别人和被人面的过程中都遇到过这个问题,说句实话,能一口气说出四种策略的人不少,但能把"什么时候触发拒绝策略""生产环境选哪个更合理""自定义策略怎么写"讲清楚的,十个里面也就两三个。

这篇文章我不打算按八股文的套路给你念标准答案,而是从一个真实的使用者角度,把线程池的执行流程、四种内置拒绝策略的源码逻辑、生产场景的选型思路,以及我实际踩过的坑和排查方法都拆开讲一遍。无论你是在准备面试,还是已经在项目里维护线程池,这篇文章都值得认真看完。

1. 先搞明白:什么情况下才会走到"拒绝"这一步

很多候选人上来就背策略名字,却说不清楚拒绝策略在什么条件下被触发,这是个很明显的知识漏洞。拒绝策略不是随便触发的,它有非常明确的前置条件,理解这个条件比背策略名字重要得多。

1.1 线程池的执行流程

一个线程池从提交任务到任务真正被执行,中间要过好几道关卡。ThreadPoolExecutor 的核心处理逻辑可以用下面这个流程概括:

  1. 如果当前线程数小于核心线程数(corePoolSize),创建新线程来执行这个任务。
  2. 如果当前线程数已经大于等于核心线程数,把任务放入工作队列(workQueue)等待执行。
  3. 如果队列已经满了,而且当前线程数小于最大线程数(maximumPoolSize),创建非核心线程来执行任务。
  4. 如果线程数已经达到了 maximumPoolSize,队列也满了,这时候新提交的任务就会走拒绝策略。

注意第三点和第四点之间的差别:非核心线程不是一开始就创建的,而是队列满了之后才"临时扩编"的。这和我们出去吃饭等位是一个道理——餐厅平时只有几个厨师在忙(核心线程),座位(队列)坐满了,客人还在往店里进,店长才会把后备厨师也叫出来炒菜(非核心线程)。等备用的厨师也叫完了,门口还在排队,那这单生意就接不了了,只能想办法处理"接不了单"这件事,这个处理办法就是拒绝策略。

1.2 走一遍 execute() 源码

光看概念还是不够透彻,我从 JDK 源码里把这套逻辑给你标注一遍。ThreadPoolExecutor 的 execute() 方法,核心代码是这样的:

public void execute(Runnable command) { if (command == null) throw new NullPointerException(); 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); // 走到这里,就轮到拒绝策略上场了 }

这个方法最容易被忽略的两个细节,一个是入队之后的 recheck(第二次检查),一个是 addWorker 失败的条件。recheck 是为了避免这种情况:任务刚放进队列,线程池却刚好执行了 shutdown,导致这个任务永远没机会被执行,所以需要把任务从队列里移除然后走拒绝路径。addWorker 失败的原因有两种:要么是当前线程数已经达到 maximumPoolSize,要么是线程池已经处于非运行状态。这两种情况都会触发 reject()。

reject() 方法最终会调用 handler.rejectedExecution(),这个 handler 就是构造线程池时传进去的 RejectedExecutionHandler 对象。所以拒绝策略的本质,就是线程池在"实在接不下活"的时候,对外抛出的一个兜底回调接口。

2. 四种内置拒绝策略逐个拆解

ThreadPoolExecutor 内部定义了四种拒绝策略,全部是 RejectedExecutionHandler 接口的静态内部类实现。每个策略的实现逻辑非常简短,但背后的设计思想和适用场景差异很大。

2.1 AbortPolicy:默认策略,直接抛异常

这是线程池默认的拒绝策略,它的实现非常干脆——抛异常:

public static class AbortPolicy implements RejectedExecutionHandler { public AbortPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException( "Task " + r.toString() + " rejected from " + e.toString()); } }

这个策略的语义是:我接不了这个任务,并且我要让提交方立刻知道自己被拒绝了。它会把异常抛给提交任务的那个线程,如果是主线程提交的,主线程就会捕获到 RejectedExecutionException。

我对这个策略的评价是"安全但不够友好"。安全在什么地方呢?它至少不会让任务悄悄消失,提交方能在第一时间感知到异常并做补救,比如把任务存数据库、记录告警、切换降级方案。不够友好的原因是,如果你的业务系统没有对 RejectedExecutionException 做捕获,那么这个异常可能导致整个请求链路失败。很多线上事故就是这么来的——线程池满了,提交任务的请求直接抛出异常,接口返回 500。

注意:很多人以为默认策略等于"没有策略",这是误解。默认的 AbortPolicy 是会在高并发下直接抛异常的,如果你的调用方没有 try-catch 兜底,一个线程池打满就可能引发接口大面积报错。

2.2 CallerRunsPolicy:谁提交的,谁自己来跑

这个策略的名字很直白:调用者自己运行。它的大致逻辑是——任务被拒绝的时候,不抛弃任务,而是把任务丢回给调用者所在的线程去执行:

public static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { r.run(); // 直接在调用方线程里同步执行 } } }

注意这里调用的是 r.run(),而不是去创建新线程或者把任务交给某个线程池。这意味着任务会在提交任务的线程里同步执行。比如你的 web 请求线程提交了一个耗时的异步任务,在线程池被占满时,这个任务的执行会阻塞在你的请求线程里,直到它跑完请求才能结束。

这个策略有一种"负反馈调节"的意味:线程池越忙,调用方就越要自己分担工作,调用方的处理速度就会降下来,从而反过来减少对线程池的压力。它和消息队列的背压机制有异曲同工之处。

但 CallerRunsPolicy 有一个非常容易踩的坑:如果任务本身特别耗时,或者提交方是一个高频调用方,这个策略会导致调用方的线程被长时间占用,接口响应时间飙升,甚至引发级联超时。我后面会在实战踩坑部分专门讲这个问题。

2.3 DiscardPolicy:静默丢弃,任务人间蒸发

这个策略的实现最简单——空实现,什么都不做:

public static class DiscardPolicy implements RejectedExecutionHandler { public DiscardPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { // 什么也不做,直接丢弃 } }

任务被拒绝后直接丢掉,没有任何异常,没有任何日志。这种策略适合什么场景呢?只适合那些"丢了也无所谓"的任务,比如丢弃一些非关键性的统计数据上报、临时的日志采集之类。凡是涉及订单处理、支付消息、数据同步这类不能丢的任务,用 DiscardPolicy 就是在给自己埋雷。

我在实际工作中基本上不会主动选择这个策略,因为它会让问题变得不可见。线上任务丢了,你可能毫无感知,直到业务方反馈数据对不上才追查,到时候连日志都没有,排查难度成倍上升。

2.4 DiscardOldestPolicy:丢最老的,保最新的

这个策略和 DiscardPolicy 的区别在于,它不是丢弃当前提交的任务,而是先把队列头部的任务丢掉,再重新提交当前任务:

public static class DiscardOldestPolicy implements RejectedExecutionHandler { public DiscardOldestPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { e.getQueue().poll(); // 把队头最老的任务踢掉 e.execute(r); // 再把当前任务提交进去 } } }

这个策略的核心思想是"用旧任务换新任务"。它假设队列里面排队的任务,时间越久价值越低,新任务的价值比旧任务高。典型的适用场景是实时性要求高的业务,比如行情推送、实时消息通知,数据稍微晚一点处理就已经没意义了,新数据永远比旧数据有价值。

这个策略有两个隐藏问题。一个是如果队列是优先级队列(PriorityBlockingQueue),poll() 弹出的并不是最早入队的任务,而是优先级最低的任务,这时的行为就和"丢弃最老"的初衷不一致了。另一个是如果被丢弃的旧任务是一个关键操作(比如用户退款),直接丢掉照样是会出事故的。所以用什么队列、任务重不重要,都得先想清楚。

2.5 一张表看完四个策略

我把四种策略放到一张表里做对比,方便你面试前快速回顾:

策略名称触发后的行为优点风险点典型场景
AbortPolicy(默认)抛出 RejectedExecutionException任务不会丢失,调用方能感知未捕获异常会导致请求失败通用默认,配合调用方兜底
CallerRunsPolicy在调用方线程同步执行提供背压,任务不丢调用方线程被阻塞,耗时任务会拖垮接口对耗时敏感度低、能容忍阻塞的场景
DiscardPolicy静默丢弃新任务实现最简单任务丢失无感知,难排查可丢弃的非关键任务
DiscardOldestPolicy丢弃队头旧任务,重新入队新任务新任务优先,适合实时场景旧任务可能被误丢,优先级队列下语义变化实时性要求高的数据推送

3. 生产环境里到底怎么选

面试的时候,你说出四个策略的名字只是及格线,能说出"线上我用的是哪个、为什么"才是加分项。这一节聊聊我的选型思路和自定义实践经验。

3.1 不同业务场景的选型逻辑

我自己在项目里选拒绝策略,习惯先回答三个问题:

第一,任务能不能丢?订单、支付、消息这类核心业务,一帧都不能丢。这类任务我会避免使用所有"丢弃"类策略,优先考虑 CallerRunsPolicy 或者自定义的持久化兜底策略。

第二,调用方能接受阻塞吗?如果提交方是网关、接口层这类对延迟敏感的地方,CallerRunsPolicy 的同步执行会让请求线程阻塞在耗时任务上,这时候就得慎重。相比之下,可能选 AbortPolicy 配合异常兜底,或者直接用自定义策略把任务转移到别的通道处理,会更合适。

第三,系统有没有完善的监控告警?有监控的情况下,AbortPolicy 是相对安全的,因为异常和指标会触发告警,你能在第一时间介入。没有监控的情况下,DiscardPolicy 这种静默丢弃的策略非常危险,因为它把问题彻底隐藏了。

我见过一个比较稳妥的通用方案:核心业务线程池统一使用 CallerRunsPolicy 作为兜底,同时配合线程池指标监控。任务多到线程池都扛不住的时候,提交方线程自己分担一部分任务,系统不会因为线程池打满而崩溃,只是响应速度会变慢。这种"降速保命"的思路,在很多高并发服务里是可行的。前提是任务的执行时长必须可控,如果任务动不动要跑几秒钟,这个方案就不合适了。

3.2 自定义拒绝策略的正确姿势

四种内置策略解决的是通用问题,生产环境里很多场景需要自定义实现。RejectedExecutionHandler 是一个只有一个方法的接口,实现起来成本很低:

public interface RejectedExecutionHandler { void rejectedExecution(Runnable r, ThreadPoolExecutor executor); }

下面我写一个实际用过的自定义策略,思路是把被拒绝的任务写入一个可靠的消息队列做持久化,由另一个消费者服务慢慢消化,相当于给线程池加上了一个"熔断缓冲池":

public class PersistentRejectHandler implements RejectedExecutionHandler { private static final Logger log = LoggerFactory.getLogger(PersistentRejectHandler.class); // 被拒绝任务的缓冲通道,比如 MQ 的 producer,或者本地文件的写入器 private final BlockingQueue<Runnable> bufferQueue; public PersistentRejectHandler(BlockingQueue<Runnable> bufferQueue) { this.bufferQueue = bufferQueue; } @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 1. 记录现场信息,方便事后排查 log.warn("task rejected, queueSize={}, poolSize={}, activeCount={}, corePoolSize={}, maxPoolSize={}", executor.getQueue().size(), executor.getPoolSize(), executor.getActiveCount(), executor.getCorePoolSize(), executor.getMaximumPoolSize()); // 2. 尝试进入缓冲队列,等待低峰期再处理 boolean offered = bufferQueue.offer(r); if (!offered) { // 3. 缓冲队列也满了,说明系统真的到了极限 // 这时候只能做最后兜底:记录日志、发送告警、丢弃 log.error("buffer queue is full too, discard task: {}", r); sendAlarm(executor); } } private void sendAlarm(ThreadPoolExecutor executor) { // 发送短信/钉钉/邮件告警 } }

这个自定义策略包含了一个比较完整的设计思路:先记日志保留现场,再尝试缓冲,缓冲也满了才走最后的告警兜底。每一层都有明确的处理动作,不会像 DiscardPolicy 那样无声无息。

写自定义拒绝策略时有几个细节要注意。一个是拒绝策略里执行的逻辑越小越好,不要在 rejectedExecution 方法里做重活,比如不要在拒绝现场去写大文件、调远程接口,这些操作本身会占用提交方线程的时间。另一个是线程安全,rejectedExecution 可能被多个线程并发调用,如果你的实现里有共享状态,必须加锁或者保证线程安全。还有一个容易忽略的点,就是 rejectedExecution 里不要调用 executor.shutdown() 之类改变线程池状态的方法,这会导致不可预料的连锁反应。

3.3 动态调整拒绝策略

ThreadPoolExecutor 提供了 setRejectedExecutionHandler 方法,允许在运行期切换拒绝策略。很多人不知道这个方法的存在,其实它在灰度发布和应急处理时非常有用。

举个例子,你的服务默认用的 AbortPolicy,某天大促流量上来,线程池被打满,接口开始疯狂抛 RejectedExecutionException。运维可以通过配置中心动态下发一个指令,把该线程池的拒绝策略临时切换成 CallerRunsPolicy,系统马上就能从"报错"变成"降速",等流量回落后再切回来。

这种动态调整需要配合配置中心来做,核心代码如下:

// 通过配置中心监听配置变更 configCenter.addListener("threadpool.rejectPolicy", new ConfigChangeListener() { @Override public void onChange(String policyName) { switch (policyName) { case "abort": threadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); break; case "callerRuns": threadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); break; default: // 默认切回 CallerRunsPolicy threadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); } } });

动态切换策略是一把双刃剑,它能救急,也容易掩盖问题。如果系统长期处在"靠临时切换策略续命"的状态,说明线程池的参数配置本身就不合理,该做的是优化 corePoolSize、maximumPoolSize 和队列长度,而不是反复折腾拒绝策略。

4. 面试官藏在后面的追问

拒绝策略这个知识点,面试官很少只问一句就完事。我总结了一些高频追问,把这些想清楚,比背策略名字有用得多。

4.1 拒绝策略和队列有什么关系

这题考的是你懂不懂线程池的完整参数体系。拒绝策略不是独立存在的,它的触发前提是"队列满了 + 线程数达到上限"。所以队列的选择直接决定了任务在拒绝之前能缓冲多少。

  • ArrayBlockingQueue:有界队列,必须指定容量,容量大小直接影响拒绝策略触发的早晚。
  • LinkedBlockingQueue:可以设置容量,Executors.newFixedThreadPool 默认用的是容量很大的无界队列,这种情况下线程池几乎不会触发拒绝策略,因为任务全堆在队列里了。
  • SynchronousQueue:不存储任务的队列,提交的任务必须立刻交给线程执行。Executors.newCachedThreadPool 用的就是它。用这个队列时,只要线程数到了 maximumPoolSize,新任务立刻触发拒绝策略,连缓冲的机会都没有。

所以你在面试的时候可以说:拒绝策略是线程池参数体系的"最后一环",它的触发时机和队列容量、最大线程数强相关。无界队列情况下拒绝策略基本形同虚设,反而容易因为任务无限堆积导致内存问题。

4.2 任务被拒绝之后就真的没了吗

这个问题是我比较喜欢追问的,因为它能考察候选人对线程池完整生命周期的理解。答案是:不一定。

任务提交到线程池之后,状态可以分为两类:一类是正在被线程执行的,另一类是还在队列里等待的。拒绝策略处理的是"根本进不了线程池"的那个新任务。但线程池如果调用了 shutdown(),队列里排队等待的任务会被继续执行完,只有那些还没来得及入队的任务才会被拒绝。如果是 shutdownNow(),它会返回一个 List ,里面是"还没来得及执行就被打断"的任务,这其实是另一种维度的"拒绝"——任务从队列里被清出来了,需要调用方自己决定怎么处理。

再往深一点说,DiscardOldestPolicy 从队列尾部 poll() 出来的那个旧任务,也是"被拒绝"的,它在被拒绝的那一刻就已经不会被执行了。所以一个优秀的回答应该是:拒绝策略不只影响新提交的任务,某些实现(比如 DiscardOldestPolicy)也会影响已经在队列里的任务,设计时要考虑被丢弃的旧任务是否会造成不可接受的损失。

4.3 还需要准备哪些扩展问题

除了上面两个,面试官可能还会顺着往下问这些:

  • 线程池的 execute() 和 submit() 有什么区别?拒绝策略对这两种提交方式产生的异常表现不同。submit() 返回的 Future 会把异常包装到 ExecutionException 里,你需要在使用 Future.get() 的时候才能感知到。
  • 被拒绝的任务能否拿到线程池的实时状态?当然可以,通过 executor.getQueue().size()、getActiveCount()、getPoolSize() 这些方法,在自定义拒绝策略里可以拿到足够丰富的上下文信息。
  • 为什么阿里巴巴开发规范不建议使用 Executors 的静态方法创建线程池?因为固定线程池和单线程池默认用了无界队列,缓存线程池用了 SynchronousQueue 配合无界 maximumPoolSize,这些组合各有各的隐患,更重要的是很多开发者用了 Executors 却从来没传过拒绝策略参数,对拒绝行为完全没有感知。

5. 实战踩坑与排查记录

理论知识讲再多,不如把真实踩过的坑说出来。这一节分享我实际遇到过的几个问题,以及对应的排查思路。

5.1 坑一:CallerRunsPolicy 拖垮了整个调用链路

有一次我在一个内部服务里给异步任务线程池配置了 CallerRunsPolicy,当时想的是"任务不丢就好"。结果上线后没过多久,发现接口的 TP99 从原来的 80ms 涨到了 3 秒。排查下来才发现,某次业务高峰线程池被打满后,耗时的报表任务开始在 web 请求线程里同步执行,一个请求要等报表跑完才返回,接口直接被拖死。

这个坑让我总结出一个原则:CallerRunsPolicy 必须配合"任务时长可控"这个前提。如果任务里既有快任务又有慢任务,慢任务一旦在调用方线程里执行,后果就是灾难级的。现在我会在任务提交之前根据任务的类型做分流,慢任务走专门的线程池或者消息队列,绝不混在业务线程池里。

5.2 坑二:DiscardPolicy 静默丢弃导致数据对不上

另一个项目里,有人为了"性能好",给日志上报线程池配置了 DiscardPolicy。当时看起来没问题,日志上报本身就不追求百分百完整。但后来业务方排查一笔订单的数据缺失,顺着日志查才发现,日志在写入上报线程池时被静默丢掉了,现场什么日志都没有留下。

从那以后我对 DiscardPolicy 的态度就是:除非这个任务真的丢了也无所谓,否则一律不用。如果只是想"轻量化处理",也应该用 DiscardPolicy 的变体——在丢弃前打印一条 warn 日志。JDK 的 DiscardPolicy 是空实现,你完全可以写一个带日志的版本:

public class LoggingDiscardPolicy implements RejectedExecutionHandler { private static final Logger log = LoggerFactory.getLogger(LoggingDiscardPolicy.class); @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { log.warn("task discarded, task={}, queueSize={}, activeCount={}", r, e.getQueue().size(), e.getActiveCount()); } }

一个小小的改动,能让任务被丢弃时至少留下痕迹,排查数据问题的时候不至于两眼一抹黑。

5.3 坑三:拒绝策略里的异常被吞掉了

还有一个细节值得单独说:如果拒绝策略本身抛出了异常,这个异常会被直接抛回给调用 execute() 的线程。这也是 AbortPolicy 的机制。但如果你在自定义拒绝策略里做了类似"写日志失败然后抛出异常"的操作,这个异常同样会打断调用方的正常流程。

我见过有人为了防止线程池打满导致业务失败,在自定义拒绝策略里加了大段的 try-catch,把异常全吞了。结果任务丢了、日志也没了,因为日志的写入逻辑也在 try-catch 里被吞掉了。我的建议是,拒绝策略里的代码要遵循最小化原则:记录关键信息 → 做必要的兜底 → 不要在拒绝策略里抛新异常,也不要在拒绝策略里放大段的业务逻辑。

5.4 监控与预警:让拒绝策略可见化

最后一个建议,也是我认为最重要的一个:拒绝策略必须配合监控使用。线程池是一个内部组件,除非你把它的运行状态暴露出来,否则它打满之前你是不会有感知的。

我在生产环境里会给每个核心线程池加上这几项监控指标:

  • 队列深度(queue.size()):队列剩余容量接近耗尽时,就要提前告警。
  • 活跃线程数(getActiveCount())和池中线程数(getPoolSize()):判断线程池是否处于高负载。
  • 拒绝任务计数:不管用的什么策略,只要发生过拒绝,就要让这个事件可见并触发告警。

这些指标可以通过 Micrometer 暴露给 Prometheus,再配合 Grafana 做可视化。如果公司没有这类监控体系,退而求其次,也要在代码里埋上日志。线程池的参数不是配置一次就万事大吉的,它的容量要跟随业务流量不断调整,而这个调整的依据就是这些监控数据。

我在实际使用中的体会是,拒绝策略这个问题,面试只是一个小切口,背后考察的是你对线程池整体运行机制的理解深度。如果你能把 execute() 的完整流程讲清楚、能说出每种策略在生产场景下的选型理由、能写一个带日志的兜底版本,面试官通常就不会再在这个问题上追着不放了。而且这些东西平时真的能救命——线上线程池打满的时候,一套合理的拒绝策略配上监控告警,往往就是系统不崩的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询