Java 从多线程到并发编程(七)—— wait notify 生产者消费者问题 管程法 信号灯法

前言 ´・ᴗ・`

继上一节我们学习了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 这个图什么意思我们心里都有数了。如果还不太能接受,可以看完后边的生产者消费者问题再回头看看。

Java层面的线程状态

上面这个“线程状态”的分类方法可能比较底层,在我们Java里边,通过getState获取的状态,其实我们之前的文章有介绍过,分别是

  • NEW
    OS还没有分配内存 线程并没有真正建立起来
  • RUNNABLE
    这里包括了就绪态(可以运行但是未获得CPU)和Running(真的在CPU那边0跑)
  • BLOCKED
    这里说的阻塞(BLOCKED)状态特指上边所谓的同步阻塞,即第二种,这种阻塞比较狭义,一定是为了保护临界资源而阻塞的称为阻塞。
  • WAITING
    这就是前面说的 等待阻塞 一般就是因为wait导致的,当然join也会导致,总之这类状态都有等待其他线程,其他事件发生的含义。
  • TIMED_WAITING
    其实wait方法还有变式,你可以设定超时自动唤醒,因为有些场合会导致没有被notify 线程直接死掉的现象。当然,sleep也是同样的状态,可以说他们的共性就是“定时”的等待
  • TERMINATED
    这里既然是Java的状态,那实际上OS内部,可能真的就注销,内存等资源回收了,也可能被复用,套了层皮(Thread对象),然后继续使用。

生产者消费者模型

听起来好像很玄乎的样子 我们假设有三个角色,生产产品的人,消费产品的人,放产品的仓库,

生产者,把产品放到仓库里,并且仓库空间有限,所以满了就不放了呗,
消费者,从仓库中拿产品,并且产品有限,拿完了就没了,因此拿完了消费者也没法做事了

由于多线程,我们这个仓库要作为临界资源,即同时只能有一个线程访问之,否则会造成数据不安全。目前我们所知的就是使用Synchronized代码块来实现,所谓的同步访问

换言之,仓库满了,生产者线程应当阻塞,而且是等待阻塞,why?因为仓库满了 你生产者不应该占用仓库而不干事啊:)这时应当让消费者线程进去,消费产品。

但是这里有个问题了 消费者怎么知道 你啥时候仓库满了?所以这就需要所谓的线程通讯机制,我们之前学Synchronized是怎么保证线程安全,也就是实现同步访问,同时只能有一个线程能够修改临界资源,这里则需要的是线程之间的通讯与协调。

聪明的你应该能想到,为啥我们先介绍了奇怪的wait和notify -> 没错,wait / notify 正是一种线程通讯机制。

wait notify深入一点

前面我们说了,wait 方法使线程暂停运行,等待阻塞,而notify 方法通知所有处于等待队列的线程继续运行。

但是要想正确使用wait/notify,一定要注意四点:
① wait/notify 调用,其object必须是临界资源,是有锁的,否则程序会抛出异常,也就调用不了wait/notify
Why?其实答案很明显,如果,这个object压根就不是临界资源,那我等待阻塞什么?阻塞线程其实没有必要了,同样,如果这个object不是临界资源,那也没有所谓等待的队列,没有线程会因为非临界资源而被等待阻塞。

我们想一下,如果不用临界资源对象来实现,有什么方法?答案是suspend和resume stop 但这些方法都是反面教材,为啥?脱离临界资源对象的实现就是耍流氓:)——)你都不知道线程当前真正的状态,就瞎指挥,最后造成各种数据不安全问题怪谁呢?

所以另外一个注意点也很简单,② 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 int maxSize = 100;
    //仓库存储的载体
    private LinkedList list = new LinkedList();
    // 产品名称
    private String name = "";

    Storage(String name, int maxSize){
        this.name = name;
        this.maxSize = maxSize;
    }

    //生产产品
    public void produce(int num){
        //同步
        synchronized (list){
            //仓库剩余的容量不足以存放即将要生产的数量,暂停生产
            while(list.size()+num > maxSize){
                System.out.println("预生产"+name+"产品数量:" + 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("已生产"+name+"产品数:" + num + "\t【现仓储量为】:" + list.size());

            list.notifyAll();
        }
    }

    //消费产品
    public void consume(int num){
        synchronized (list){

            //不满足消费条件
            while(num > list.size()){
                System.out.println("预出货"+name+"产品数量:" + 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("已出货"+name+"产品数量:" + 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得到线程安全的维护即可 这里略过

总结 ´◡`

请注意线程状态的两种分类方式,前一种将阻塞分为三类的方式更多的是从,是否占用锁的角度来考虑,而后边java层面的线程状态分类,更多的是从java层面,比如BLOCKED 还有WAIT和TIMEED_WAIT,我觉得都需要知道一下,但是不用深究为啥这么分类。

这一节我们学习了wait /notify 机制,即一部分线程通讯的知识,用以解决经典的生产者消费者问题,当然wait和notify有自己的固有缺陷,哪还有没有更好的方式呢?另外,Synchronized方法,又没有更好的替代品呢?他们的缺陷是什么?这些会在随后的文章中介绍。不过介绍之前我们得正式的看看所谓的并发内存模型,介绍完模型,我们才能真正细化并发问题,真正理解线程安全。之后,解决他们用什么工具,工具的原理是啥,我们学起来就很轻松了。

下一节 Java 从多线程到并发编程(八)—— 并发内存模型
正在更新

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值