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 更像是“先帮你排一会儿队,实在排不下了再让上游慢下来”。
队列不是越大越好。
队列越大,系统越不容易马上报错,但也越容易把问题藏起来。
真正好的线程池设计,不是单纯追求不拒绝任务,而是要让系统在压力变大时,能够及时反压、及时暴露问题、避免局部被打爆。

浙公网安备 33010602011771号