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处理完毕时,线程重新转入就绪状态。值得注意的是,这样的阻塞,是不会释放持有的锁

很明显,第三种是很常见的,也是导致之前讨论过的诸多问题的阻塞,“经典的”阻塞不会释放锁。

线程的状态切换

我们通过状态图,来看看线程的状态变化是怎么样的:
Java 从多线程到并发编程(七)—— wait notify 生产者消费者问题 管程法 信号灯法
我们的三种阻塞,其实分别走向了三个路径,

  • 等待队列
  • 锁池
  • 阻塞状态
    但是表面上看起来都是被阻塞的状态——CPU暂时不跑 而且不可运行(不在就绪态)

或许这些名词不够舒服 我们看另一张图:
Java 从多线程到并发编程(七)—— wait notify 生产者消费者问题 管程法 信号灯法
这里说得很清楚,阻塞状态广义上包括:

  • 等待阻塞(第一种 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 从多线程到并发编程(八)——
正在更新

上一篇:java书籍排名,3面直接拿到offer


下一篇:GARP和GVRP协议基础