线程池队列与拒绝策略:有界队列、背压、降级和自定义策略(045)

Java并发编程约 16 分钟阅读8,585 字符
知识库文档 46 / 251

一、为什么"队列选型"和"拒绝策略"经常被低估

前几篇把线程池的参数估算讲得很细:corePoolSize 该设多少、maximumPoolSize 该设多少、甚至怎么用阻塞系数公式和 Little 定律去反推这些数字。但几乎所有关于线程池的讨论,最后都会绕回同一个问题:当估算不准、真实流量超出了所有准备,线程池会怎么处理这部分"多出来"的任务?

这个问题的答案,几乎完全由两个经常被当作"随便填"的参数决定:工作队列的选型和拒绝策略。核心线程数和最大线程数决定的是"系统正常工作时的处理能力",而队列和拒绝策略决定的是"系统被打满之后的行为"——是优雅地把压力传导回上游,还是任务堆积到内存打满,还是简单粗暴地抛异常,这几种结局的用户体验和系统稳定性天差地别,但很多人在配置线程池的时候,把大部分注意力都放在了前两个参数上,队列和拒绝策略往往是复制一段别人的代码就直接用了。这篇要讲清楚的,正是"系统扛不住的时候,到底该怎么办"这个问题背后的完整设计空间。

二、有界队列:给系统一个明确的"缓冲上限"

ThreadPoolExecutor 的工作队列本质上是一个"任务已经提交、但还没有线程去处理"的缓冲区。选型上最关键的一个分野,是有界(bounded)还是无界(unbounded):

  • LinkedBlockingQueue 默认构造是无界的(容量等于 Integer.MAX_VALUE),也可以传入一个容量参数变成有界;
  • ArrayBlockingQueue 天生就是有界的,创建时必须显式指定容量;
  • SynchronousQueue 严格来说不是一个"存储"队列,它不持有任何元素,每一次 put 必须立刻有一个对应的 take 来接手,否则会阻塞——可以把它理解成"容量为 0 的有界队列",任务提交进来必须立刻有空闲线程接住,接不住就直接触发创建新线程或者拒绝。

无界队列最大的问题在前几篇已经反复提到过:一旦核心线程被打满,后续所有任务都会被塞进这个"没有上限"的队列,maximumPoolSize 这个参数会因此永远没有机会生效——因为触发"创建超出核心线程数的临时线程"这个逻辑的前提,是工作队列已经满了,而无界队列永远不会满。这意味着如果使用无界队列,配置 maximumPoolSize 基本上是在写一个"看起来存在、但永远不会被执行到"的死代码。

有界队列则给了系统一个明确的缓冲上限:队列满了之后,才会真正触发"创建临时线程"和"拒绝策略"这两条后续路径,maximumPoolSize 才有意义。但有界队列的容量本身也是一个需要权衡的参数:容量太小,稍微有点流量波动就会立刻触发拒绝策略或者疯狂创建临时线程,系统显得"太敏感";容量太大,任务会在队列里堆积很久才被处理,用户能感知到的延迟会变得很高,而且这种堆积在监控上很容易被忽略——线程数、CPU 使用率可能看起来都很正常,只有专门去看队列长度或者任务的排队时长,才能发现问题已经在积累。

文章配图
文章配图

三、背压:把压力真实地传导回上游,而不是悄悄藏起来

"背压"(Backpressure)这个词经常出现在流处理、消息队列的语境里,但它同样是理解线程池拒绝策略设计的一个核心概念。背压的核心思想是:当下游处理不过来的时候,这个"处理不过来"的信号应该被真实地传导回上游,让上游主动减慢发送速度,而不是让下游默默地扛住压力,直到彻底崩溃。

放到线程池的场景里,"下游"就是线程池本身,"上游"就是不断调用 execute 提交任务的那部分代码。如果用无界队列把所有任务都接住,或者用 DiscardPolicy 把超出处理能力的任务悄悄丢弃,表面上线程池"没有出错",但压力从来没有被传导回上游——上游代码完全不知道下游已经处理不过来了,会继续以同样的速度提交任务,问题被无限期地隐藏和拖延,直到内存被堆积的任务占满,或者业务数据因为被静默丢弃而出现无法解释的缺失。

