java多线程之——生产者和消费者(详解及提高)

前情引入

做一些简单的认识和告知一些前置知识

简单介绍

生产者和消费者是一种特殊的业务需求的抽象,这种业务就是:需求和供给达到平衡关系,生产一个,就消费一个,或者是生产一部分,就消费一部分。

利用多线程,可以对这种业务需求进行简单的模拟和实现,主要是利用Object中的wait方法和notify方法。

注意,不能同时生产和消费,因为在多线程下,对共享的数据进行了修改,必须使用同步机制,不然会出现数据安全问题。

预备知识

首先对java中的多线程,有一定的认识。

再者呢,就是Object中的wait方法和notify方法的作用。

  • void wait():在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。 简单来说,就是让当前线程进入阻塞,直到被唤醒,并且会释放调用当前线程占用的对象锁
  • void notify() :唤醒在此对象监视器上等待的单个线程。 如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。简单来说,就是唤醒被wait方法进入阻塞的线程。
  • 注意:这两个方法都只能在同步代码块(包括同步方法)中使用
  • 顺带提一点,sleep虽然也能使当前线程进入阻塞状态,但是是不会释放锁和资源的

代码及详解

先上代码,再基本详解,最后再提高。

简单代码

import java.util.ArrayList;

//使用wait和notify实现生产者和消费者模式
public class PCMode1
{
    public static void main(String[] args)
    {
    	
        ArrayList<Object> arrayList = new ArrayList<>();
        new Thread(new Producer1(arrayList), "Producer").start();
        new Thread(new Consumer1(arrayList), "Consumer").start();
        
    }
}

@SuppressWarnings("all")
class Producer1 implements Runnable
{
    private final ArrayList arrayList;

    public Producer1(ArrayList arrayList)
    {
        this.arrayList = arrayList;
    }

    @Override
    public void run()
    {
        while (true)
        {
            synchronized (arrayList)
            {
                System.out.println(Thread.currentThread().getName()+"抢到了对象锁");
                if (arrayList.size() > 0)
                {
                    System.out.println("已有食物,请消费者消费!");
                    System.out.println();
                    try
                    { arrayList.wait(); } 
                    catch (InterruptedException e)
                    { e.printStackTrace(); }
                } 
                else
                {
                    arrayList.add(0,"a");
                    try
                    { Thread.sleep(100); } 
                    catch (InterruptedException e)
                    { e.printStackTrace(); }

                    System.out.println("已生产食物,请消费者消费!");
                    arrayList.notify();
                }
            }
        }
    }
}

@SuppressWarnings("all")
class Consumer1 implements Runnable
{

    private final ArrayList arrayList;

    public Consumer1(ArrayList arrayList)
    { this.arrayList = arrayList; }

    @Override
    public void run()
    {
        while (true)
        {
            synchronized (arrayList)
            {
                System.out.println(Thread.currentThread().getName()+"抢到了对象锁");
                if (arrayList.size()>0)
                {
                    arrayList.remove(0);
                    try
                    { Thread.sleep(100); } 
                    catch (InterruptedException e)
                    { e.printStackTrace(); }
                    System.out.println("消费者消费了一个食物,请生产者生产!");
                    arrayList.notify();
                } 
                else
                {
                    System.out.println("没有食物,请生产者生产!!");
                    System.out.println();
                    try
                    { arrayList.wait(); }
                    catch (InterruptedException e)
                    { e.printStackTrace(); }
                }
            }
        }
    }
}

@SuppressWarnings(“all”) 这个注解是为了去掉代码中那些难看的提示,可以直接忽略。

能看懂的话,那就是大佬咯,大佬可以看看后面的提高。看不懂也没关系,我们来一步一步的分析。

基本解释

我把三个类写在一个java文件中的,一个是测试类,另外两个,分别是生产者线程类和消费者线程类。测试类就是公共类,其中有main方法,用来测试用的,没啥好说的,主要是两个线程类。

先说一下大概的思路,两个线程类里都一个ArrayList类型的变量,这两个线程都是共享的同一个变量,这就是那个共享的数据。

简单起见,我模拟的是生产一个,消费一个的情况。我将这个ArrayList作为一个容器,生产者每生产一个食物,就放进这个容器,然后等消费者来消费,消费一个之后,生产者又进行生产,如此往复……

生产者线程类

Producer1是生产者线程,之前说了,有一个ArrayList类型的成员变量,构造方法是为了给这个变量赋值。

