SynchronousQueue + CallerRunsPolicy 与 LinkedBlockingQueue + CallerRunsPolicy 对比

SynchronousQueue + CallerRunsPolicy 与 LinkedBlockingQueue + CallerRunsPolicy 对比

本文是总结业务上遇到的线程池设计问题
询问GPT后总结的内容

1. 背景

在 Java 线程池中,很多人会重点关注这几个参数:

corePoolSize
maximumPoolSize
keepAliveTime

但实际上,真正决定线程池在高并发下如何表现的,往往是这两个东西:

workQueue
RejectedExecutionHandler

也就是:

任务队列 + 拒绝策略

这篇文章主要对比两个常见组合:

SynchronousQueue + CallerRunsPolicy

和:

LinkedBlockingQueue<>(100) + CallerRunsPolicy

它们都用了 CallerRunsPolicy,但是因为队列不同,最终表现完全不一样。


2. 先理解 CallerRunsPolicy

CallerRunsPolicy 是 Java 线程池的一种拒绝策略。

它的特点是:

当线程池处理不过来时,不丢弃任务,也不抛异常,而是让提交任务的线程自己执行这个任务。

例如:

executor.execute(() -> handleTask());

如果线程池已经满了,触发了 CallerRunsPolicy,那么 handleTask() 可能就会由调用 execute() 的线程自己执行。

也就是说:

谁提交任务,谁自己执行

这个机制天然带有一种反压效果。

因为提交任务的线程被迫去执行任务之后,它就没办法继续快速提交新任务了。


3. 什么是反压?

反压可以简单理解为:

下游处理不过来时,让上游慢下来。

比如 MQ 消费场景:

Broker -> Consumer -> 业务线程池 -> 数据库

如果业务线程池处理不过来,就不能让 Consumer 继续疯狂从 Broker 拉消息。

否则消息可能不会堆在 Broker,而是堆在 Consumer 的 JVM 内存里。

这样很危险:

本地任务越来越多
内存压力越来越大
处理延迟越来越高
最终可能 OOM

所以我们希望:

业务线程池忙不过来
↓
Consumer 消费线程变慢
↓
从 Broker 拉消息的速度下降
↓
消息留在 Broker 侧

这就是 MQ 消费场景中的反压。


4. SynchronousQueue + CallerRunsPolicy

4.1 线程池配置

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        4,
        8,
        60L,
        TimeUnit.SECONDS,
        new SynchronousQueue<>(),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

4.2 SynchronousQueue 的特点

SynchronousQueue 的特点是:

不存任务,容量可以理解为 0。

它不是一个真正用来缓存任务的队列,而是一个“直接交接点”。

也就是说:

任务来了,必须马上交给线程执行。
如果没有线程接,就不能放进去排队。

4.3 在线程池中的执行流程

假设线程池参数是:

corePoolSize = 4
maximumPoolSize = 8
queue = SynchronousQueue

当任务不断进来时:

第 1 ~ 4 个任务:创建核心线程执行
第 5 ~ 8 个任务:创建非核心线程执行
第 9 个任务开始:线程池满了,SynchronousQueue 又不能排队,触发 CallerRunsPolicy

于是第 9 个任务开始,就会由提交任务的线程自己执行。

4.4 它的核心特点

不排队
快速扩容
满了立刻反压

所以它是一种比较强硬的线程池模型。

它不帮你偷偷堆任务。

能处理就处理,处理不了就让提交线程自己慢下来。


5. LinkedBlockingQueue<>(100) + CallerRunsPolicy

5.1 线程池配置

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        4,
        8,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(100),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

5.2 LinkedBlockingQueue 的特点

LinkedBlockingQueue<>(100) 是一个有界阻塞队列。

它最多可以缓存 100 个任务。

也就是说:

线程忙不过来时,任务可以先排队。

5.3 在线程池中的执行流程

假设线程池参数是:

corePoolSize = 4
maximumPoolSize = 8
queue = LinkedBlockingQueue<>(100)

当任务不断进来时:

第 1 ~ 4 个任务:创建核心线程执行
第 5 ~ 104 个任务:进入队列排队
第 105 ~ 108 个任务:创建非核心线程执行
第 109 个任务开始:线程池满了,队列也满了,触发 CallerRunsPolicy

注意,这个组合不会很快触发反压。

它会先使用队列缓存任务。

5.4 它的核心特点

允许短暂排队
可以削峰填谷
队列满了之后才反压

所以它是一种比较温和的线程池模型。

它适合那些可以稍微排队的任务。


6. 两种组合的核心区别

对比项 SynchronousQueue + CallerRunsPolicy LinkedBlockingQueue<>(100) + CallerRunsPolicy
是否缓存任务 不缓存 最多缓存 100 个
反压触发时机 最大线程满了就触发 队列满、最大线程也满了才触发
反压强度 强反压 缓冲后反压
本地任务堆积 基本不堆积 可能堆积 100 个任务
任务延迟 更可控 高峰期可能排队变久
适合场景 MQ 消费、实时任务、不允许本地堆积 普通异步任务、通知、日志、轻量业务任务
主要风险 提交线程被拖慢 队列积压导致延迟升高