ThreadPoolExecutor 内置的四种拒绝策略里,真正体现"背压"思想的是 **CallerRunsPolicy**:当线程池和队列都已经满了,execute 调用会让提交任务的那个线程自己去执行这个任务,而不是丢给线程池内部的某个线程。这样做的效果是:调用方(往往是接收请求的线程,比如某个 Web 服务器的 I/O 线程)在提交任务被拒绝的这一刻,会被迫先执行完这个任务、再回去做其他事情——也就是说,调用方自己被"拖慢"了,它自然会因为处理速度变慢而降低继续提交新任务的速度。这个"减慢上游"的效果,正是背压最典型的体现:压力没有被隐藏,而是实实在在地反馈回了产生压力的源头。

文章配图
文章配图

四、四种内置拒绝策略逐一拆解

当核心线程、工作队列、临时线程这三层缓冲全部用满之后,execute 最终会调用线程池的 RejectedExecutionHandler.rejectedExecution(Runnable r, ThreadPoolExecutor executor) 方法。ThreadPoolExecutor 内置了四种实现:

  • **AbortPolicy**(默认策略):直接抛出 RejectedExecutionException,把"处理不过来"这个事实以异常的形式立刻反馈给调用方。这是最"诚实"的策略——它不会隐藏问题,调用方必须显式地处理这个异常(比如捕获后走降级逻辑),代价是如果调用方没有做好异常处理,这个未捕获的异常可能会直接影响上层业务逻辑的正常执行。
  • **CallerRunsPolicy**:如上一节所说,让调用线程自己执行这个任务,天然实现背压。需要注意的前提是:这要求调用方所在的线程"有能力"去执行这个任务(比如不会因为缺少某些线程本地的上下文而执行出错),并且调用方能接受"这次调用会变慢"这个代价——如果调用方是一个绝对不能被阻塞的线程(比如某些事件循环的核心线程),这个策略反而可能引发新的问题。
  • **DiscardPolicy**:什么都不做,静默丢弃这个任务,execute 方法直接返回,调用方完全感知不到任务被丢弃了。这是最"危险"的策略,因为它彻底破坏了背压——问题被完全隐藏,只适合那种"任务丢失的代价完全可以接受、且不需要任何感知"的场景(比如某些非关键的埋点上报)。
  • **DiscardOldestPolicy**:丢弃工作队列里排在最前面(等待最久)的那个任务,然后重新尝试提交当前这个新任务。它背后的假设是"越新的任务价值越高",适合那种数据有明显时效性、旧数据几乎没有意义的场景(比如某些只关心最新状态的监控采集)。但要注意,它丢弃的是队列里等待最久的任务,而不是"最不重要"的任务——如果队列里排在最前面的任务恰好是一个重要的任务,只是运气不好排得早,也会被无差别地丢弃。

这四种策略的本质区别,其实是在"要不要让调用方知道"和"知道之后由谁来承担代价"这两个维度上做的不同选择:AbortPolicy 让调用方知道、代价由调用方通过异常处理来承担;CallerRunsPolicy 让调用方知道(以"变慢"的形式)、代价由调用方自己执行任务来承担;DiscardPolicy 和 DiscardOldestPolicy 都不让调用方知道,代价则是"数据丢失的风险"被静默地转嫁给了整个系统。

文章配图
文章配图

文章配图
文章配图

五、降级:拒绝之后,业务层还能做什么

线程池层面的拒绝策略只是第一道防线,真正决定用户体验的,往往是业务层在收到拒绝信号之后如何降级。常见的降级手段包括:

  • 返回一个缓存的或者默认的结果,而不是让用户看到一个报错页面——比如推荐系统在推荐服务线程池被打满时,直接返回一份提前准备好的"热门榜单"作为兜底,用户体验上几乎没有察觉。
  • 把任务写入一个更持久的缓冲介质(比如消息队列、数据库表),标记为"待异步处理",之后由专门的补偿任务重新拉取处理——这种方式适合那些不要求实时完成、但要求最终一定要被处理的任务,本质上是把"内存里的排队"换成了"外部存储里的排队",代价是处理延迟会变高,但换来了更强的持久性保证(线程池的队列如果进程重启就会丢失,外部存储通常不会)。
  • 触发熔断,暂时切断某条调用链路:如果某个下游依赖持续导致线程池被打满,可以在应用层引入熔断器(比如统计最近一段时间的拒绝率,超过阈值就暂时熔断这条调用路径,一段时间后再尝试恢复),避免线程池长时间处于打满状态而拖累整个系统的其他功能。