重写线程任务的run方法,先来一个while(true)死循环,意思就是,生产者线程一直生产。然后是synchronized同步代码块,因为是对共享的数据进行修改操作,所以要使用同步机制,来保证数据的安全,synchronized的锁对象就是共享的对象:arrayList。synchronized的对象选取原则就是:想要那些线程排队执行,就选择一个这些线程共享的对象

进入synchronized代码块中做的第一件事情,我先打印了一句话,方便后面观察控制台的输出情况,然后是正事。

我们要进行生产,首先第一件要做的事情是什么?当然是判断arrayList这个容器里是不是已经有食物了。如果已经有食物了,咱就不能生产,得让消费者来消费是吧。如果没有,咱们才能生产食物,并且添加到容器。

34行到42行,就是容器中存在食物的逻辑,先打印提示语句,然后用arrayList这个对象调用wait方法。还记得这个方法的作用吗?会让当前线程进入阻塞,并且会释放占用的对象锁。释放了对象锁,那么消费者线程就可能拿到对象锁,然后进行消费。当然,我这里只说了一个大概,具体细节后面再分析。

43行到53行,就是容器中不存在食物,我们生产食物并添加进容器的逻辑。就是往arrayList中添加一个元素,然后模拟一下生产的消耗时间(主要是为控制台输出可控,不然控制台飞一般的跑),再打印提示信息,最后再调用arrayList的notify方法。还记得这个方法的作用吗?唤醒在此对象监视器上等待的单个线程。那此时谁在arrayList对象上等待呢?我们当前生产者线程在执行,那么肯定就是消费者线程进入了阻塞状态撒。而调用这个方法,就可以唤醒消费者线程,进行消费。

消费者线程类

消费者线程类的处理逻辑和生产者线程非常类似,只是处理的业务不同,一个是进行生产的,一个是进行消费的,我们来简单的过一遍。

一样的代码就不说了,一样的意思。
76行到85行,是容器中存在食物,进行消费的逻辑,先将容器中这个食物移除,然后在模拟一下耗时,最后调用arrayList的notify方法,因为我们已经消费了容器里的食物,现在要通知在等待中的生产者生产了。

87行到94行,是容器中不存食物的处理逻辑,很简单,打印控制信息,然后调用arrayList对象的wait方法,让当线程进入等待状态,等待生产者生产。

测试类

测试类里,就是创建了两个线程对象,然后将生产者线程和消费线程传了进去,然后启动。其实就算这样挨着挨着分析了代码,可能能还是会不清楚,我觉得最好的办法是:自己来跟着代码走一遍流程,我自己屡试不爽,我们一起来走走吧

