文章目录
前言 ´・ᴗ・`
继上一节我们学习了synchronized四种应用形式,这次我们要对真正的问题动手了——生产着消费者问题
我们会引入wait notify机制 之后,结合synchronize,利用管程法和信号灯法(其实就是一个标志位)来给出生产着消费者问题的两种solution
wait 与 notify
wait 和 notify都是 object类的native方法(native方法即是由JVM的C代码实现的),而正因为他是native方法,它也是final的, 即不可被override,否则会衍生出很多问题。
wait()的作用是使当前执行wait()方法的线程等待阻塞,进入等待队列,表现出来的效果就是,在wait()所在的代码行处暂停执行,并立即释放锁,直到接到notify通知或被interrupt中断(像我们之前说的,在阻塞状态的线程停止可以用interrupt中断来实现)。
notify()的作用是通知那些可能等待该锁的其他线程,如果有多个线程等待,则按照执行wait方法的顺序发出一次性通知(一次只能通知一个!),因此,自然是在等待队列中排第一的的线程获得锁,因为他被最先释放了,其他等待的线程只能在队列待着。因为notify只是通知,因此自然与当前线程释放锁与否毫无关系:)
notify 和 notifyAll
notify方法只唤醒一个处于等待阻塞状态的线程并使该线程开始执行。所以如果有多个线程等待一个资源,这个方法只会唤醒队列中的第一个线程,因为队列是先入先出,因此就是最早被等待阻塞的线程
而notifyAll 会唤醒所有等待的线程,因此常用的就是notifyAll 方法。
比如在生产者-消费者里面的使用,每次都需要唤醒所有的消费者或是生产者,以判断程序是否可以继续往下执行。
深入了解 阻塞
上面讲到wait的时候提到,他会使得线程阻塞,
奇怪了 还记得上一节我们提到的阻塞,似乎都与 “占着茅坑不拉屎”,被阻塞了还占用着资源,这些说法相关啊,这里的wait阻塞为啥反而是释放锁呢???
我们想想阻塞的定义:
阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行,经过一系列操作,直到线程进入就绪状态Runnable,才有机会转到运行状态Running。
阻塞更多的是种表象,你只能说CPU暂时不运行它了 而且他也不是在就绪状态,至于他是占用着锁,还是释放了锁,其实看情况的
具体来说,阻塞有三种:
等待阻塞 运行的线程执行wait()方法,JVM会把该线程放入等待队列中,同时值得注意的是,wait会释放持有的锁
同步阻塞 运行的线程在获取对象的同步锁时,也即是为了访问对象资源的时候,若该同步锁被别的线程抢先占用,则该线程被阻塞,JVM会把该线程放入锁池中。
其他阻塞 运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。值得注意的是,这样的阻塞,是不会释放持有的锁
很明显,第三种是很常见的,也是导致之前讨论过的诸多问题的阻塞,“经典的”阻塞不会释放锁。
线程的状态切换
我们通过状态图,来看看线程的状态变化是怎么样的:
我们的三种阻塞,其实分别走向了三个路径,
- 等待队列
- 锁池
- 阻塞状态
但是表面上看起来都是被阻塞的状态——CPU暂时不跑 而且不可运行(不在就绪态)
或许这些名词不够舒服 我们看另一张图:
这里说得很清楚,阻塞状态广义上包括:
- 等待阻塞(第一种 wait比如)
- 同步阻塞(第二种 synchronize)
- 其他阻塞(第三种sleep join IO)
另外,说明一下,之前之所以没有放这张图,是因为我们好多东西没学,放了看不懂,只会徒增烦恼额,而现在,我们学习了join和yield,也稍微了解了wait和notify 这个图什么意思我们心里都有数了。如果还不太能接受,可以看完后边的生产者消费者问题再回头看看。
生产者消费者模型
听起来好像很玄乎的样子 我们假设有三个角色,生产产品的人,消费产品的人,放产品的仓库,
生产者,把产品放到仓库里,并且仓库空间有限,所以满了就不放了呗,
消费者,从仓库中拿产品,并且产品有限,拿完了就没了,因此拿完了消费者也没法做事了
由于多线程,我们这个仓库要作为临界资源,即同时只能有一个线程访问之,否则会造成数据不安全。目前我们所知的就是使用Synchronized代码块来实现,所谓的同步访问
换言之,仓库满了,生产者线程应当阻塞,而且是等待阻塞,why?因为仓库满了 你生产者不应该占用仓库而不干事啊:)这时应当让消费者线程进去,消费产品。
但是这里有个问题了 消费者怎么知道 你啥时候仓库满了?所以这就需要所谓的线程通讯机制,我们之前学Synchronized是怎么保证线程安全,也就是实现同步访问,同时只能有一个线程能够修改临界资源,这里则需要的是线程之间的通讯与协调。
聪明的你应该能想到,为啥我们先介绍了奇怪的wait和notify -> 没错,wait / notify 正是一种线程通讯机制。
wait notify深入一点
前面我们说了,wait 方法使线程暂停运行,等待阻塞,而notify 方法通知所有处于等待队列的线程继续运行。
但是要想正确使用wait/notify,一定要注意四点:
-
wait/notify 调用,其object必须是临界资源,是有锁的,否则程序会抛出异常,也就调用不了wait/notify;
Why?其实答案很明显,如果,这个object压根就不是临界资源,那我等待阻塞什么?阻塞线程其实没有必要了,同样,如果这个object不是临界资源,那也没有所谓等待的队列,没有线程会因为非临界资源而被等待阻塞。 -
所以另外一个注意点也很简单,wait和notify必须配合Synchronized 或者其他能够使得object称为临界资源,能够给他上锁的机制来使用 这篇文章来说我们就是把wait和notify放在Synchronized代码块中的。
-
还有个注意点,如果wait和notify所服务的不是同一个object,即不是同一把锁,那也不起作用,wait阻塞的线程,进入队列之后,应当有与之对应的notify来通知唤醒!
最后,这点可能需要看下后边代码才能理解,就是在编程中,尽量在使用了notify/notifyAll() 后立即退出临界区,这样唤醒其他线程后,被唤醒的线程们可以立即获得锁。
为啥?比如正常来说,你用工具干完了,叫下一个人接着干,可如果你干到一半,活还没干完,你就唤醒人家,但是你自己又占着锁,这不是逗人家玩嘛(人家还是只能干等着,等待阻塞状态)。。
管程法
不知道这个名字啥意思,反正对于wait / notify 通讯机制,经典的一种应用方式就是管程法。思路也很简单,
首先生产者和消费者之间,要设立缓冲区,
生产者把产品放进去,检测到缓冲区的产品满了,就阻塞自己,
同样的,如果消费者消费的时候发现产品没了,也会阻塞自己确保库存不是负数
当然了 对生产者而言,要是产品没有满,应该怎么办呢?答案是唤醒别的线程(包括消费者和生产者)来消费
而反之消费者发现产品还没有空 他也会唤醒别的线程来生产。
管程法 仓库
仓库Storage类的代码如下:
import java.util.LinkedList;
public class Storage implements AbstractStorage {
//仓库最大容量
private final int MAX_SIZE = 100;
//仓库存储的载体
private LinkedList list = new LinkedList();
//生产产品
public void produce(int num){
//同步
synchronized (list){
//仓库剩余的容量不足以存放即将要生产的数量,暂停生产
while(list.size()+num > MAX_SIZE){
System.out.println("【要生产的产品数量】:" + num + "\t【库存量】:"
+ list.size() + "\t暂时不能执行生产任务!");
try {
//条件不满足,生产阻塞
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for(int i=0;i<num;i++){
list.add(new Object());
}
System.out.println("【已经生产产品数】:" + num + "\t【现仓储量为】:" + list.size());
list.notifyAll();
}
}
//消费产品
public void consume(int num){
synchronized (list){
//不满足消费条件
while(num > list.size()){
System.out.println("【要消费的产品数量】:" + num + "\t【库存量】:"
+ list.size() + "\t暂时不能执行生产任务!");
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//消费条件满足,开始消费
for(int i=0;i<num;i++){
list.remove();
}
System.out.println("【已经消费产品数】:" + num + "\t【现仓储量为】:" + list.size());
list.notifyAll();
}
}
}
其抽象接口的代码如下:
public interface AbstractStorage {
void consume(int num);
void produce(int num);
}
对于仓库而言,他需要实现consume(消费)和生产(product)两个方法,Why?
因为仓库和产品绑定在一起,对于这种产品,我们只能这么生产和消费。如果让生产者来实现生产方法,消费者实现消费方法,假设还有别的仓库和产品,该怎么办呢?我们即便采用 根据不同输入参数的方式来分流不同的生产方式,也不能很好地解决问题,因为代码臃肿不堪,与仓库的耦合度太高,另外也不符合开闭原则。
或许可以尝试工厂模式?不过遗憾的是本节重点不在这里,可能我们聊到设计模式的时候会改进目前的代码:)就目前而言我觉得仓库和生产消费方法同属于一个类,然后通过组合composite的方式来给线程类使用 是一个不错的方式。
另外 为啥使用LinkedList?
因为这里增删的产品完全相同,因此只需要链表末尾元素增删,不需要随机访问的功能,那自然排除增删性能没那么好的ArrayList。
为啥需要一个抽象的接口AbstractStorage?
我说了,假设还有别的仓库,别的产品,那我们该怎么组合仓库到我们的消费者生产者里边呢?(组合是啥意思 不明确的话请先移步下边生产者的代码)既然具体类我组合不了,我再抽象一层他不香吗:)如果愿意,你甚至可以运用工厂模式,抽象工厂模式,给你弄各种各样的产品哈哈。
管程法 生产者
public class Producer implements Runnable{
//所属的仓库
public AbstractStorage abstractStorage;
public Producer(AbstractStorage abstractStorage){
this.abstractStorage = abstractStorage;
}
// 线程run函数
public void run()
{
Random random = new Random();
abstractStorage.produce(random.nextInt(50));
}
}
管程法 消费者
public class Consumer implements Runnable {
// 所在放置的仓库
private AbstractStorage abstractStorage;
// 构造函数,设置仓库
public Consumer(AbstractStorage abstractStorage1)
{
this.abstractStorage = abstractStorage1;
}
// 线程run函数
public void run()
{
Random random = new Random();
abstractStorage.consume(random.nextInt(50));
}
}
管程法 main调用
public class Test {
public static void main(String[] args) {
// 仓库
Storage storageHuaWei = new Storage("HuaWei",100);
Storage storageIphone = new Storage("iphone",100);
Producer HuaWeiProducer = new Producer(storageHuaWei);
Consumer HubWeiConsumer = new Consumer(storageHuaWei);
// 造十个华为生产者
for (int i = 0; i < 10; i++)
new Thread(HuaWeiProducer, "HuaWeiProducer_"+i).start();
// 造十个华为消费者
for (int i = 0; i < 10; i++)
new Thread(HubWeiConsumer, "HuaWeiProducer_"+i).start();
}
}
这里体现出来Runnable的优势,避免了线程类的泛滥(可以看看此专栏第一篇第二篇介绍的 Thread和Runnable两种方法的区别)
另外,使用了抽象Storage接口,使得我们可以更灵活的生产与消费,比如建立iphone的仓库,创建iphone的生产者和消费者。这里还可以进一步运用工厂模式来拓展功能,读者们可以想想该怎么操作。
管程法结果
已生产HuaWei产品数:23 【现仓储量为】:23
预出货HuaWei产品数量:32 【库存量】:23 出货任务等待阻塞
已出货HuaWei产品数量:14 【现仓储量为】:9
预出货HuaWei产品数量:38 【库存量】:9 出货任务等待阻塞
预出货HuaWei产品数量:23 【库存量】:9 出货任务等待阻塞
预出货HuaWei产品数量:14 【库存量】:9 出货任务等待阻塞
预出货HuaWei产品数量:11 【库存量】:9 出货任务等待阻塞
预出货HuaWei产品数量:29 【库存量】:9 出货任务等待阻塞
已出货HuaWei产品数量:1 【现仓储量为】:8
已出货HuaWei产品数量:5 【现仓储量为】:3
预出货HuaWei产品数量:27 【库存量】:3 出货任务等待阻塞
已生产HuaWei产品数:40 【现仓储量为】:43
已生产HuaWei产品数:14 【现仓储量为】:57
已生产HuaWei产品数:10 【现仓储量为】:67
已生产HuaWei产品数:22 【现仓储量为】:89
预生产HuaWei产品数量:12 【库存量】:89 生产任务等待阻塞
预生产HuaWei产品数量:39 【库存量】:89 生产任务等待阻塞
已生产HuaWei产品数:7 【现仓储量为】:96
预生产HuaWei产品数量:29 【库存量】:96 生产任务等待阻塞
预生产HuaWei产品数量:26 【库存量】:96 生产任务等待阻塞
预生产HuaWei产品数量:39 【库存量】:96 生产任务等待阻塞
预生产HuaWei产品数量:12 【库存量】:96 生产任务等待阻塞
已出货HuaWei产品数量:27 【现仓储量为】:69
已出货HuaWei产品数量:29 【现仓储量为】:40
已出货HuaWei产品数量:11 【现仓储量为】:29
已出货HuaWei产品数量:14 【现仓储量为】:15
预出货HuaWei产品数量:23 【库存量】:15 出货任务等待阻塞
预出货HuaWei产品数量:38 【库存量】:15 出货任务等待阻塞
预出货HuaWei产品数量:32 【库存量】:15 出货任务等待阻塞
已生产HuaWei产品数:12 【现仓储量为】:27
已生产HuaWei产品数:39 【现仓储量为】:66
已生产HuaWei产品数:26 【现仓储量为】:92
预生产HuaWei产品数量:29 【库存量】:92 生产任务等待阻塞
已出货HuaWei产品数量:32 【现仓储量为】:60
已出货HuaWei产品数量:38 【现仓储量为】:22
预出货HuaWei产品数量:23 【库存量】:22 出货任务等待阻塞
已生产HuaWei产品数:29 【现仓储量为】:51
已出货HuaWei产品数量:23 【现仓储量为】:28
Process finished with exit code 0
if还是while
在多线程中要测试某个条件的变化,使用if 还是while?
给个结论 用while,
注意,notify唤醒沉睡的线程后,线程会接着上次的执行继续往下执行。所以,之前,因为不符合条件,wait执行导致自己被阻塞,但是当被唤醒的时候,程序接着往下跑,(还记得吗,我们有线程控制块Thread Control BLock能够很好地保存现场,程序计数器PC能够精确到上次运行到的代码处)
如果你是If 往下跑就跑没了 但事实上是我们想要的吗?比方说作为消费者,if里边检查的是还有没有库存,如果是if,被唤醒以后直接跑下去了,但是唤醒了不代表此时库存满足条件,这样很可能导致负数——因为你一唤醒就执行,也不管条件是否真的符合
显然,需要使用while,是得条件满足才能继续。
信号灯法
其实换汤不换药,之前我们通过判断一些条件来决定是否需要wait,比如库存没了需要生产者,或者库存满了需要消费者,换言之,你可以把这句话num > list.size()
就当做是所谓的信号灯flag就完事了
就只不过是通过判断flag来指示是否wait,与原来的条件判断一样,你只需要保证flag得到线程安全的维护即可 这里略过
总结 ´◡`
这一节我们学习了wait /notify 机制,即一部分线程通讯的知识,用以解决经典的生产者消费者问题,当然wait和notify有自己的固有缺陷,哪还有没有更好的方式呢?另外,Synchronized方法,又没有更好的替代品呢?他们的缺陷是什么?这些会在随后的文章中介绍。
下一节 Java 从多线程到并发编程(八)——
正在更新
- 本文专栏
- 我的其他专栏 希望能够帮到你 ( •̀ ω •́ )✧
- 谢谢大佬支持! 萌新有礼了:)