这里有一个容易被忽略但很重要的细节:rejectedExecution 方法(无论是内置策略还是自定义策略)运行在调用**execute**的那个线程上,而不是线程池内部的某个工作线程。这意味着如果降级逻辑本身写得很重(比如同步写数据库、同步调用另一个可能很慢的服务),这段降级代码本身会拖慢调用方,可能在系统已经处于高压状态的时候,反而制造出新的性能问题——降级逻辑的设计原则应该是足够轻量、足够快,比如异步地丢进一个内存队列或者本地磁盘缓冲,而不是在拒绝的这一刻同步地做很重的操作。

六、自定义拒绝策略:什么时候四种内置策略都不够用

四种内置策略覆盖了最常见的场景,但很多生产系统最终都会走向自定义拒绝策略,原因通常是需要在"拒绝"这一刻做一些内置策略没有覆盖的事情:

  • 在拒绝发生的瞬间打一条监控指标或者告警,因为拒绝本身就是一个系统已经过载的强烈信号,值得被单独统计和告警,而不是混在普通的异常日志里;
  • 把被拒绝的任务持久化到一个降级队列或者数据库,供后续补偿处理,这是前一节提到的"降级"手段中最常见的一种实现方式;
  • 把被拒绝的任务转发到另一个专门用于兜底的、更小的线程池,让主线程池的过载不至于完全阻断这类任务的处理,只是降低处理的及时性。

实现自定义拒绝策略只需要实现 RejectedExecutionHandler 接口,重写 rejectedExecution(Runnable r, ThreadPoolExecutor executor) 方法,在构造 ThreadPoolExecutor 时把这个自定义实现传进去。需要特别提醒的是上一节提到的那个约束:这段代码运行在调用方的线程上,所以无论往里面塞监控上报、持久化写入还是转发到另一个线程池,都应该保证这些操作本身是非阻塞或者足够快的(比如用异步日志、异步写入队列的方式),否则自定义策略本身反而会成为一个新的"隐性阻塞点",在系统最脆弱的时刻制造额外的延迟。

文章配图
文章配图

七、面试追问链

从"系统扛不住时线程池会怎么处理多出来的任务"这个问题出发,逐层追问:有界队列和无界队列在触发 maximumPoolSize 这件事上有什么本质区别、"背压"这个概念具体指的是什么、CallerRunsPolicy 为什么能实现背压而 DiscardPolicy 不能、四种内置拒绝策略在"是否让调用方知道"和"代价由谁承担"这两个维度上分别是怎么选择的、业务层收到拒绝信号之后有哪些常见的降级手段、以及为什么自定义拒绝策略里的逻辑必须保持轻量和非阻塞,这条链基本能覆盖面试官对线程池过载处理这个话题的核心考察点。

文章配图
文章配图

延伸阅读

  • java.util.concurrent.ThreadPoolExecutor 源码中 RejectedExecutionHandler 接口及其四个内置实现类(AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy)的具体实现,建议逐个对照阅读
  • Reactive Streams 规范里对"背压"的正式定义,能帮助理解这个概念在更广泛的流处理场景里的通用形态
  • 下一篇预告:线程池的关闭流程——shutdown 与 shutdownNow 的区别、中断的传递范围与优雅停机实践

问答(不计入阅读时长)

Q1:为什么说"无界队列会让**maximumPoolSize**变成死代码",具体是卡在哪一步?

A:卡在 ThreadPoolExecutor.execute 内部判断"是否需要创建超出核心线程数的临时线程"这一步的触发条件上——这个判断的前提是"工作队列已经满了,塞不进去了",只有塞入队列失败,才会往下走到"看看能不能加线程"这一步。无界队列的容量是 Integer.MAX_VALUE,在实际业务里几乎不可能真的堆到这个数量级(堆到那种量级之前,进程早就因为内存溢出崩溃了),所以"队列已满"这个条件永远不会被触发,创建临时线程的分支自然也永远不会被执行到。这也是为什么用无界队列时,无论把 maximumPoolSize 设成 20 还是 200,系统的实际表现完全没有区别——这个参数写在代码里,却从来没有被读到过。

