【Java】一文搞懂生产者和消费者模型

阻塞队列的概念

之前介绍过队列,是一种数据结构,先进先出FIFO。阻塞队列也满足队列的特性,不过有特殊之处:
入队元素时,先判断一下队列是否已满,如果满了就等待(阻塞),当有空余空间时再插入;
出队元素时,先判断一下队列是否空了,如果空了就等待(阻塞),当队列中有元素时再取出。

现实生活中的例子:包饺子
1.每个人各擀各的饺子皮,各包各的饺子
这种情况大概率会出现争抢擀面杖的现象,在多线程环境下就是锁竞争。
2.一个人专门来擀饺子皮,其他人负责包饺子
当饺子皮多的时候,擀饺子皮的人就可以休息一会儿;当没有饺子皮的时候就得一直擀;
当饺子皮多的时候,包饺子的人就要一直去包饺子;当没有饺子皮的时候就停下来休息;
在这里插入图片描述

上述这个例子中,擀皮的人可以成为生产者,包饺子的人称为消费者,放饺子皮的地方就是一个交易场所,这个场所就可以用阻塞队列实现。这就是阻塞队列的一个典型应用场景—— “生产者消费者模型”,这是一种非常典型的开发模型。

生产者消费者模式

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。在具体项目中,使用消息队列这样一个“中间件”来实现这个功能。

消息队列

在基础的数据结构上,做了一些针对应用场景的优化和实现,把这样的一些框架和软件称为“中间件”。消息队列就是一个“中间件”,本质上是一个阻塞队列,在此基础上把放入阻塞队列的消息打了一个标签。比如卖包子场景:
在这里插入图片描述
假如说刚出锅的全是豆沙包,那么只需要处理买豆沙包的消息,消息的标签就可以实现分组的作用

消息队列的作用

1.解耦
比如下面这种情况,服务器A和服务器B必须知道对方的存在,且参数必须要约定好,如果有其中一方挂了,那么整个流程都会受影响。这种情况下如果某一个功能需要修改,涉及的服务器可能都需要修改代码。这种情况就说耦合度非常高,不建议这种系统组织方式。
在这里插入图片描述
通过消息队列把三个系统进行解耦,是他们不再进行直接通信,起到了单独维护,单独运行的效果
在这里插入图片描述
在设计程序的时候提出过一些要求:比如高内聚,低耦合。高内聚就是把功能强相关的代码写在一起,维护起来非常方便。这是一种组织代码的方式。低耦合就是不要把相同的代码写的到处都是,一般是通过抽象的方式把代码封装成方法,使用的时候调用即可。良好的代码组织方式,可以有效的降低维护成本。

2.削峰填谷
峰和谷是指消息的密集程度。
比如双十一期间流量会暴增,在这个链路中,任何一个节点出现问题都会影响整个业务流程。
在这里插入图片描述
这时你可能会想,可以使用多组链路,不同的用户可以通过不同的链路来访问(负载均衡),这样可以解决流量暴增带来的问题。确实可以解决这个问题,但是需要思考的是,流量不会一直在峰值,大部分时间流量都是正常状态, 此时部署的其他链路就用不上了,这无疑增加了好几倍的花销。

假设银行一秒只能处理200个订单,物流公司一秒只能处理100个订单,当调用第三方接口时,如果调用次数达到了上限就阻塞一会儿。此时使用消息队列,在流量激增的时候用消息队列缓冲(削峰)在流量减少的时候,把消息队列中存储的消息一点点消费(填谷)。最终让系统和硬件配置达到平衡。
在这里插入图片描述

3.异步
同步是指请求方必须死等对方的响应。
异步是指发出请求之后,自己去干别的事情,有响应时会接收到通知从而处理响应

JDK中的阻塞队列

JDK提供了多种不同的阻塞队列,可以根据不同的业务场景选择不同的阻塞队列实现方式。

public class Demo01_BlockingQueue {
    
    
    public static void main(String[] args) {
    
    
        //定义阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        BlockingQueue<Integer> queue1 = new ArrayBlockingQueue<>(3);
        BlockingQueue<Integer> queue2 = new PriorityBlockingQueue<>(3);
    }
}

1.添加元素
在定义阻塞队列时可以给定初始化容量。下面这个示例中,由于当前的阻塞容量是3,所以当插入第四个元素时就会发生阻塞。

public class Demo01_BlockingQueue {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        //定义一个阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        //往队列中写入元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经插入了三个元素");
        queue.put(4);
        System.out.println("已经插入了第四个元素");
    }
}

在这里插入图片描述
2.取出元素

阻塞队列中获取元素不使用poll()方法,而是使用take()方法,会产生阻塞效果
在这里插入图片描述

public class Demo01_BlockingQueue {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        //定义一个阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        //往队列中写入元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经插入了三个元素");
        System.out.println(queue);
        //阻塞队列中获取元素使用take方法,会产生阻塞效果
        System.out.println("开始获取元素");
        System.out.println(queue.take());
        System.out.println(queue);
    }
}

在这里插入图片描述
当获取完阻塞队列中所有元素时,此时阻塞队列为空,再继续获取元素时,会进入阻塞状态。

public class Demo01_BlockingQueue {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        //定义一个阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        //往队列中写入元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经插入了三个元素");
        System.out.println(queue);
        //阻塞队列中获取元素使用take方法,会产生阻塞效果
        System.out.println("开始获取元素");
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println("已经获取了三个元素");
        System.out.println(queue.take());
        System.out.println("已经获取了四个元素");
    }
}

在这里插入图片描述

