手写生产者消费者问题

58 阅读10分钟

面试官:手写个生产者消费者模式?我反手就是一套王炸!

前言:一次面试引发的“血案”

“同学,看你简历上写着熟悉Java并发编程,那我们来聊聊生产者-消费者模式吧。不许用BlockingQueue,就用一个普通的List当容器,你来实现一下。”

上周,我的一个朋友在面试时就遇到了这道“送命题”。这道题看似简单,实则暗藏玄机,考察的不仅仅是多线程的基础,更是对并发编程底层原理、性能优化和代码健壮性的深度理解。

他当时给出了一个基础的synchronized版本,勉强过关,但面试官后续的追问让他捉襟见肘:“你这个实现有什么问题?怎么优化?ArrayListLinkedList该用哪个?如何优雅地关闭这些线程?”

痛定思痛,我决定把这个问题彻底搞明白,从一个最基础的版本开始,一步步重构、优化,直到打磨出一个接近“生产级”的实现。如果你也想在面试官面前秀一把操作,那么请系好安全带,我们发车了!

第一站:能跑就行的“青铜”版本

我们先从一个最基础的版本开始,这也是很多人(包括我朋友)在面试时第一反应能写出来的版本。它严格遵守了题目要求:使用List作为容器,并利用Java最基础的synchronizedwait()notifyAll()来实现线程间的同步与协作。

Java

import java.util.ArrayList;
import java.util.List;

// 这是一个能跑,但问题多多的初始版本
public class ProducerConsumerV1 {

    public static void main(String args) {
        List<String> buffer = new ArrayList<>();
        int capacity = 10;

        // 启动生产者线程
        new Thread(new Producer(buffer, capacity), "Producer").start();
        // 启动消费者线程
        new Thread(new Consumer(buffer), "Consumer").start();
    }
}

class Producer implements Runnable {
    private final List<String> buffer;
    private final int capacity;

    public Producer(List<String> buffer, int capacity) {
        this.buffer = buffer;
        this.capacity = capacity;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (buffer) {
                if (buffer.size() == capacity) {
                    try {
                        // 缓冲区满了,生产者等待
                        buffer.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                String item = "item-" + System.currentTimeMillis();
                buffer.add(item);
                System.out.println(Thread.currentThread().getName() + " produced " + item);
                // 通知消费者可以消费了
                buffer.notifyAll();
            }
        }
    }
}

class Consumer implements Runnable {
    private final List<String> buffer;

    public Consumer(List<String> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (buffer) {
                if (buffer.isEmpty()) {
                    try {
                        // 缓冲区空了,消费者等待
                        buffer.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                String item = buffer.remove(0);
                System.out.println(Thread.currentThread().getName() + " consumed " + item);
                // 通知生产者可以生产了
                buffer.notifyAll();
            }
        }
    }
}

这个版本看起来逻辑清晰,但它至少隐藏了三个“巨坑”。接下来,让我们开启优化之旅,逐一填坑。

第二站:迈向“白银”——修正致命的逻辑缺陷

优化点1:防范“虚假唤醒”

上面的代码中,我们用if语句来判断是否需要等待。这是一个非常严重且隐蔽的缺陷。在多线程编程中,存在一种现象叫做虚假唤醒 (Spurious Wakeups) 1。简单来说,线程可能在没有任何notify()notifyAll()调用的情况下,自己从wait()状态中“醒来”。

如果使用if判断,线程醒来后就不会再次检查条件,直接往下执行。想象一下,如果一个消费者被虚假唤醒,而此时缓冲区恰好是空的,调用buffer.remove(0)会发生什么?IndexOutOfBoundsException!程序直接崩溃。

【改造方案】 :永远在while循环中调用wait()。这是Java并发编程的铁律 1。

Java

// 正确的等待姿势
synchronized (buffer) {
    // 使用while循环,线程被唤醒后会重新检查条件
    while (buffer.isEmpty()) {
        buffer.wait();
    }
    //...消费逻辑...
}

这样,即使线程被虚假唤醒,它也会重新检查循环条件。如果条件不满足(缓冲区依然为空),它会再次进入wait()状态,从而保证了程序的正确性。

第三站:冲击“黄金”——深挖性能瓶颈

优化点2:ArrayList vs. LinkedList,一个关乎性能的抉择

我们的代码用List来模拟一个先进先出(FIFO)的队列:生产者在队尾添加(add(item)),消费者从队头移除(remove(0))。那么问题来了,List的实现类ArrayListLinkedList,哪个性能更好?

  • ArrayList.remove(0) : 它的底层是数组。移除第一个元素后,为了保持数组的连续性,它需要把后面所有的元素都向前移动一位。这是一个**O(n)**复杂度的操作 4。当缓冲区很大时,每次消费都会带来巨大的数据复制开销。
  • LinkedList.remove(0) : 它的底层是双向链表。移除第一个元素只需要修改几个指针的指向,与链表长度无关。这是一个**O(1)**复杂度的操作 5。

从算法复杂度来看,LinkedList完胜。但别急,还有个“反常识”的知识点:CPU缓存