执行流程
  1. 程序的执行从main函数开始,我先new了一个生产者线程并且启动,所以是生产者先抢到了arrayList的对象锁,然后开始生产食物……不对,虽然生产者线程先启动,但是如果在生产者线程还没有进入同步代码块,也就是还没有拿到arrayList的对象锁时,有没有可能消费者线程就启动了,并且拿到了CPU的执行权,先拿到了arrayList的对象锁呢?其实是完全有可能的,因为在没有进入同步代码块的时候,两个线程是共同抢夺CPU的执行权的,先启动的,不一定就能占到便宜。

  2. 那怎么办呢?我生产者还没生产呢,消费者就来消费了。别急,假设消费者先拿到了arrayList的对象锁,我们就随着消费者线程的逻辑往下走,进入了消费者线程,经过条件判断,直接就进入了else分支里,因为此时容器里是没有食物的,然后它干了一件什么事情?原地wait,直接就原地阻塞,而且还将拿到的对象锁给释放了。那生产者呢?开始arrayList的对象锁被消费者给拿走了,它肯定就一直在自己线程里的synchronized代码块外面等待,拿不到对象锁,它只能在synchronized代码块外面等待。它肯定在想,是哪个个天杀的,抢了我的对象锁,害我一直在这里等。消费者一旦释放了锁,而且此时消费者本身进入了阻塞状态,不会和生产者去抢arrayList的对象锁,所以一定是生产者拿到对象锁,然后进入synchronized代码块生产食物。

  3. 生产者线程拿到对象锁之后,就美滋滋的去生产食物去了,那是它存在的唯一使命。它还是很严谨的,先判断容器中是否已经存在食物,呀,没有,就进入了else分支了,先生产了一个食物,将其存入了容器中,然后再用notify方法唤醒了正在arrayList对象上等待的线程,然后自己再退出了同步代码块,释放了对象锁。(退出synchronized代码块,也是会释放锁的!

  4. 消费者被唤醒了,而且生产者释放了arrayList的对象锁,消费者终于可拿到对象锁,并且大吃特吃了。但是作为程序出生的吃货,虽然爱吃,但是也是严谨的。它也是先判断容器中是否有食物,没有的话,那还瞎费什么劲呢,赶紧睡一觉(wait方法),让生产者那家伙生产。但是这次它运气不错,容器里是有食物的,然后它饱餐了一顿,然后再用同样的方法(notify)唤醒正在arrayList对象上等待的线程,自己退出synchronized代码块的时候,将锁给释放了。

  5. 生产者又拿到对象锁了,……如此往复

当然,我这里只说了大致的流程,有疑惑的小伙伴可以多分析几遍。还有一些细节,我们在提高中分析。

控制台输出

为了看到效果,在运行的时候,我特意将消费者线程的创建放在前面的,让它先抢到对象锁的概率大一些。
运行结果

自我提高

问题一
  1. 生产者/消费者释放了锁之后,可以再次拿到锁吗?再次拿到,会有什么影响吗?再次拿到的概率如何?为什么?

我的意思就是,在上面执行流程中第三步中,生产者最后将对象锁释放了,而且自己退出了synchronized代码块。但是别忘了,虽然生产者线程退出了同步代码块,但它还是一个正常执行的线程,并没有进入阻塞状态,它还是会和消费者抢锁的。

那会不会有问题?生产了之后生产者又抢到了锁。其实不会,就和我们在在执行流程第二步中的分析类似,即便生产者再次抢到了锁,但是此时容器中已经有食物了,它抢到了也会调用wait方法进入阻塞状态。生产者它此时的内心活动一定是:我靠,消费者那家伙还没吃?动作真慢,那我再睡会吧。然后消费者就能拿到对象锁,进行消费了。消费者再次抢到锁,情况也是类似,消费者前一次已经将食物消费了,再次抢到锁,发容器中已经没有食物了,就会调用arrayList对象wait方法,进入阻塞,释放锁。

其实上面程序的输出结果,也佐证了这一点。生产者总是在生产之后再次在控制台打印“已有食物,请消费者消费!”,消费者总是在消费之后,再次在控制台打印“没有食物,请生产者生产!!”,这其实就是因为再次抢到了锁,被wait方法进入阻塞之前打印的信息。

但是如果仔细分析,就会发现有问题。为什么每次都会再次抢到锁?每次都是,生产者先生产了一个食物,然后它再次抢到锁,再打印已有食物的提示信息。或者是,消费者先消费了一个食物,然后再次抢到了锁,并且打印没有食物的提示信息。这是为什么呢?按道理来说,他们俩都是有机会抢到锁的,为什么总是一个抢到两次,直到它自己被wait方法阻塞了,另一个线程才有执行的机会?如果你多刷几遍,可能会发现,偶尔一次,另一个还是后可能抢到锁的,只不过几率很小很小,以至于我一开始都怀疑我的代码有问题。

特意找到了这种情况,截图如下:(代码是上面的代码,没有动过哦)
特殊情况要解释这个问题,就需要仔细分析wait和notify方法的执行时机了,也就是第二个问题,往下面看。

问题二
  1. 生产者/消费者被唤醒了之后,是马上就执行的吗?

我们思考一个场景:假设容器里现在是有食物的,生产者在31行阻塞,消费者拿到锁在执行。当消费者消费了食物之后,它就调用了arrayList对象的notify方法,之前被wait方法阻塞的生产者线程,此时就被唤醒了。那么问题就来了:消费者线程在执行,生产者线程也被唤醒了,就有两个线程同时在使用了共享对象的synchronized代码块里面

那么,synchronized还有用吗?还能保证共享数据的安全吗?java的设计者肯定不会允许这样的事情发生。我做了简单的测试,得出了结论:即便唤醒了arrayList对象上等待的线程,但是被唤醒的线程并不会第一时间执行,而是等待当前线程执行完毕,被唤醒的那个线程才可以继续执行。应该是,即便等待的线程被唤醒了,但是锁时被当前的线程占用着的,被唤醒的那个线程拿不到锁,所以无法执行。

也就是说,即便生产者将消费者唤醒了,但是由于arrayList的对象锁是在生产者手上的,所以消费者不能第一时间执行,必须等生产者自动退出了synchronized代码块,将锁给释放了,被唤醒的那个线程才能继续从上次等待的那个位置继续执行。所以,为什么两个线程连续抢到的概率为什么那么大的疑问,也能解释了。

因为当前线程将另外一个线程唤醒了之后,当前线程会继续执行,直到退出了synchronized代码块,退出了synchronized代码块,那么被唤醒的那个线程就会接着上次等待的地方继续执行,但是别人在执行的时候,当前这个线程也没闲着呀,他自己也会自己继续执行,直到再次遇到了synchronized关键字,此时arrayList的对象锁在被唤醒的那个线程手上,当前线程就只能卡在synchronized关键字这里。但是被唤醒那个线程马上就会退出synchronized代码块,一旦退出,由于当前线程之前就已经卡synchronized关键字这里了,所以当前线程马上就能获取到arrayList的对象锁,直到当前线程被wait方法给阻塞了,才没有能力去抢那个锁了。

那为什么有会出现,不是一个线程连续两次抢到锁,别的线程也能在中间抢到锁呢?这是因为java中的线程调度用的是抢占式,这个东西就和玄学一样,谁能抢到,不能确定。所以可能被唤醒的那个线程抢夺能力很强,从他被唤醒并且当前线程释放了对象锁之后,他一直在占用CPU的执行权,没有给当前线程留时间,被唤醒的那个线程就抢到了锁,但是这种几率很小,除非是刻意的去制造,增大概率。

升级代码

import java.util.Random;

@SuppressWarnings("all")
public class PCMode
{
    public static void main(String[] args)
    {
        Bun[] buns = new Bun[4];
        new Thread(new Consumer(buns),"consumer").start();
        new Thread(new Producer(buns),"producer").start();
    }
}

@SuppressWarnings("all")
class Producer implements Runnable
{

    private Bun [] buns;
    private Random random = new Random();
    private String[] skins = {"冰皮儿","薄皮儿","厚皮儿"};
    private String[] stuffings = {"牛肉馅儿","大葱馅儿","韭菜馅儿","酱肉馅儿"};

    public Producer() { }
    public Producer(Bun[] buns)
    { this.buns = buns; }


    // 生产者生产包子
    @Override
    public void run()
    {

        while (true)//死循环,一直生产
        {
            synchronized (buns)//涉及到了共享的成员变量,而且要对其修改,所以要用同步机制
            {
                System.out.println(Thread.currentThread().getName()+"抢到了对象锁");
                boolean isFull = true;//用来标记包子库是否已满
                for (int i = 0; i < buns.length;i++ )//尝试生产包子并将包子存入包子库
                {
                    //如果有空位就可以生产包子并且存入
                    if (buns[i] == null)
                    {
                        isFull = false;
                        //生产一个包子,随机馅儿和皮
                        Bun bun = new Bun(skins[random.nextInt(skins.length)],stuffings[random.nextInt(stuffings.length)]);
                        buns[i] = bun;//将该空位置上加上包子
                        //模拟产生包子的耗时
                        int time = random.nextInt(5);
                        try
                        {
                            Thread.sleep(time*100);
                            System.out.println("生产者生产一个“"+bun.toString()+"”,耗时:"+time+"百毫秒");
                            //每生产一个包子,都唤醒包子库对象上所有的等待线程。分析和下面消费者的类似
                            buns.notify();
                        } catch (InterruptedException e)
                        { e.printStackTrace(); }
                    }
                }

                /*
                    这里的分析和消费者那里类似,如果包子库已经满了,就自己进入等待状态,并且释放包子库对象上的锁。让消费者来执行
                 */
                if (isFull)
                {
                    try
                    {
                        System.out.println("包子铺满了,吃货快来吃!");
                        System.out.println();
                        buns.wait();//自己进入等待状态
                    }
                    catch (InterruptedException e)
                    { e.printStackTrace(); }
                }
            }
        }
    }
}

//生产者线程
@SuppressWarnings("all")
class Consumer implements Runnable
{
    private Bun [] buns; //包子库
    private Random random = new Random();

    public Consumer() { }

    public Consumer(Bun[] buns)
    { this.buns = buns; }

    //尝试消费包子
    @Override
    public void run()
    {
        Bun bun = null;

        while (true) //死循环,一直消费
        {
            synchronized (buns)//涉及到了共享的成员变量,而且要对其修改,所以要用同步机制
            {
                System.out.println(Thread.currentThread().getName()+"抢到了对象锁");
                boolean isEmpty = true;//用来标记包子库是否为空
                for (int i = 0; i < buns.length; i++)//遍历包子库,消费包子
                {
                    if (buns[i] != null)//不为null,就说明有包子,进行消费
                    {
                        isEmpty = false;//将标记标为false
                        bun = buns[i];//后面要用到这个包子对象
                        buns[i] = null;//包子消费后,将其置为null

                        //模拟消耗包子的耗时
                        int time = random.nextInt(5);
                        try
                        {
                            Thread.sleep(time * 100);
                            System.out.println("消费者消费了一个“" + bun.toString() + "”,耗时:" + time + "百毫秒");
                        }
                        catch (InterruptedException e)
                        { e.printStackTrace(); }
                        /*每次消费一个包子后,都唤醒在仓库对象上等待的线程,但是由于对象锁还在当前线程,所以其他线程是不会执行的。当当前先线程跳出
                        了synchronized代码块时,就将对象锁释放了,而生产者线程也是被唤醒了的,所以有可能也会抢到对象锁,但是抢到了也没事,因为条件判断又会使其
                        进入等待,并且释放锁。但是不知道为什么,这种可能性很小很小,我刻意找了很久,只找到了一次*/
                        buns.notify();
                    }
                }

                /*
                    如果一个包子都没有,自己进入等待状态。由于每次消费一个包子后,都唤醒了包子库对象上的所有线程,所以生产者线程早就在等待了。
                    一旦当前线程(消费者线程)使用wait方法,释放了锁,并且自己进入了等待状态,立马就被生产者线程抢到。
                 */
                if (isEmpty)
                {
                    try
                    {
                        System.out.println("老板没包子了,快做包子!");
                        System.out.println();

                        buns.wait();
                    }
                    catch (Exception e)
                    { e.printStackTrace(); }
                }
            }
        }
    }
}

//包子类
@SuppressWarnings("all")
class Bun
{
    private String skin;//包子皮儿
    private String stuffing;//包子馅儿

    public Bun() { }

    public Bun(String skin, String stuffing)
    {
        this.skin = skin;
        this.stuffing = stuffing;
    }

    public String getSkin()
    { return skin; }

    public void setSkin(String skin)
    { this.skin = skin; }

    public String getStuffing()
    { return stuffing; }

    public void setStuffing(String stuffing)
    { this.stuffing = stuffing; }

    @Override
    public String toString()
    { return skin+stuffing+"包子"; }
}

升级代码中,增加了仓库的容量,并且生产的时候,变成了随机生产的,模拟时间消耗也变成了随机性的。但其实核心的逻辑和最开始的是一样的,我这里就不分析了,大家有兴趣的可以去分析一下,我代码中写了很多注释,方便大家分析。

运行结果
运行结果
在这个程序中,想去找那种特殊情况,就很难找了。

总结

模拟生产者和消费者,要记住几个要点

  1. while(true)死循环,因为生产者要一直生产,消费者要一直消费。
  2. synchronized,同步代码块,因为要对共享的数据进行修改,必须使用同步机制,而且wait和notify只能在同步代码块中使用。注意要用两个线程共享的对象,不用arrayList,用其他两个线程共享的对象也是可以的。比如用所有线程都共享的字符串,如果用字符串,那wait和notify方法也需要用那个字符串对象去调用。

还有一个很重要,但是容易把握的要点,我详细说一下:就是wait方法和notify方法该在什么时候调用。比如说,我是生产者线程,根据容器里是否有食物,有两种状态,有或者没有。如果已经有,我该用wait方法阻塞当前线程,释放资源?还是该用notify方法唤醒在等待的线程呢?
如果仅仅从字面上分析:如果已经有食物了,那我就自己就进入阻塞,释放锁对象。或者如果已经有食物了,我就唤醒在等待的线程(消费者线程)。两者好像都说得通。那如果没有呢,我生产一个食物存入容器之后,又该用那个方法呢?好像也都能说得通。

针对这种情况,我发现了一个诀窍:要保证两次被同一个线程抢到了对象锁时,这个线程要进入阻塞状态,。我们再来分析上面的疑惑,如果是生产者,在容器中有没有食物的情况下,我调用了wait方法,将当前线程进入了阻塞状态。那么如果生产者两次抢到了对象锁,第二次进入的时候,容器里已经有食物了,因为第一次会生产并存入。那么第二次就不会进入“容器中没有食物”的那个选项,也就不能进入阻塞。所以我们选择在有食物的情况下,使用wait方法,进入阻塞状态。然后在另一种情况下,选择用notify方法唤醒等待的线程。消费者也可以用类似的方法判断。这种方法是可取的,但是不知道是否有其它更好的办法。

有什么不对或不懂的地方,欢迎一起讨论

猜你喜欢

转载自blog.csdn.net/ql_7256/article/details/107675314
今日推荐