【并发编程系列】
❤️并发编程❤️创建线程的四种方式 线程通信
❤️并发编程❤️一万字线程生命周期和状态转换知识梳理
❤️并发编程❤️Java内存模型
❤️并发编程❤️重排序与happens-before
❤️并发编程❤️显式锁Lock和内置锁知识整理
❤️并发编程❤️如何正确停止线程?
❤️并发编程❤️生产者消费者模式实现的三种方式
(1)模式介绍
生产者消费者问题,也称有限缓冲问题,是一个多进程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个进程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生 产线程和消费线程的工作能力来提高程序整体处理数据的速度。
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发 中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理 完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须 等待生产者。为了解决这种生产消费能力不均衡的问题,便有了生产者和消费者模式。
生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消 费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用 等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取, 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
(2)基于BlockingQueue 实现生产者消费者模式
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
/***
*
* 使用阻塞队列BlockingQueue实现生产者消费者模式
* @author ZhangYu
* @date 2021/10/3
*/
public class Main {
public static void main(String[] args) throws InterruptedException {
//创建一个阻塞队列,容量为8
BlockingQueue<Double> queue= new ArrayBlockingQueue<>(8);
//创建生产者线程
Runnable producer=()->{
while (true){
try {
double random = Math.random();
queue.put(random);
System.out.println("生产数据==>"+random);
} catch (InterruptedException e) {
e.printStackTrace();
}
}};
new Thread(producer).start();
//创建消费者线程
Runnable consumer=()->{
while (true){
try {
System.out.println("消费数据==>"+queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}};
new Thread(consumer).start();
}
}
这种方式实现起来最简单也最实用,直接利用JUC包所提供的阻塞队列工具来帮助我们实现。创建了一个 ArrayBlockingQueue 类型的 BlockingQueue,命名为 queue 并将它的容量设置为 8;其次,创建一个简单的生产者,while(true) 循环体中的queue.put() 负责往队列添加数据;然后,创建两个生产者线程并启动;同样消费者也非常简单,while(true) 循环体中的 queue.take() 负责消费数据,同时创建两个消费者线程并启动。
这种方式,ArrayBlockingQueue 已经为我们实现了很多阻塞通知的代码功能,比如下面的put的方法,可以看到其中实用notFull的条件变量来实现生产者队列满时阻塞的机制,这里不再细说,后面会分析
(3)用 Condition 实现生产者消费者模式
BlockingQueue 实现生产者消费者模式看似简单,背后却暗藏玄机,我们在掌握这种方法的基础上仍需要掌握更复杂的实现方法。BlockingQueue 的基础上利用 Condition 实现生产者消费者模式,它们背后的实现原理非常相似,下面就使用Condition 来参考BlockingQueue 实现自定义的简化版的阻塞队列
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/***
*
* 自定义阻塞队列
* @author ZhangYu
* @date 2021/10/3
*/
public class MyBlockingQueue<E> {
/** 队列容器 */
private final Queue queue;
/** 队列容量 */
private final int capacity;
/** 对象锁 */
final ReentrantLock lock;
/** 等待取出数据条件 */
private final Condition notEmpty;
/** 等待添加数据条件 */
private final Condition notFull;
/**
* 初始化阻塞队列
* @param capacity 队列容量
* @param fair 是否公平锁
*/
public MyBlockingQueue(int capacity, boolean fair) {
this.queue = new LinkedList();
this.capacity=capacity;
this.lock = new ReentrantLock(fair);
this.notEmpty = lock.newCondition();
this.notFull = lock.newCondition();
}
/**
* 往队列插入元素,如果队列大小到达容量限制则阻塞
* @param e 插入元素
* @throws InterruptedException 中断异常
*/
public void put(E e) throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lock();
try{
while (queue.size()==capacity){
notFull.await();
}
queue.add(e);
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
/**
* 从队列取出一个元素,如果队列为空则阻塞
* @return 队列元素
* @throws InterruptedException 中断异常
*/
public E take()throws InterruptedException{
final ReentrantLock lock = this.lock;
lock.lock();
try{
while (queue.size()==0){
notEmpty.await();
}
E element = (E) queue.remove();
notFull.signalAll();
return element;
} finally {
lock.unlock();
}
}
}
定义了一个 ReentrantLock 类型的 Lock 锁,并在 Lock 锁的基础上创建两个 Condition,一个是 notEmpty,另一个是 notFull,分别代表队列没有空和没有满的条件;最后,声明了 put 和 take 这两个核心方法。
因为生产者消费者模式通常是面对多线程的场景,需要一定的同步措施保障线程安全,所以在 put 方法中先将 Lock 锁上,然后,在 while 的条件里检测 queue 是不是已经满了,如果已经满了,则调用 notFull 的 await() 阻塞生产者线程并释放 Lock,如果没有满,则往队列放入数据并利用 notEmpty.signalAll() 通知正在等待的所有消费者并唤醒它们。最后在 finally 中利用 lock.unlock() 方法解锁,把 unlock 方法放在 finally 中是一个基本原则,否则可能会产生无法释放锁的情况。
take 方法实际上是与 put 方法相互对应的,同样是通过 while 检查队列是否为空,如果为空,消费者开始等待,如果不为空则从队列中获取数据并通知生产者队列有空余位置,最后在 finally 中解锁。
【测试代码】
public class Main {
public static void main(String[] args) {
//创建一个阻塞队列,容量为8
MyBlockingQueue<Double> queue= new MyBlockingQueue<>(8,false);
//创建生产者线程
Runnable producer=()->{
while (true){
try {
double random = Math.random();
queue.put(random);
System.out.println("生产数据==>"+random);
} catch (InterruptedException e) {
e.printStackTrace();
}
}};
new Thread(producer).start();
//创建消费者线程
Runnable consumer=()->{
while (true){
try {
System.out.println("消费数据==>"+queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}};
new Thread(consumer).start();
}
}
(4)wait/notify 实现生产者消费者模式
import java.util.LinkedList;
/***
*
* 自定义阻塞队列
* @author ZhangYu
* @date 2021/10/3
*/
public class MyBlockingQueue {
/** 容器允许存放的最大数量 **/
private final int maxSize;
/** 容器 **/
private final LinkedList<Double> container;
public MyBlockingQueue(int maxSize ) {
this.maxSize = maxSize;
this.container = new LinkedList<>();
}
/**
* 往队列添加元素,如果队列已满则阻塞线程
*/
public synchronized void put(Double data){
//如果队列已满,则阻塞生产者线程
while (container.size()==maxSize){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//队列未满则添加元素,并通知消费者消费数据
container.add(data);
notifyAll();
}
/**
* 从队列取出数据,如果队列为空则阻塞
* @return 队列元素
*/
public synchronized Double take(){
//如果队列为空,则消费者停止消费
while (container.size()==0){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//队列不为空则消费数据,并通知生产者继续生产数据
Double data = container.poll();
notifyAll();
return data;
}
}
最主要的部分仍是 take 与 put 方法,我们先来看 put 方法,put 方法被 synchronized 保护,while 检查队列是否为满,如果不满就往里放入数据并通过 notifyAll() 唤醒其他线程。同样,take 方法也被 synchronized 修饰,while 检查队列是否为空,如果不为空就获取数据并唤醒其他线程
注意代码中 notify 的位置,并不是等到队列满或空了才唤醒对方,而是每生产或消费一次都会唤醒对方,即生产者与消费者交替唤醒。当然,这并不意味着交替运行,真正的运行时机由线程获取时间片决定。
【测试代码】
public static void main(String[] args) {
//创建一个阻塞队列,容量为8
MyBlockingQueue queue= new MyBlockingQueue(8);
//创建生产者线程
Runnable producer=()->{
while (true){
double random = Math.random();
queue.put(random);
System.out.println("生产数据==>"+random);
}};
new Thread(producer).start();
//创建消费者线程
Runnable consumer=()->{
while (true){
System.out.println("消费数据==>"+queue.take());
}};
new Thread(consumer).start();
}
(5)注意事项
不建议使用 while (container.size()==0){}或者 if (container.size()==maxSize),而应该使用while( queue.size() == 0 )循环的方式
这是因为生产者消费者往往是多线程的,我们假设有两个消费者,第一个消费者线程获取数据时,发现队列为空,便进入等待状态;因为第一个线程在等待时会释放 Lock 锁,所以第二个消费者可以进入并执行 if( queue.size() == 0 ),也发现队列为空,于是第二个线程也进入等待;而此时,如果生产者生产了一个数据,便会唤醒两个消费者线程,而两个线程中只有一个线程可以拿到锁,并执行 queue.remove 操作,另外一个线程因为没有拿到锁而卡在被唤醒的地方,而第一个线程执行完操作后会在 finally 中通过 unlock 解锁,而此时第二个线程便可以拿到被第一个线程释放的锁,继续执行操作,也会去调用 queue.remove 操作,然而这个时候队列已经为空了,所以会抛出 NoSuchElementException 异常,这不符合我们的逻辑。而如果用 while 做检查,当第一个消费者被唤醒得到锁并移除数据之后,第二个线程在执行 remove 前仍会进行 while 检查,发现此时依然满足 queue.size() == 0 的条件,就会继续执行 await 方法,避免了获取的数据为 null 或抛出异常的情况。