Q2:**SynchronousQueue**被形容成"容量为 0 的有界队列",实际使用上和普通有界队列(比如**ArrayBlockingQueue**)相比要注意什么?

A:最大的不同在于"任务进入队列"这件事本身就要求必须有一个空闲线程立刻接手,稍慢一点都不行。普通有界队列允许任务先"存"在队列里等着,哪怕当前没有空闲线程,只要队列没满,任务就能提交成功;而 SynchronousQueue 不存储任何元素,put 操作必须立刻匹配上一个 take 操作才能成功,否则会阻塞(在线程池场景里,"阻塞"会转化为"立刻判断线程数是否已达上限,进而立刻走向创建临时线程或拒绝策略")。这意味着用 SynchronousQueue 的线程池,"队列缓冲"这个中间地带几乎被抹平了——系统对瞬时并发的容忍度直接由 maximumPoolSize 决定,而不是由队列容量决定。典型的例子是 Executors.newCachedThreadPool() 就用了 SynchronousQueue:这个线程池没有队列缓冲,一旦并发上来就疯狂创建新线程(因为它的 maximumPoolSize 是 Integer.MAX_VALUE),本质上是把"控制并发"这件事完全交给了外部调用方自己控制节奏,用在没有并发上限保护的场景里很容易把资源耗尽。

Q3:**CallerRunsPolicy**实现的"背压",和其他系统里的背压机制(比如 TCP 的滑动窗口)是同一个思路吗?

A:是同一个思路,只是实现的层面不同。TCP 滑动窗口的背压体现在:接收方会告知发送方自己当前还能接收多少数据(窗口大小),发送方必须根据这个窗口调整发送速率,窗口变小时发送方自然要放慢——这是一种"显式告知处理能力,发送方主动配合减速"的背压。CallerRunsPolicy 的背压则更"物理":它不是通过告知一个数字让上游自己决定怎么做,而是直接让上游线程本身陷入执行任务的忙碌状态,用"上游被占用、没空继续提交"这个既成事实来达到减速的效果。两者的共同本质都是"让感受到压力的一方,把这份压力真实地传递给能够调整节奏的一方",只是 TCP 用的是协商式的信号,CallerRunsPolicy 用的是更直接的资源占用。理解了这层共性,再看消息队列里的"生产者限流"、响应式编程里的 Reactive Streams 背压协议,会发现它们都是同一套思想在不同层面的具体实现。

Q4:**DiscardPolicy**被认为是四种内置策略里最危险的,那它到底适合什么场景,什么场景绝对不能用?

A:适合"任务丢失完全不影响系统正确性,且没有任何人需要知道任务被丢弃"的场景,比较典型的是一些非核心的埋点上报、非关键的心跳采集——这类任务丢了几条,业务逻辑本身不会受任何影响,用户也感知不到。绝对不能用的场景是任何"任务丢失会导致数据不一致、或者需要有人为此负责"的地方,比如订单状态更新任务、支付相关的通知任务、任何写库操作——如果这类任务被静默丢弃,系统表面上运行正常,但底层数据已经出现了缺失,而且这种缺失往往是在很久之后被用户投诉或者对账异常发现时才暴露出来,排查成本极高,因为日志里根本没有留下"这个任务被丢弃了"的痕迹。一个基本的判断标准是:如果要给这个策略配一个监控指标(比如统计每分钟丢弃了多少个任务),如果连"要不要为它配监控"这件事都觉得没必要,才说明这个场景真的适合用 DiscardPolicy。

Q5:**DiscardOldestPolicy**丢弃的是"排队最久"的任务,而不是"最不重要"的任务,这个假设在什么情况下会翻车?