7. MQ 消费场景为什么适合 SynchronousQueue + CallerRunsPolicy?

假设 MQ 消费代码是这样的:

public void onMessage(Message message) {
    executor.execute(() -> {
        try {
            handleMessage(message);
            ack(message);
        } catch (Exception e) {
            nack(message);
        }
    });
}

如果使用:

new SynchronousQueue<>()
new ThreadPoolExecutor.CallerRunsPolicy()

当业务线程池满了以后,executor.execute() 会触发 CallerRunsPolicy

这时会发生:

MQ 消费线程自己执行 handleMessage(message)

于是 MQ 消费线程被业务处理逻辑占住了。

它就没办法继续快速从 Broker 拉消息。

最终效果是:

业务线程池处理不过来
↓
MQ 消费线程自己处理消息
↓
MQ 消费线程变慢
↓
从 Broker 拉消息速度下降
↓
消息留在 Broker 侧

这就是反压。

为什么消息留在 Broker 更好?

因为 Broker 本来就是用来存消息的。

它通常具备:

消息持久化
消费位点管理
失败重试
死信队列
消息堆积监控
集群副本机制

而 Consumer 本地线程池队列只是 JVM 内存结构。

如果任务大量堆在 Consumer 本地,风险更大:

应用重启任务丢失
内存占用越来越高
排队时间不可控
严重时可能 OOM

所以 MQ 消费场景里,通常更希望:

消息堆在 Broker,而不是堆在 Consumer JVM 内存里

8. Broker 崩了怎么办?

让消息留在 Broker,不代表 Broker 永远不会出问题。

这只是说明:

Broker 比 Consumer JVM 更适合承接消息堆积压力。

但 Broker 侧也必须做好高可用设计。

常见措施包括:

开启消息持久化
部署 Broker 集群
配置副本机制
监控消息堆积量
监控磁盘使用率
设置死信队列
必要时对 Producer 限流

完整链路应该是:

Consumer 处理不过来
↓
Consumer 反压,降低消费速度
↓
Broker 开始堆积消息
↓
Broker 堆积过高触发告警
↓
Producer 限流或业务降级

所以真正稳定的 MQ 系统,不是只靠 Consumer 反压,而是整条链路都要有保护。


9. 普通异步任务为什么适合 LinkedBlockingQueue<>(100) + CallerRunsPolicy?

比如这些场景:

发送邮件
发送短信
异步日志
站内通知
轻量数据同步

这些任务通常不要求马上执行。

它们可以接受短暂排队。

这时使用:

new LinkedBlockingQueue<>(100)
new ThreadPoolExecutor.CallerRunsPolicy()

会更加平滑。

因为短时间内流量突然变高时,队列可以先缓存一部分任务。

只有当队列也满了,才会触发 CallerRunsPolicy

也就是:

平时正常异步执行
高峰期先排队缓冲
队列满了再反压调用方

这种模型适合普通业务系统。


10. 如何选择?

可以问自己一个问题:

这个任务排队 5 秒、10 秒之后,还有没有处理意义?

如果答案是:

没有意义,任务不能堆积,必须尽快处理

更适合:

SynchronousQueue + CallerRunsPolicy

比如:

MQ 消费处理
实时任务处理
请求转发
不允许本地堆积的任务

如果答案是:

可以接受短暂排队,只要最终处理即可

更适合:

LinkedBlockingQueue<>(100) + CallerRunsPolicy

比如:

发送通知
异步日志
邮件短信
普通业务异步任务

11. 总结

SynchronousQueue + CallerRunsPolicy 是强反压模型。

它的特点是:

不排队
满了立刻反压
不让任务在 Consumer 本地大量堆积

它适合 MQ 消费、实时任务、不允许本地堆积的场景。

LinkedBlockingQueue<>(100) + CallerRunsPolicy 是缓冲后反压模型。

它的特点是:

先排队缓冲
队列满了再反压
系统表现更加平滑

它适合普通异步任务、通知、日志、轻量业务处理等场景。

最后可以记住一句话:

SynchronousQueue 更像是“处理不了就立刻让上游慢下来”;LinkedBlockingQueue 更像是“先帮你排一会儿队,实在排不下了再让上游慢下来”。

队列不是越大越好。

队列越大,系统越不容易马上报错,但也越容易把问题藏起来。

真正好的线程池设计,不是单纯追求不拒绝任务,而是要让系统在压力变大时,能够及时反压、及时暴露问题、避免局部被打爆。

posted @ 2026-05-26 16:05  代码丰  阅读(5)  评论(0)    收藏  举报