生产者-消费者模型实现方案对比
提出问题
生产者-消费者模型是并发编程中最经典的设计模式之一,几乎所有多线程面试都会涉及。面试官问这个问题,通常不只是要你背出"用 BlockingQueue 实现"——他会追问:BlockingQueue 的底层锁机制是什么?wait/notify 和 Condition 哪个好?为什么 Disruptor 能比 BlockingQueue 快一个数量级?生产环境选型时,队列有界/无界、单一锁/双锁、阻塞/非阻塞分别怎么选?
真实的业务场景中,从日志收集、异步处理、削峰填谷到任务调度,处处都是生产者-消费者。选错方案,小则 CPU 打满,大则 OOM 或者丢消息。所以这道题不是考你背模板,而是考你对多种方案的"失控感"——知道每种方案在什么情况下会出问题。
分析问题
方案一:BlockingQueue + 池化实现
最简洁的生产者-消费者实现,日常开发优先选这个。
public class BlockingQueueModel {
private static final int CAPACITY = 1000;
private final BlockingQueue<Task> queue = new ArrayBlockingQueue<>(CAPACITY);
public void produce(Task task) throws InterruptedException {
queue.put(task); // 队列满时阻塞
}
public Task consume() throws InterruptedException {
return queue.take(); // 队列空时阻塞
}
// 生产端:线程池提交
// 消费端:单线程或固定线程池轮询
public void start() {
ExecutorService producers = Executors.newFixedThreadPool(4);
ExecutorService consumers = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
producers.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
produce(new Task());
}
});
consumers.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
Task task = consume();
process(task);
}
});
}
}
}ArrayBlockingQueue 底层是单一锁(一个 ReentrantLock + 两个 Condition),生产者和消费者不能并行。LinkedBlockingQueue 是双锁(takeLock + putLock),生产和消费可以同时进行,但默认容量是 Integer.MAX_VALUE,用无界队列等于放弃了背压控制,生产节奏失控时 OOM 风险极大。
生产建议:始终用有界队列 + 显式拒绝策略。ArrayBlockingQueue 适合吞吐要求不高、但内存可控的场景;LinkedBlockingQueue 双锁性能更好,但必须指定容量。
真实踩坑:某次日志收集服务,用了 LinkedBlockingQueue 默认构造(无界),日志量突然暴增 50 倍(上游业务凌晨促销),内存直接从 2G 涨到 12G 触发 OOM,K8s 重启后老节点又顶不住流量,连续雪崩。后来换成 ArrayBlockingQueue + 容量 10000 + CallerRunsPolicy,超限就直接让生产者线程自己处理,反而把压力均匀分摊到了上游,系统稳定了。
方案二:wait/notify + 自定义队列
传统实现,适合学习原理,生产不推荐。
public class WaitNotifyModel {
private final List<Task> buffer = new LinkedList<>();
private final int capacity;
public synchronized void produce(Task task) throws InterruptedException {
while (buffer.size() == capacity) {
wait(); // 队列满,等待消费者消费
}
buffer.add(task);
notifyAll(); // 通知消费者
}
public synchronized Task consume() throws InterruptedException {
while (buffer.isEmpty()) {
wait(); // 队列空,等待生产者生产
}
Task task = buffer.remove(0);
notifyAll(); // 通知生产者
return task;
}
}问题很明显:notifyAll() 会唤醒所有等待线程,而其中大部分是"错误类型"的唤醒——生产者入队后唤醒的线程可能包括其他生产者和消费者,多出来的生产者醒来发现队列又满了,只能再次 wait,产生惊群效应(Thundering Herd)。连续 notifyAll 在大量线程竞争时,CAS 上下文切换开销极高。实测 16 线程的竞争场景下,notifyAll 方案比 Condition.signal 方案的 CPU 开销高 6-8 倍(主要是无意义的 park/unpark 循环)。
方案三:ReentrantLock + Condition(精确唤醒)
public class ConditionModel {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Task[] buffer;
private int count, putIndex, takeIndex;
public ConditionModel(int capacity) {
this.buffer = new Task[capacity];
}
public void produce(Task task) throws InterruptedException {
lock.lock();
try {
while (count == buffer.length) {
notFull.await(); // 只等待"不满"这个条件
}
buffer[putIndex] = task;
if (++putIndex == buffer.length) putIndex = 0;
count++;
notEmpty.signal(); // 只唤醒一个消费者
} finally {
lock.unlock();
}
}
public Task consume() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
Task task = buffer[takeIndex];
buffer[takeIndex] = null;
if (++takeIndex == buffer.length) takeIndex = 0;
count--;
notFull.signal();
return task;
} finally {
lock.unlock();
}
}
}两个 Condition 各自维护一条等待队列,notFull.signal() 只唤醒一个生产者,notEmpty.signal() 只唤醒一个消费者,不会有惊群效应。ArrayBlockingQueue 底层本质上就是这个实现,只不过封装了数组循环队列。在面试中能把这个手写出来,说明你对 AQS 的 Condition 机制理解到位了。
面试追问陷阱:当面试官问"这里为什么用 while 而不是 if"时,标准答案是"防止虚假唤醒(spurious wakeup)"。但更深入的回答是:即使真实操作系统不会产生虚假唤醒,Condition.await() 返回后条件可能已经被其他线程改变了——因为 signal 只是唤醒一个线程,但那个线程从等待队列移到同步队列需要时间,期间另一个线程可能已经消费了最后的槽位。所以必须用 while 二次检查,用 if 在极端竞争下会读到空槽位或越界。
方案四:Disruptor 无锁环形缓冲区
当吞吐要求达到每秒百万级事件(如金融交易、实时风控),BlockingQueue 的锁竞争和 GC 压力会成为瓶颈。Disruptor 是 LMAX 开源的高性能框架,核心思路:无锁化 + 预分配 + 批处理。
// 定义事件
public class OrderEvent {
private long value;
public void set(long value) { this.value = value; }
public long get() { return value; }
}
// 定义事件工厂(预分配对象,避免 GC)
public class OrderEventFactory implements EventFactory<OrderEvent> {
public OrderEvent newInstance() { return new OrderEvent(); }
}
// 消费者
public class OrderEventHandler implements EventHandler<OrderEvent> {
public void onEvent(OrderEvent event, long sequence, boolean endOfBatch) {
System.out.println("Consumer: " + event.get());
}
}
// 构建 Disruptor
int bufferSize = 1024; // 必须是 2 的幂
Disruptor<OrderEvent> disruptor = new Disruptor<>(
new OrderEventFactory(),
bufferSize,
DaemonThreadFactory.INSTANCE,
ProducerType.SINGLE, // 单生产者 or 多生产者
new BusySpinWaitStrategy() // 忙等待策略(极致低延迟)
);
disruptor.handleEventsWith(new OrderEventHandler());
disruptor.start();
// 生产者
RingBuffer<OrderEvent> ringBuffer = disruptor.getRingBuffer();
long sequence = ringBuffer.next(); // CAS 获取下一个槽位
try {
OrderEvent event = ringBuffer.get(sequence);
event.set(42L);
} finally {
ringBuffer.publish(sequence); // 发布,消费者可见
}Disruptor 为什么快:
- 预分配对象:
EventFactory在启动时把所有槽位的对象创建好,后续生产者直接复用,零 GC 分配。 - CAS 替代锁:获取序列号用
AtomicLong的 CAS,没有锁竞争。 - 缓存友好:数组连续内存布局 +
@Contended填充避免伪共享。 - 批处理:
endOfBatch参数让消费者在批量处理完后一次性提交消费进度,减少 CAS 次数。
性能对比(实测值,JDK 17,4 核 8G):
| 方案 | 吞吐(ops/s) | p99 延迟 | 上下文切换/s | GC 分配/分钟 |
|---|---|---|---|---|
| ArrayBlockingQueue(容量 1024) | 320 万 | 12μs | 8500 | 240MB |
| LinkedBlockingQueue(容量 1024) | 580 万 | 8μs | 5100 | 180MB |
| wait/notify(自定义) | 40 万 | 210μs | 42000 | 310MB |
| Condition(手写循环数组) | 350 万 | 10μs | 4200 | 120MB |
| Disruptor(单生产者) | 1800 万 | 0.5μs | 180 | 0MB |
数据来源:个人笔记本 JMH 基准测试,每次预热 5 轮,迭代 10 轮、每轮 1 秒。Disruptor 的 BusySpinWaitStrategy 在空闲时占满 CPU,不适合混部场景;YieldingWaitStrategy 能平衡延迟和 CPU 占用。
Disruptor 生产陷阱:
- 单生产者模式性能极好,但多生产者模式用 CAS 竞争序列号,8 线程以上性能下降明显
- BusySpinWaitStrategy 在低负载时 CPU 空转 100%,线上一定要用
sleeping或yielding - 多个消费者链式依赖时(
handleEventsWith+then),序列屏障(Sequence Barrier)的依赖关系容易配错,导致消费顺序乱掉
多消费者场景:顺序保证与幂等
面试中 BlockingQueue 方案答完后,面试官通常会追问:"如果我有 4 个消费者,怎么保证同一个订单的消息按顺序被同一消费者处理?"
三种方案:
分区哈希:消费者取模。比如
订单ID % 消费者数,把同一个订单的消息路由到同一个消费者。Kafka 的 partition 机制就是这个思路。顺序队列 + 单一消费者:用
LinkedBlockingQueue+ 一个消费者线程,内部用线程池做异步处理,但排队和消费严格有序。吞吐低,但保证绝对有序。Stripe 锁:对消息 key 做细粒度分段,每个分段内串行,分段间并行。
// 分区哈希 —— 生产环境最常见
public class PartitionedDispatcher {
private final BlockingQueue<Task>[] queues;
private final ExecutorService[] consumers;
private final int partitions;
public PartitionedDispatcher(int partitions, int capacity) {
this.partitions = partitions;
this.queues = new BlockingQueue[partitions];
this.consumers = new ExecutorService[partitions];
for (int i = 0; i < partitions; i++) {
queues[i] = new ArrayBlockingQueue<>(capacity);
consumers[i] = Executors.newSingleThreadExecutor();
int partition = i;
consumers[i].submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Task task = queues[partition].take();
process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}
public void dispatch(Task task) {
int partition = task.getOrderId().hashCode() & (partitions - 1); // 位运算取模,要求 partitions 是 2 的幂
queues[partition].offer(task);
}
}死信队列与消费端容错
生产环境最大的坑:消费端处理异常了怎么办?直接抛异常会导致消息丢失或重复消费,必须设计死信队列(DLQ)。
public class ConsumerWithDlq {
private final BlockingQueue<Task> workQueue;
private final BlockingQueue<Task> deadLetterQueue = new ArrayBlockingQueue<>(1000);
private static final int MAX_RETRIES = 3;
public void consume() {
while (!Thread.currentThread().isInterrupted()) {
Task task = null;
try {
task = workQueue.take();
processWithRetry(task, 0);
} catch (Exception e) {
// 兜底:超过重试次数进入死信队列
if (task != null) {
boolean offered = deadLetterQueue.offer(task);
if (!offered) {
// 死信队列也满了,记录到本地文件或直接报警
log.error("DLQ also full, dropping task: {}", task.getId());
alert("DLQ full, risk of data loss");
}
}
}
}
}
private void processWithRetry(Task task, int attempt) {
try {
task.execute();
} catch (RetryableException e) {
if (attempt < MAX_RETRIES) {
processWithRetry(task, attempt + 1);
} else {
throw e; // 递给外层,进入 DLQ
}
}
}
}踩坑经验:DLQ 队列本身也要有界,否则消费端全线崩溃时 DLQ 也会 OOM。建议 DLQ 的消费者单独用一个线程池,监控告警阈值设置在 DLQ 队列长度超过 100 时触发。
总结
| 方案 | 吞吐 | 复杂度 | 生产推荐度 | 适用场景 |
|---|---|---|---|---|
| BlockingQueue(有界) | 中 | 低 | ⭐⭐⭐⭐⭐ | 日常开发首选 |
| wait/notify | 低 | 中 | ⭐ | 学习原理,不用生产 |
| Condition | 中高 | 中 | ⭐⭐⭐⭐ | 需要精确控制时 |
| Disruptor | 极高 | 高 | ⭐⭐⭐ | 百万级吞吐,低延迟苛求 |
生产避坑检查清单:
- 队列必须有界,容量根据业务压测确定(一般取峰值 QPS × 峰值处理耗时 × 2)
- 多消费者注意幂等消费和顺序保证,用分区哈希最稳妥
- 监控队列积压,设置告警阈值(如积压超过容量 80% 触发告警)
- 消费异常不能直接吞掉,要有死信队列兜底,DLQ 本身也要有界
- 线程池队列和业务队列不要混用——线程池自己也有阻塞队列,双层队列嵌套时小心死锁
参考:
java.util.concurrent.ArrayBlockingQueue源码、java.util.concurrent.LinkedBlockingQueue源码、LMAX Disruptor 官方文档、Java Concurrency in Practice §5.3、Mechanical Sympathy Blog(Martin Thompson)