为获得良好的阅读体验,请访问原文: 传送门
前序文章
- 高并发编程学习(1)——并发基础 - https://www.wmyskxz.com/2019/11/26/gao-bing-fa-bian-cheng-xue-xi-1-bing-fa-ji-chu/
一、经典的生产者消费者案例
上一篇文章我们提到一个应用可以创建多个线程去执行不同的任务,如果这些任务之间有着某种关系,那么线程之间必须能够通信来协调完成工作。
生产者消费者问题(英语:Producer-consumer problem)就是典型的多线程同步案例,它也被称为有限缓冲问题(英语:Bounded-buffer problem)。该问题描述了共享固定大小缓冲区的两个线程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。(摘自维基百科:生产者消费者问题)
-
注意: 生产者-消费者模式中的内存缓存区的主要功能是数据在多线程间的共享,此外,通过该缓冲区,可以缓解生产者和消费者的性能差;
准备基础代码:无通信的生产者消费者
我们来自己编写一个例子:一个生产者,一个消费者,并且让他们让他们使用同一个共享资源,并且我们期望的是生产者生产一条放到共享资源中,消费者就会对应地消费一条。
我们先来模拟一个简单的共享资源对象:
public class ShareResource {
private String name;
private String gender;
/**
* 模拟生产者向共享资源对象中存储数据
*
* @param name
* @param gender
*/
public void push(String name, String gender) {
this.name = name;
this.gender = gender;
}
/**
* 模拟消费者从共享资源中取出数据
*/
public void popup() {
System.out.println(this.name "-" this.gender);
}
}
然后来编写我们的生产者,使用循环来交替地向共享资源中添加不同的数据:
public class Producer implements Runnable {
private ShareResource shareResource;
public Producer(ShareResource shareResource) {
this.shareResource = shareResource;
}
@Override
public void run() {
for (int i = 0; i < 50; i ) {
if (i % 2 == 0) {
shareResource.push("凤姐", "女");
} else {
shareResource.push("张三", "男");
}
}
}
}
接着让我们的消费者不停地消费生产者产生的数据:
public class Consumer implements Runnable {
private ShareResource shareResource;
public Consumer(ShareResource shareResource) {
this.shareResource = shareResource;
}
@Override
public void run() {
for (int i = 0; i < 50; i ) {
shareResource.popup();
}
}
}
然后我们写一段测试代码,来看看效果:
public static void main(String[] args) {
// 创建生产者和消费者的共享资源对象
ShareResource shareResource = new ShareResource();
// 启动生产者线程
new Thread(new Producer(shareResource)).start();
// 启动消费者线程
new Thread(new Consumer(shareResource)).start();
}
我们运行发现出现了诡异的现象,所有的生产者都似乎消费到了同一条数据:
张三-男
张三-男
....以下全是张三-男....
为什么会出现这样的情况呢?照理说,我的生产者在交替地向共享资源中生产数据,消费者也应该交替消费才对呀..我们大胆猜测一下,会不会是因为消费者是直接循环了 30 次打印共享资源中的数据,而此时生产者还没有来得及更新共享资源中的数据,消费者就已经连续打印了 30 次了,所以我们让消费者消费的时候以及生产者生产的时候都小睡个 10 ms 来缓解消费太快 or 生产太快带来的影响,也让现象更明显一些:
/**
* 模拟生产者向共享资源对象中存储数据
*
* @param name
* @param gender
*/
public void push(String name, String gender) {
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {
}
this.name = name;
this.gender = gender;
}
/**
* 模拟消费者从共享资源中取出数据
*/
public void popup() {
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {
}
System.out.println(this.name "-" this.gender);
}
再次运行代码,发现了出现了以下的几种情况:
-
重复消费:消费者连续地出现两次相同的消费情况(张三-男/ 张三-男);
-
性别紊乱:消费者消费到了脏数据(张三-女/ 凤姐-男);
分析出现问题的原因
-
重复消费:我们先来看看重复消费的问题,当生产者生产出一条数据的时候,消费者正确地消费了一条,但是当消费者再来共享资源中消费的时候,生产者还没有准备好新的一条数据,所以消费者就又消费到老数据了,这其中的根本原因是生产者和消费者的速率不一致。
-
性别紊乱:再来分析第二种情况。不同于上面的情况,消费者在消费第二条数据时,生产者也正在生产新的数据,但是尴尬的是,生产者只生产了一半儿(也就是该执行完
this.name = name
),也就是还没有来得及给 gender
赋值就被消费者给取走消费了.. 造成这样情况的根本原因是没有保证生产者生产数据的原子性。
解决出现的问题
加锁解决性别紊乱
我们先来解决性别紊乱,也就是原子性的问题吧,上一篇文章里我们也提到了,对于这样的原子性操作,解决方法也很简单:加锁。稍微改造一下就好了:
/**
* 模拟生产者向共享资源对象中存储数据
*
* @param name
* @param gender
*/
synchronized public void push(String name, String gender) {
this.name = name;
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {
}
this.gender = gender;
}
/**
* 模拟消费者从共享资源中取出数据
*/
synchronized public void popup() {
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {
}
System.out.println(this.name "-" this.gender);
}
- 我们在方法前面都加上了
synchronized
关键字,来保证每一次读取和修改都只能是一个线程,这是因为当 synchronized
修饰在普通同步方法上时,它会自动锁住当前实例对象,也就是说这样改造之后读/ 写操作同时只能进行其一;
- 我把
push
方法小睡的代码改在了赋值 name
和 gender
的中间,以强化验证原子性操作是否成功,因为如果不是原子性的话,就很可能出现赋值 name
还没赋值给 gender
就被取走的情况,小睡一会儿是为了加强这种情况的出现概率(可以试着把 synchronized
去掉看看效果);
运行代码后发现,并没有出现性别紊乱的现象了,但是重复消费仍然存在。
等待唤醒机制解决重复消费
我们期望的是 张三-男
和 凤姐-女
交替出现,而不是有重复消费的情况,所以我们的生产者和消费者之间需要一点沟通,最容易想到的解决方法是,我们新增加一个标志位,然后在消费者中使用 while
循环判断,不满足条件则不消费,条件满足则退出 while
循环,从而完成消费者的工作。
while (value != desire) {
Thread.sleep(10);
}
doSomething();
这样做的目的就是为了防止「过快的无效尝试」,这种方法看似能够实现所