  • ArrayList的元素在内存中是连续存放的,这非常有利于CPU缓存。System.arraycopy这个操作在硬件层面是高度优化的,速度极快 7。
  • LinkedList的节点在内存中是分散的,访问下一个节点可能导致“缓存未命中”,CPU需要从慢速的主内存中重新加载数据,这个过程叫“指针追逐” 7。

【结论】 :尽管对于小容量的缓冲区,ArrayList可能因其缓存友好性而表现不错,但从可扩展性和通用性来看,LinkedList是更合理、更安全的选择。因为随着缓冲区容量的增加,ArrayList那O(n)的复制成本最终会成为无法忽视的性能瓶颈。

【改造方案】 :将共享容器的实现从ArrayList改为LinkedList

第四站:晋级“铂金”——引入更优雅的锁与信令

优化点3:告别“惊群效应”,拥抱ReentrantLockCondition

在我们的基础版本中,无论是生产者还是消费者,每次操作后都粗暴地调用notifyAll()。这会唤醒所有正在等待的线程,包括那些条件根本不满足的线程(比如,一个生产者唤醒了其他生产者)。这些被“误伤”的线程在徒劳地竞争锁、检查条件、然后再次进入等待后,会造成大量的上下文切换和资源浪费。这种现象被称为**“惊群效应”(Thundering Herd)**。

为了实现更精准的通知,我们可以使用java.util.concurrent.locks包下的ReentrantLockCondition 8。

ReentrantLocksynchronized的“Pro Max”版本,功能更强大。它最关键的特性之一就是可以创建多个Condition对象。我们可以为“缓冲区不满”和“缓冲区不空”这两个条件分别创建Condition 11。

  • 生产者在缓冲区满时,在notFull这个Condition上等待(await())。
  • 消费者在缓冲区空时,在notEmpty这个Condition上等待(await())。

当生产者生产了一个物品后,它只需要精准地唤醒一个可能在等待的消费者,即在notEmpty上调用signal()。反之亦然。

【改造方案】 :使用ReentrantLock和两个Condition重构代码。

第五站:登顶“钻石”——实现生产级的优雅关闭

到目前为止,我们的生产者和消费者都还在无限循环中裸奔。在真实应用中,我们必须能够干净利落地停止它们,否则会导致资源泄露,甚至JVM无法正常退出。

优化点4:优雅关闭——中断机制与“毒丸”策略

一个常见的错误是使用volatile boolean标志位来控制循环。这种方法的致命弱点是,如果线程正处于wait()await()等阻塞状态,它根本无法检查到标志位的变化,导致关闭请求被无限期延迟 14。

正确的方式是使用Java的协作式中断机制 15。

  1. 中断生产者:通过调用producerThread.interrupt()来请求生产者停止。生产者的循环条件应改为while (!Thread.currentThread().isInterrupted())
  2. 处理InterruptedException:当一个线程在await()sleep()时被中断,会抛出InterruptedException千万不要“吞掉”这个异常!正确的做法是在catch块中,调用Thread.currentThread().interrupt()恢复中断状态。这是一个至关重要的最佳实践,它确保了上层代码能知道这个线程被中断过 15。
  3. 消费者的“毒丸”(Poison Pill) :仅仅中断消费者是不够的,因为缓冲区里可能还有未处理的数据。正确的做法是,在停止生产者后,向缓冲区中放入一个特殊的“信号”对象,我们称之为“毒丸”。消费者像处理正常数据一样取出这个对象,当它发现是“毒丸”时,就知道所有数据都已处理完毕,可以安全退出了 15。

终极王者:一份可以写入教科书的代码

好了,集齐了所有优化点,我们来看看最终的“王者”版本。

Java

import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

// 共享资源类
class SharedBuffer<T> {
    private final List<T> buffer = new LinkedList<>();
    private final int capacity;
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public SharedBuffer(int capacity) {
        this.capacity = capacity;
    }

    public void produce(T item) throws InterruptedException {
        lock.lock();
        try {
            while (buffer.size() == capacity) {
                System.out.println("Buffer is full, producer is waiting...");
                notFull.await();
            }
            buffer.add(item);
            System.out.println("Produced: " + item);
            notEmpty.signal(); // 精准唤醒消费者
        } finally {
            lock.unlock();
        }
    }

    public T consume() throws InterruptedException {
        lock.lock();
        try {
            while (buffer.isEmpty()) {
                System.out.println("Buffer is empty, consumer is waiting...");
                notEmpty.await();
            }
            T item = buffer.remove(0);
            System.out.println("Consumed: " + item);
            notFull.signal(); // 精准唤醒生产者
            return item;
        } finally {
            lock.unlock();
        }
    }
}

// 生产者任务
class ProducerTask implements Runnable {
    private final SharedBuffer<Integer> buffer;

    public ProducerTask(SharedBuffer<Integer> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        try {
            for (int i = 0;!Thread.currentThread().isInterrupted(); i++) {
                buffer.produce(i);
                TimeUnit.MILLISECONDS.sleep(50); // 模拟生产耗时
            }
        } catch (InterruptedException e) {
            System.out.println("Producer interrupted.");
            // 关键:恢复中断状态
            Thread.currentThread().interrupt();
        }
        System.out.println("Producer finished its work.");
    }
}

// 消费者任务
class ConsumerTask implements Runnable {
    private final SharedBuffer<Object> buffer;
    private final Object poisonPill;

