面试官:手写个生产者消费者模式?我反手就是一套王炸!
前言:一次面试引发的“血案”
“同学,看你简历上写着熟悉Java并发编程,那我们来聊聊生产者-消费者模式吧。不许用BlockingQueue,就用一个普通的List当容器,你来实现一下。”
上周,我的一个朋友在面试时就遇到了这道“送命题”。这道题看似简单,实则暗藏玄机,考察的不仅仅是多线程的基础,更是对并发编程底层原理、性能优化和代码健壮性的深度理解。
他当时给出了一个基础的synchronized版本,勉强过关,但面试官后续的追问让他捉襟见肘:“你这个实现有什么问题?怎么优化?ArrayList和LinkedList该用哪个?如何优雅地关闭这些线程?”
痛定思痛,我决定把这个问题彻底搞明白,从一个最基础的版本开始,一步步重构、优化,直到打磨出一个接近“生产级”的实现。如果你也想在面试官面前秀一把操作,那么请系好安全带,我们发车了!
第一站:能跑就行的“青铜”版本
我们先从一个最基础的版本开始,这也是很多人(包括我朋友)在面试时第一反应能写出来的版本。它严格遵守了题目要求:使用List作为容器,并利用Java最基础的synchronized、wait()和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的实现类ArrayList和LinkedList,哪个性能更好?
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:告别“惊群效应”,拥抱ReentrantLock与Condition
在我们的基础版本中,无论是生产者还是消费者,每次操作后都粗暴地调用notifyAll()。这会唤醒所有正在等待的线程,包括那些条件根本不满足的线程(比如,一个生产者唤醒了其他生产者)。这些被“误伤”的线程在徒劳地竞争锁、检查条件、然后再次进入等待后,会造成大量的上下文切换和资源浪费。这种现象被称为**“惊群效应”(Thundering Herd)**。
为了实现更精准的通知,我们可以使用java.util.concurrent.locks包下的ReentrantLock和Condition 8。
ReentrantLock是synchronized的“Pro Max”版本,功能更强大。它最关键的特性之一就是可以创建多个Condition对象。我们可以为“缓冲区不满”和“缓冲区不空”这两个条件分别创建Condition 11。
- 生产者在缓冲区满时,在
notFull这个Condition上等待(await())。 - 消费者在缓冲区空时,在
notEmpty这个Condition上等待(await())。
当生产者生产了一个物品后,它只需要精准地唤醒一个可能在等待的消费者,即在notEmpty上调用signal()。反之亦然。
【改造方案】 :使用ReentrantLock和两个Condition重构代码。
第五站:登顶“钻石”——实现生产级的优雅关闭
到目前为止,我们的生产者和消费者都还在无限循环中裸奔。在真实应用中,我们必须能够干净利落地停止它们,否则会导致资源泄露,甚至JVM无法正常退出。
优化点4:优雅关闭——中断机制与“毒丸”策略
一个常见的错误是使用volatile boolean标志位来控制循环。这种方法的致命弱点是,如果线程正处于wait()或await()等阻塞状态,它根本无法检查到标志位的变化,导致关闭请求被无限期延迟 14。
正确的方式是使用Java的协作式中断机制 15。
- 中断生产者:通过调用
producerThread.interrupt()来请求生产者停止。生产者的循环条件应改为while (!Thread.currentThread().isInterrupted())。 - 处理
InterruptedException:当一个线程在await()或sleep()时被中断,会抛出InterruptedException。千万不要“吞掉”这个异常!正确的做法是在catch块中,调用Thread.currentThread().interrupt()来恢复中断状态。这是一个至关重要的最佳实践,它确保了上层代码能知道这个线程被中断过 15。 - 消费者的“毒丸”(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!
ArrayBlockingQueue和LinkedBlockingQueue这些类,已经由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并发有更深刻的理解。下次面试再遇到类似问题,你就可以自信地秀出这套“王炸”了!