A:会翻车的典型情况是队列里混杂着"重要任务"和"不重要任务",且它们的排队顺序和重要性完全无关。比如一个统一的通知线程池,队列里同时排着"给用户发送验证码"和"给用户推送一条不痛不痒的活动提醒",如果验证码任务恰好提交得早、排在了队首,而线程池又恰好在这个时刻打满触发了 DiscardOldestPolicy,验证码任务就会被无差别地丢弃,用户会因为收不到验证码而无法完成登录或支付,但这条"活动提醒"任务反而被保留了下来。DiscardOldestPolicy 隐含的假设"越新的任务价值越高",只在那种"任务价值确实随时间递减、且队列里任务的重要性基本同质"的场景里才成立(比如行情推送、传感器采集这类"旧数据几乎没用"的场景)。如果一个线程池里跑着重要性天差地别的任务,更合理的做法是从设计上拆分成多个线程池分别处理,而不是依赖 DiscardOldestPolicy 去"猜"哪个任务该被丢弃。

Q6:为什么反复强调拒绝策略和降级逻辑必须"轻量、非阻塞",如果违反了,具体会引发什么连锁反应?

A:因为 rejectedExecution 是运行在调用 execute 的那个线程上的,这个线程往往身兼要职——很可能就是接收用户请求的 I/O 线程、或者是上一层业务逻辑正在占用的线程。如果这段代码本身很重(比如同步写数据库、同步调用一个可能很慢的下游服务),会发生这样的连锁反应:本来只是"线程池打满、需要拒绝一个任务"这一件事,现在变成了"占用调用方线程去做一件耗时不确定的事情";如果调用方线程本身也是从另一个线程池借来的(比如 Web 框架的请求处理线程池),这个线程被占用的时间变长,会导致这一层线程池的吞吐量也跟着下降,进而让更多请求排队、更多请求触发它自己的超时或拒绝——一个本该是"线程池 A 内部的降级处理",最终演变成了"线程池 A 拖慢了线程池 B,线程池 B 又拖慢了更上层的调用方",形成了压力沿着调用链反向蔓延的连锁反应,而这原本恰恰是"拒绝策略"应该去阻止、而不是去制造的问题。

Q7:如果自定义拒绝策略里选择把被拒绝的任务异步持久化到消息队列,供后续补偿处理,实践中最容易踩的坑是什么?

A:最容易踩的坑是幂等性和重复执行的边界没有想清楚。任务被拒绝、被写入消息队列、之后被补偿逻辑重新消费执行——这个链路里,任务本身很可能已经被赋予了"只执行一次"的隐含假设(比如一次扣款、一次库存扣减),但被拒绝的这个瞬间并不代表任务完全没有产生任何副作用:如果任务在被判定"需要拒绝"之前,其实已经部分执行、只是还没来得及完成,简单地把它重新丢进队列重跑,就可能导致重复扣款、重复扣库存这类问题。正确的做法是在设计降级持久化逻辑之前,先确认原始任务本身是否幂等(比如是否带有唯一的业务凭证号,重复提交时下游能识别并拒绝重复处理),如果任务本身不是幂等的,仅仅靠"降级时多存一份"是没办法保证正确性的,必须在任务设计的更早阶段就把幂等性作为前提条件,拒绝策略这一层能做的只是"不要让任务彻底丢失",无法替代业务层本该具备的幂等保护。

Q8:结合前几篇讲的**corePoolSize**、**maximumPoolSize**估算方法,实践中调优有界队列容量和拒绝策略,大致应该按什么顺序排查?

A:建议按"先确认参数是否合理,再看队列容量是否合适,最后才调整拒绝策略"这个顺序。第一步,回顾前几篇提到的估算方法(阻塞系数公式、Little 定律、压测拐点),确认 corePoolSize 和 maximumPoolSize 本身是不是已经贴近真实的日常负载和峰值负载——如果这两个参数本身估算得偏小,无论怎么调队列容量和拒绝策略都只是在"合理地处理一个不合理的瓶颈",治标不治本。第二步,在参数基本合理的前提下,观察线上实际的排队情况(队列长度、任务平均排队时长),判断当前的队列容量是偏小(经常瞬间打满触发拒绝)还是偏大(任务堆积导致延迟明显但很少真正触发拒绝),据此调整队列容量。第三步,才是根据业务对"任务丢失/延迟/降级"的容忍程度,选择合适的拒绝策略(或者决定要不要写自定义策略)——这一步的调整空间其实是最小的,因为它处理的是"前两步已经没能兜住的极端情况",如果发现需要频繁依赖拒绝策略去兜底,往往说明前两步的参数或队列容量本身还有优化空间,而不是拒绝策略选错了。