前言:
面试常问:单例模式,排序算法,生产者和消费者问题,死锁问题
解决生产者消费者问题,我们可以使用synchronized或者Lock锁来解决,这就是我们今天所要学习和讨论的问题!
1.传统的生产者消费者问题
1.1 生产者消费者简单了解
1-1 什么是生产者消费者问题?
本质上是 线程之间的通信问题 (即等待唤醒和通知唤醒)
例如线程交替执行, A、B操作同一个变量 num=0, A: num+1, B: num+1
1-2 生产者消费者实现步骤
生产者,消费者问题三步走:
- 判断是否进入等待
- 执行相关业务
- 通知其他线程
1.2 生产者消费者问题具体实现
这里,我们将生产者和消费者问题转换成银行账户存钱和取钱问题,并进行两轮测试,第一轮 测试只存在AB两个线程,即A存钱,B取钱;第二轮测试存在ABCD四个线程,即A存钱,BCD取钱,分别观察各自执行结果如何
2-1 只存在AB两个线程
在AB两个线程中,A负责存钱,而B负责取钱
- 代码实现
package com.kuang.lock;
/**
* @Description 生产者消费者问题
* 只有AB两个线程
* @Author 狂奔の蜗牛rz
* @Date 2021/7/23
*/
public class ProducerConsumerTest {
public static void main(String[] args) {
//获取Money资源类
Money money = new Money();
//A线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//存钱
money.saveMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小A").start();
//B线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小B").start();
}
}
//资源类:Money
class Money {
//钱的余额
private int number = 0;
//只要是并发编程就一定要有锁
//存钱(使用synchronized关键字进行加锁)
public synchronized void saveMoney() throws InterruptedException {
if (number != 0) {
//如果余额不为0,则该线程进入等待
this.wait();
}
//否则余额加1
number++;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"取出后,剩余"+number+"000元");
//通知其他的线程,已经存好钱了
this.notifyAll();
}
//取钱
public synchronized void drawMoney() throws InterruptedException {
//判断余额是否为0
if (number == 0) {
//如果余额为0,则该线程进行等待
this.wait();
}
//否则余额减1
number--;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"取出后,剩余"+number+"000元");
//通知其他线程,已经取完钱了
this.notifyAll();
}
}
-
测试结果:
结果:存入和取出后的余额正常,符合我们的预期!
2-2 同时存在ABCD四个线程
在ABCD四个线程中,A负责存钱,而BCD负责取钱
- 代码实现:
package com.kuang.lock;
/**
* @Description 生产者消费者问题
* 存在ABCD四个线程
* @Author 狂奔の蜗牛rz
* @Date 2021/7/23
*/
public class ProducerConsumerTest {
public static void main(String[] args) {
//获取Money资源类
Money money = new Money();
//A线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//存钱
money.saveMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小A").start();
//B线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小B").start();
//C线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小C").start();
//D线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小D").start();
}
}
//资源类:Money
class Money {
//钱的余额
private int number = 0;
//只要是并发编程就一定要有锁
//存钱(使用synchronized关键字进行加锁)
public synchronized void saveMoney() throws InterruptedException {
if (number != 0) {
//如果余额不为0,则该线程进入等待
this.wait();
}
//否则余额加1
number++;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"存入"+number+"000元");
//通知其他的线程,已经存好钱了
this.notifyAll();
}
//取钱
public synchronized void drawMoney() throws InterruptedException {
//判断越是否为0
if (number == 0) {
//如果余额为0,则该线程进行等待
this.wait();
}
//否则余额减1
number--;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"取出"+number+"000元");
//通知其他线程,已经取完钱了
this.notifyAll();
}
}
- 测试结果:
结果:数据出现混乱,余额出现负数,这显然是不符合我们预期的!
结果分析:
我们发现在C取钱后,余额为零时,B没有等待A存钱,仍然继续取钱,所以余额出现了负数,那么为什么会出现这种情况呢,这是因为出现了虚假唤醒!
1.3 解决虚假唤醒问题
3-1 什么是虚假唤醒?
在JDK 8 的API文档中,是这样解释的:
所谓虚假唤醒,就是线程可以被唤醒,而不会被通知、中断或者超时
解决方案:
通过JDK 8 API 文档我们可以得知,等待应该总是出现在while循环中,因此我们可以将if判断修改为while循环
3-2 使用while循环解决虚假唤醒问题
- 代码实现
package com.kuang.lock;
/**
* @Description 生产者消费者问题
* 存在ABCD四个线程
* @Author 狂奔の蜗牛rz
* @Date 2021/7/23
*/
public class ProducerConsumerTest {
public static void main(String[] args) {
//获取Money资源类
Money money = new Money();
//A线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//存钱
money.saveMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小A").start();
//B线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小B").start();
//C线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小C").start();
//D线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小D").start();
}
}
//资源类:Money
class Money {
//钱的余额
private int number = 0;
//只要是并发编程就一定要有锁
//存钱(使用synchronized关键字进行加锁)
public synchronized void saveMoney() throws InterruptedException {
while (number != 0) {
//如果余额不为0,则该线程进入等待
this.wait();
}
//否则余额加1
number++;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"存入"+number+"000元");
//通知其他的线程,已经存好钱了
this.notifyAll();
}
//取钱
public synchronized void drawMoney() throws InterruptedException {
//判断余额是否为0
while (number == 0) {
//如果余额为0,则该线程进行等待
this.wait();
}
//否则余额减1
number--;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"取出"+number+"000元");
//通知其他线程,已经取完钱了
this.notifyAll();
}
}
- 测试结果:
结果:余额符合预期结果,没有出现负数了!
3-3 为什么不能使用if而要使用while?
由于我们对使用wait方法后进入休眠的线程被唤醒时的执行位置有误解,就会认为这两种方式的执行顺序似乎相同;
以saveMoney方法为例:
如果使用if进行条件判断 (即判断余额是否等于0), 若条件不满足 (即等于0),当前线程调用wait()方法进行等待,若当前线程被其他线程唤醒,不会重新进行条件判断,而是直接执行number++(余额加一操作);
如果使用while循环进行条件判断,虽然还是直接执行number++ (余额加一操作),但是当前线程被唤醒后,会重新进行条件判断,如果条件不成立(即剩余数量等于0),就会继续进入等待
总结:使用if判断线程唤醒后不会重新进行条件判断,而使用while判断线程唤醒后会重新进行条件判断
2.使用Lock锁解决生产者消费者问题
要使用Lock锁解决生产者消费者问题,需要调用newCondition()方法,那么什么是newCondition呢?
2.1 newCondition方法和Condition接口
1-1 newCondition方法是什么?
在 JDK8 的API文档中,我们可以看到如下解释:
newCondition()方法是指返回一个新的Condition实例,绑定到当前Lock对象; 在等待条件之前,锁必须由当前线程保持,呼叫Condition实例的await()方法后,将在等待之前将原子锁释放,并且在等待返回之前重新获取到锁
文档中还提到注意事项:Condition实例的确切操作取决于Lock锁实现,必须由该实现记录 (意思应该是Lock锁会记录Condition实例的状态)
提到了newCondition()方法,这里反复出现了一个Condition实例,那么这个Condition实例究竟是指什么呢?
在查看了API文档后,我们发现,这个Condition实例本质上是一个接口
它有两个 ConditionObject 实现类,其中一个来自AbstractQueuedLongSynchronizer,另一个来自AbstractQueuedLongSynchronizer
1-2 什么是condition接口?
Condition接口包含了Object对象的一些监视器方法 (包括wait、notify和notifyAll方法等) ,以得到具有多个等待集的对象,通过将它们进行任意的组合来实现Lock锁的效果;使用Lock锁替换 synchronized方法和语句,使用Condition条件来取代对象监视器方法
注意:由于这个JDK文档的翻译有些问题,所以有些不通的地方是为根据自己的理解进行修改的
文档中最后还特别补充:
一个Condition实例本质上绑定到一个锁,要获得特定Condition实例,请使用其newCondition()方法
2.2 Condition接口的具体使用
2-1 Condition实例使用官方案例
API文档中给出的Condition使用案例:
从该使用案例中我们可以得知:
要想使用Condition实例 (条件),我们首先需要获取一个ReentrantLock对象(重入锁),然后通过调用重入锁的newCondition()方法来获取Condition实例
2-4 使用Lock实现生产者消费者
看完了官方给出的案例,我们把前面使用传统的synchronized同步锁实现的生产者消费者问题,使用Lock锁来再实现一下,进一步体会两者的区别的各自的优势
- 代码实现:
package com.kuang.lock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @Description 生产者消费者问题(使用Lock锁实现)
* @Author 狂奔の蜗牛rz
* @Date 2021/7/24
*/
public class ProducerConsumerTest2 {
public static void main(String[] args) {
//获取Money资源类
Money2 money = new Money2();
//四个线程:A和C存钱,B和D取钱
//A线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//存钱
money.saveMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小A").start();
//B线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小B").start();
//C线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//存钱
money.saveMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小C").start();
//D线程
new Thread(()->{
for (int i = 0; i < 10; i++) {
try{
//取钱
money.drawMoney();
} catch (Exception e) {
e.printStackTrace();
}
}
},"小D").start();
}
}
//资源类:Money2
class Money2 {
//钱的余额
private int number = 0;
//获取一个重入锁对象实例lock
Lock lock= new ReentrantLock();
//获取Condition实例(通过调用lock锁的newCondition()方法获得)
Condition condition = lock.newCondition();
//存钱
public void saveMoney() throws InterruptedException {
//首先上锁
lock.lock();
//执行业务代码
try {
//判断余额是否不为0
while (number != 0) {
//如果条件满足(即余额为0),则该线程进入等待
condition.await();
}
//否则余额加1
number++;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"存入后,剩余"+number+"000元");
//通知所有其他线程(已经存好钱了)
condition.signalAll();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后解锁
lock.unlock();
}
}
//取钱
public void drawMoney() throws InterruptedException {
//首先上锁
lock.lock();
//执行业务代码
try {
//判断余额是否为0
while (number == 0) {
//如果条件满足(即余额为0),则该线程进行等待
condition.await();
}
//否则余额减1
number--;
//打印存钱信息
System.out.println(Thread.currentThread().getName()+"取出后,剩余"+number+"000元");
//通知所有其他线程(已经取完钱了)
condition.signalAll();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后解锁
lock.unlock();
}
}
}
- 测试结果:
结果:执行结果符合预期要求,没有出现余额为负数的情况!
2.3 结果分析和使用总结
3-1 结果分析
我们发现,虽然执行的结果符合预期要求,但是其执行结果并不是ABCD四个线程顺序执行,而是随机执行!
3-2 使用总结
- 在synchronized同步锁中有wait()方法 (等待) 和 notify()方法 (唤醒) ,而在Lock锁中有await()方法 (等待) 和 signal()方法 (唤醒) 方法,有异曲同工之妙!
- 因此任何一个新的技术,绝对不是覆盖了原来的技术,只是在其基础上做了改进和补充!
3. Condition实现精准通知唤醒
3.1 为什么要实现精准通知唤醒?
在前面,我们分别使用synchronized同步锁和ReentrantLock重入锁,实现了生产者消费者问题,但是我们发现其线程执行过程虽然没有出错,但是线程却是随机执行的;
如果我们能够控制线程顺序执行,就可以解决一些特殊的需求,例如在电商平台交易过 程:加入购物车->下单->支付->收货,接下来就让我们来使用线程模拟一下吧
3.2 如何实现精准唤醒?
我们可以使用condition来实现精准唤醒,例如在加入购物车的业务执行完后,步骤值加1,去唤醒步骤2,执行下单业务,以此类推,便可以实现我们的对顺序执行的需求!
3.3 Condition实现精准唤醒
3-1 代码实现
package com.kuang.lock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName ProducerConsumerTest3
* @Description 使用Condition实现精准唤醒
* 模拟场景:电商平台的交易过程:加入购物车->下单->支付->收货
* @Author 狂奔の蜗牛rz
* @Date 2021/7/27
*/
public class ProducerConsumerTest4 {
public static void main(String[] args) {
//获取资源对象实例trade
Trade trade = new Trade();
//模拟A B C D四个线程精准唤醒
//A线程
new Thread(()->{
//使用for循环模拟多次执行
for (int i = 0; i < 10; i++) {
//加入购物车
trade.addCard();
}
},"加入购物车").start();
//B线程
new Thread(()->{
//使用for循环模拟多次执行
for (int i = 0; i < 10; i++) {
//下单
trade.order();
}
},"下单").start();
//C线程
new Thread(()->{
//使用for循环模拟多次执行
for (int i = 0; i < 10; i++) {
//支付
trade.payment();
}
},"支付").start();
//D线程
new Thread(()->{
//使用for循环模拟多次执行
for (int i = 0; i < 10; i++) {
//收货
trade.receive();
}
},"收货").start();
}
}
//资源类:Trade
class Trade {
//设置初始步骤为1
private Integer step = 1;
//获取ReentrantLock(重入锁)对象的实例lock
private Lock lock = new ReentrantLock();
//获取Condition对象实例(通过lock锁的newCondition()方法)
//获取多个condition实例,相当于创建了一个等待队列(执行顺序为1A,2B,3C,4D)
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private Condition condition3 = lock.newCondition();
private Condition condition4 = lock.newCondition();
//步骤一:加入购物车
public void addCard() {
//首先上锁
lock.lock();
//执行业务代码(判断->执行->通知)
try {
//判断是否为步骤1
while(step != 1) {
//如果满足条件(即不为步骤1),则(线程A)进入等待
condition1.await();
}
//如果不满足条件(即是步骤1),则步骤值设置为2
step=2;
//打印输出结果
System.out.println(Thread.currentThread().getName()+"成功!");
//唤醒指定线程B(下单)
condition2.signal();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后锁定
lock.unlock();
}
}
//步骤二:下单
public void order() {
//首先上锁
lock.lock();
//执行业务代码(判断->执行->通知)
try {
//判断数量是否不为2
while(step != 2) {
//如果满足条件(即为不步骤2),则(线程B)进入等待
condition2.await();
}
//如果不满足条件(即为步骤2),则步骤值加1
step=3;
//打印输出结果
System.out.println(Thread.currentThread().getName()+"成功!");
//唤醒指定线程C(支付)
condition3.signal();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后锁定
lock.unlock();
}
}
//步骤三:支付
public void payment() {
//首先上锁
lock.lock();
//执行业务代码(判断->执行->通知)
try {
//判断是否为步骤3
while(step != 3) {
//如果满足条件(即步骤不为3),则(线程C)进入等待
condition3.await();
}
//如果不满足条件(即步骤为3),则步骤值加1
step=4;
//打印输出结果
System.out.println(Thread.currentThread().getName()+"成功!");
//唤醒指定线程D(收货)
condition4.signal();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后锁定
lock.unlock();
}
}
//步骤四:收货
public void receive() {
//首先上锁
lock.lock();
//执行业务代码(判断->执行->通知)
try {
//判断是否为步骤4
while(step != 4) {
//如果满足条件(即步骤不为4),则(线程C)进入等待
condition4.await();
}
//如果不满足条件(即步骤为4),则数量设置为3
step=1;
//打印输出结果
System.out.println(Thread.currentThread().getName()+"成功!");
//唤醒指定线程A(加入购物车)
condition1.signal();
//捕获异常
} catch (Exception e) {
e.printStackTrace();
} finally {
//最后锁定
lock.unlock();
}
}
}
3-2 测试结果
结果:按照我们预期的步骤顺序执行,实现了精准唤醒!
到这里,今天的有关生产者消费者问题的学习就结束了,欢迎大家学习和讨论!
参考视频链接:https://www.bilibili.com/video/BV1B7411L7tE (B站UP主遇见狂神说的JUC并发编程基础)