    public ConsumerTask(SharedBuffer<Object> buffer, Object poisonPill) {
        this.buffer = buffer;
        this.poisonPill = poisonPill;
    }

    @Override
    public void run() {
        try {
            while (true) {
                Object item = buffer.consume();
                if (item == poisonPill) {
                    System.out.println("Consumer received poison pill. Shutting down.");
                    // 将毒丸放回,以便其他消费者也能收到
                    buffer.produce(poisonPill);
                    break;
                }
                // 模拟消费
                System.out.println("Processing item: " + item);
                TimeUnit.MILLISECONDS.sleep(100);
            }
        } catch (InterruptedException e) {
            System.out.println("Consumer interrupted.");
            Thread.currentThread().interrupt();
        }
    }
}

// 主程序,负责启动和关闭
public class ProducerConsumerV5 {
    public static void main(String args) throws InterruptedException {
        final int NUM_CONSUMERS = 2;
        final Object POISON_PILL = new Object();
        SharedBuffer<Object> buffer = new SharedBuffer<>(5);
        ExecutorService executor = Executors.newFixedThreadPool(NUM_CONSUMERS + 1);

        // 启动生产者
        executor.submit(new ProducerTask((SharedBuffer) buffer));

        // 启动消费者
        for (int i = 0; i < NUM_CONSUMERS; i++) {
            executor.submit(new ConsumerTask(buffer, POISON_PILL));
        }

        // 模拟运行一段时间
        TimeUnit.SECONDS.sleep(3);

        System.out.println("Shutting down...");
        executor.shutdownNow(); // 中断所有线程

        // 放入毒丸,确保消费者能正常退出
        buffer.produce(POISON_PILL);
    }
}

最后的忠告:不要重复造轮子

我们费了这么大劲,从青铜一路打到王者,实现了一个相当完善的生产者-消费者模型。但这整个过程也恰恰证明了:在真实世界的项目中,请优先使用java.util.concurrent包中提供的高级并发工具,尤其是BlockingQueue

ArrayBlockingQueueLinkedBlockingQueue这些类,已经由Java的顶尖专家为你封装好了所有复杂的细节:线程安全、阻塞逻辑、中断响应等等。我们手动实现的这一切,都是为了更深刻地理解这些高级工具背后的原理。

彩蛋:另一道经典的并发面试题

掌握了前面的知识,我们再来看一道异曲同工的面试题,练练手。

面试题: 编写一个程序,开启3个线程,这三个线程的ID分别为A、B、C,每个线程将自己的ID在屏幕上打印10遍,要求输出的结果必须按顺序显示。如:ABCABCABC...

解题思路: 这本质上是一个精确的线程调度问题。我们可以使用一个ReentrantLock来保证互斥,然后用三个Condition对象,分别代表A、B、C三个线程的执行权。每个线程在执行完自己的任务后,唤醒下一个线程,然后自己在对应的Condition上等待。

参考答案:

Java

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class AlternatePrint {
    public static void main(String args) {
        AlternateDemo alternateDemo = new AlternateDemo();
        new Thread(() -> {
            for (int i = 1; i <= 10; i++) {
                alternateDemo.loopA(i);
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 1; i <= 10; i++) {
                alternateDemo.loopB(i);
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 1; i <= 10; i++) {
                alternateDemo.loopC(i);
            }
        }, "C").start();
    }
}

class AlternateDemo {
    // 1:A, 2:B, 3:C
    private int number = 1;
    private Lock lock = new ReentrantLock();
    private Condition conditionA = lock.newCondition();
    private Condition conditionB = lock.newCondition();
    private Condition conditionC = lock.newCondition();

    public void loopA(int round) {
        lock.lock();
        try {
            // 1. 判断
            while (number!= 1) {
                conditionA.await();
            }
            // 2. 打印
            System.out.print(Thread.currentThread().getName());
            // 3. 唤醒
            number = 2;
            conditionB.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void loopB(int round) {
        lock.lock();
        try {
            // 1. 判断
            while (number!= 2) {
                conditionB.await();
            }
            // 2. 打印
            System.out.print(Thread.currentThread().getName());
            // 3. 唤醒
            number = 3;
            conditionC.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    
    public void loopC(int round) {
        lock.lock();
        try {
            // 1. 判断
            while (number!= 3) {
                conditionC.await();
            }
            // 2. 打印
            System.out.print(Thread.currentThread().getName());
            // 3. 唤醒
            number = 1;
            conditionA.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

总结

从一个简单的生产者-消费者模型出发,我们探讨了虚假唤醒、数据结构选择、锁与信令机制、优雅停机等一系列并发编程的核心知识点。希望这趟从青铜到王者的“升级”之旅,能让你对Java并发有更深刻的理解。下次面试再遇到类似问题,你就可以自信地秀出这套“王炸”了!