实现阻塞队列

在学习数据结构时,实现一个普通队列底层用到了两种数据结构:循环数组和链表。阻塞队列就是在普通队列上加入了阻塞等待的操作。这个阻塞等待的操作就是等待(wait)和唤醒(notify)。
确定加锁的范围:
这两个方法和synchronized是强相关的,所以要加入synchronized,此时要先确定加锁的范围。在put()和take()方法中,根据修改共享变量的范围,加入synchronized。由于整个方法都在修改共享变量,所以给整个方法加锁。如果一个对象需要new出来使用,那么锁对象一般是this,此时的锁对象是this即可。
确定等待的时机:
在添加元素中,当前数组已满时,此时要阻塞等待;在取元素时,当阻塞队列为空时,此时要阻塞等待。
确定唤醒的时机:
在添加元素的方法中,执行put操作的最后一步之后再执行唤醒操作,把取元素的线程唤醒;在取元素的方法中,当前队列有空余位置的时候,唤醒添加元素的线程。

代码实现

public class MyBlockingQueue {
    
    
    //定义一个保存元素的数组
    private int[] elementData = new int[100];
    //定义队首下标
    private volatile int head;
    //定义队尾下标
    private volatile int tail;
    //定义有效元素的个数
    private volatile int size;


    //插入一个元素
    public void put(int value) throws InterruptedException {
    
    
        //根据修改共享变量的范围加锁
        //锁对象是this即可
        synchronized (this){
    
    
            //判断数组是否已满
            while(size >= elementData.length){
    
    
                //阻塞等待
                this.wait();
            }
            //向队尾插入元素
            elementData[tail] = value;
            //移动队尾下标
            tail ++;
            //修正队尾下标
            if (tail>=elementData.length) {
    
    
                tail = 0;
            }
            //修改有效元素个数
            size ++;
            //做唤醒操作
            this.notifyAll();
        }

    }

    //获取一个元素
    public int take() throws InterruptedException {
    
    
        //根据修改共享变量的范围加锁
        //锁对象是this即可
        synchronized (this){
    
    
            //判断队列是否为空
            while (size<=0) {
    
    
                this.wait();
            }
            //从队首出队
            int value = elementData[head];
            //移动队首下标
            head ++;
            //修正队首下标
            if (head>=elementData.length) {
    
    
                head = 0;
            }
            //修改有效元素个数
            size --;
            //唤醒操作
            this.notifyAll();
            //返回队首元素
            return value;
        }
    }
}

⚠️⚠️⚠️注意:在wait方法的官方文档中指出:“线程可以在没有通知、中断或超时的情况下唤醒,即所谓的虚假唤醒。虽然在这种情况下在实践中很少发生,但是应用程序必须通过测试导致线程被唤醒的条件来防止这种情况,如果条件不满足,则继续等待。换句话说,等待应该总是出现在循环中”。
也就是说,第一次满足wait条件时,线程进入阻塞状态,被唤醒之后,这期间会发生很多事情,有一种可能是被唤醒后,等待的条件依然成立,所以需要再次检查等待条件,如果满足就继续阻塞等待。即就是在实现上述阻塞队列时,检查wait的判断条件时,用while来判断,而非if
在这里插入图片描述
最后,在多线程环境下,需要给共享变量加volatile。

测试自定义阻塞队列

public class Demo02_MyBlockingQueue {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        //定义一个阻塞队列
        MyBlockingQueue queue = new MyBlockingQueue();
        //往队列中写入元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经插入了三个元素");

        System.out.println("开始获取元素");
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println("已经获取了三个元素");
        System.out.println(queue.take());
        System.out.println("已经获取了四个元素");
    }
}

在这里插入图片描述

实现生产者消费者模型

分别用一个线程模拟生产者和消费者,实现一个简单的生产者消费者模型。让生产者线程每10ms就生产一次,让消费者线程每1s再消费一次。
在这里插入图片描述
代码实现

public class Demo03_ProducerConsumer {
    
    
    // 定义一个阻塞队列,初始容量为100
    private static MyBlockingQueue queue = new MyBlockingQueue();

    public static void main(String[] args) {
    
    
        // 创建生产者线程
        Thread producer = new Thread(() -> {
    
    
            //记录消息编号
            int num = 1;
            while (true) {
    
    
                // 生产一条就打印一条日志
                System.out.println("生产了元素 " + num);
                try {
    
    
                    // 把消息放入阻塞队列中
                    queue.put(num);
                    num++;
                    // 休眠10ms
                    TimeUnit.MILLISECONDS.sleep(10);
                } catch (InterruptedException e) {
    
    
                    e.printStackTrace();
                }
            }
        });
        // 启动生产者
        producer.start();

        // 创建消费者线程
        Thread consumer = new Thread(() -> {
    
    
            while (true) {
    
    
                try {
    
    
                    // 从队列中获取元素(消息)
                    int num = queue.take();
                    // 打印一下消费日志
                    System.out.println("消费了元素 :" + num);
                    // 休眠1秒
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
    
    
                    e.printStackTrace();
                }
            }
        });
        // 启动消费者
        consumer.start();
    }
}

测试结果:
让生产者线程每10ms就生产一次,让消费者线程每1s再消费一次。此时阻塞队列中消息满了再消费,但生产和消费还是同时进行的。
在这里插入图片描述

相反,如果让生产的线程慢,消费的线程快,则每生产一个消息就消费一个。阻塞队列永远不会满。
在这里插入图片描述


继续加油~
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_43243800/article/details/130961993
今日推荐