Skip to content

生产者-消费者模型实现方案对比

提出问题

生产者-消费者模型是并发编程中最经典的设计模式之一,几乎所有多线程面试都会涉及。面试官问这个问题,通常不只是要你背出"用 BlockingQueue 实现"——他会追问:BlockingQueue 的底层锁机制是什么?wait/notify 和 Condition 哪个好?为什么 Disruptor 能比 BlockingQueue 快一个数量级?生产环境选型时,队列有界/无界、单一锁/双锁、阻塞/非阻塞分别怎么选?

真实的业务场景中,从日志收集、异步处理、削峰填谷到任务调度,处处都是生产者-消费者。选错方案,小则 CPU 打满,大则 OOM 或者丢消息。所以这道题不是考你背模板,而是考你对多种方案的"失控感"——知道每种方案在什么情况下会出问题。

分析问题

方案一:BlockingQueue + 池化实现

最简洁的生产者-消费者实现,日常开发优先选这个。

java
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 + 自定义队列

传统实现,适合学习原理,生产不推荐。

java
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(精确唤醒)

java
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 开源的高性能框架,核心思路:无锁化 + 预分配 + 批处理

java
// 定义事件
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 为什么快:

  1. 预分配对象EventFactory 在启动时把所有槽位的对象创建好,后续生产者直接复用,零 GC 分配。
  2. CAS 替代锁:获取序列号用 AtomicLong 的 CAS,没有锁竞争。
  3. 缓存友好:数组连续内存布局 + @Contended 填充避免伪共享。
  4. 批处理endOfBatch 参数让消费者在批量处理完后一次性提交消费进度,减少 CAS 次数。

性能对比(实测值,JDK 17,4 核 8G)

方案吞吐(ops/s)p99 延迟上下文切换/sGC 分配/分钟
ArrayBlockingQueue(容量 1024)320 万12μs8500240MB
LinkedBlockingQueue(容量 1024)580 万8μs5100180MB
wait/notify(自定义)40 万210μs42000310MB
Condition(手写循环数组)350 万10μs4200120MB
Disruptor(单生产者)1800 万0.5μs1800MB

数据来源:个人笔记本 JMH 基准测试,每次预热 5 轮,迭代 10 轮、每轮 1 秒。Disruptor 的 BusySpinWaitStrategy 在空闲时占满 CPU,不适合混部场景;YieldingWaitStrategy 能平衡延迟和 CPU 占用。

Disruptor 生产陷阱

  • 单生产者模式性能极好,但多生产者模式用 CAS 竞争序列号,8 线程以上性能下降明显
  • BusySpinWaitStrategy 在低负载时 CPU 空转 100%,线上一定要用 sleepingyielding
  • 多个消费者链式依赖时(handleEventsWith + then),序列屏障(Sequence Barrier)的依赖关系容易配错,导致消费顺序乱掉

多消费者场景:顺序保证与幂等

面试中 BlockingQueue 方案答完后,面试官通常会追问:"如果我有 4 个消费者,怎么保证同一个订单的消息按顺序被同一消费者处理?"

三种方案

  1. 分区哈希:消费者取模。比如 订单ID % 消费者数,把同一个订单的消息路由到同一个消费者。Kafka 的 partition 机制就是这个思路。

  2. 顺序队列 + 单一消费者:用 LinkedBlockingQueue + 一个消费者线程,内部用线程池做异步处理,但排队和消费严格有序。吞吐低,但保证绝对有序。

  3. Stripe 锁:对消息 key 做细粒度分段,每个分段内串行,分段间并行。

java
// 分区哈希 —— 生产环境最常见
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)。

java
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极高⭐⭐⭐百万级吞吐,低延迟苛求

生产避坑检查清单

  1. 队列必须有界,容量根据业务压测确定(一般取峰值 QPS × 峰值处理耗时 × 2)
  2. 多消费者注意幂等消费和顺序保证,用分区哈希最稳妥
  3. 监控队列积压,设置告警阈值(如积压超过容量 80% 触发告警)
  4. 消费异常不能直接吞掉,要有死信队列兜底,DLQ 本身也要有界
  5. 线程池队列和业务队列不要混用——线程池自己也有阻塞队列,双层队列嵌套时小心死锁

参考:java.util.concurrent.ArrayBlockingQueue 源码、java.util.concurrent.LinkedBlockingQueue 源码、LMAX Disruptor 官方文档、Java Concurrency in Practice §5.3、Mechanical Sympathy Blog(Martin Thompson)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。