【多线程】阻塞队列

🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈

在这里插入图片描述

1. 阻塞队列的含义

队列(Queue)】 先进先出(最简单,最朴素的队列)
优先级队列(PriorityQueue)】 堆
阻塞队列(BlockingQueue)】是一种特殊的队列,也是遵守 “先进先出” 的原则

阻塞队列是一种线程安全的数据结构,并且具有以下特性:
1)当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素
2)当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素
在这里插入图片描述这个阻塞队列是十分有用的,尤其在写多线程代码的时候,多个线程之间进行数据交互,可以使用阻塞队列简化代码的编写,其中阻塞队列的一个典型应用场景就是 “生产者消费者模型”,这是一种非常典型的开发模型!

2. Java标准库中阻塞队列的使用

2.1 BlockingQueue接口

在 Java 标准库中内置了阻塞队列,如果我们需要使用阻塞队列,直接使用标准库中的 BlockingQueue 即可!

与 BlockingQueue 相关的具体实现关系如下:
在这里插入图片描述
其中这7个类分别是:

(1) ArrayBlockingQueue:数组结构组成的有界阻塞队列,构造时必须传入一个数指定capacity的大小
在这里插入图片描述

(2) LinkedBlockingQueue:链表结构组成的有界阻塞队列 在这里插入图片描述 (3) PriorityBlockingQueue:支持优先级排序的无界阻塞队列
(4) DelayQueue:使用优先级队列实现的延迟无界队列
(5) SynchronousQueue:不存储元素的阻塞队列,其容量为0
(6) LinkedTransferQueue:链表结构组成的无界阻塞队列
(7) LinkedBlockingDeque:链表结构组成的双向阻塞队列

BlockingQueue 是一个接口,真正实现的类是 LinkedBlockingQueue(链表实现) 和 ArrayBlockingQueue(顺序表实现)等上述7个类,具体使用方法如下:

BlockingQeque<String> queue1 = new LinkedBlockingDeque<>();
BlockingQueue<String> queue2 = new ArrayBlockingQueue<String>(20);
...

2.2 阻塞队列方法

阻塞队列的核心方法主要有两个:put()和take(),均带有阻塞特性

1)put()方法
用于阻塞式地入队列,如果队列满的时候,继续尝试入队列,就会阻塞直到有其他线程从队列中取走元素,队列不为满为止 ,put()方法会抛出 InterruptedException 异常,因为该方法可能会带来阻塞,一旦阻塞就可能提前被interrupt方法唤醒,此时就会抛出异常(会带来阻塞的方法往往会抛出 InterruptedException 异常)
2)take()方法
用于阻塞式地出队列,如果队列为空,继续尝试出队列take(),也会阻塞等待直到其它线程往队列中插入元素,队列不为空为止

BlockingQueue 也有 offer,poll,peek 等方法,但是这些方法不带有阻塞特性~

通过以下代码,更直观了解阻塞队列的特性!代码如下:

public class ThreadDemo1 {
    public static void main(String[] args) throws InterruptedException {
        BlockingDeque<String> queue = new LinkedBlockingDeque<>();
        //1.入队列
        queue.put("hello1");
        queue.put("hello2");
        queue.put("hello3");
        queue.put("hello4");
        queue.put("hello5");
        //2.出队列
        String result = null;
        result = queue.take();
        System.out.println(result);
        result = queue.take();
        System.out.println(result);
        result = queue.take();
        System.out.println(result);
        result = queue.take();
        System.out.println(result);
        result = queue.take();
        System.out.println(result);
        //第6次 result为空 take会发生阻塞 所以不会打印
        result = queue.take();
        System.out.println(result);
    }
}

运行程序后打印结果如下:

在这里插入图片描述
在上述代码中,put 操作进行了5次,take 操作进行6次,当队列空的时候,继续出队列会阻塞,因此在第6次 take 操作时就会阻塞,不会打印!

3. 阻塞队列的应用场景 —— 生产者消费者模型

3.1 生产者消费者模型的含义

生产者消费者模型这个概念非常关键,是我们服务器开发中一种十分常见的代码写法~那生产者消费者模型到底是什么呢?

举个例子,方便我们对这个模型有更深刻的理解~

用包饺子来类比,假设自己擀饺子皮自己包且只有一个擀面杖,一桌人围在一起包饺子,有以下几种包法:
包法一】每个人擀一个饺子皮,自己包一个,再擀一个,再包(这种做法虽然能够完成任务,但并不高效,当一个人在使用擀面杖的时候,其它三个人包完自己的想要用擀面杖只能干等着什么都做不了,即使有4个擀面杖,但是每个人在不停切换自己的状态,也是比较费劲的)
包法二】一个人专门负责擀皮,另外三个人负责包(类似于流水线),这种情况就构成了生产者消费者模型

在生活中,包法二是更常见的包法,对于饺子皮来说,负责擀皮的人是生产饺子皮的,即为生产者,负责包饺子的人是消耗饺子皮的,即为消费者
注意
生产者,消费者这样的角色,是针对某个资源来说的,针对的资源不同,角色分配就不同!!!
在这里插入图片描述
生产者和消费者交换数据就需要用到一个场所,这个场所就是一个阻塞队列
在这里插入图片描述
在上述包饺子过程中,这个场所就是盖帘,体现阻塞队列的两个特性:如果擀饺子皮的人很快,将饺子皮暂放在盖帘上,如果盖帘一下子堆满了饺子皮,擀饺子皮的人就可以休息一会了,等饺子皮使用了再继续擀,如果饺子包的很快,一下子把饺子皮都包完了,包饺子的人就可以休息一会,等待擀饺子皮的人擀皮出来~

3.2 生产者消费者模型的作用

生产者消费者模型的初心是啥?就是上述过程中的提高效率嘛?还能解决啥问题?
能解决的问题很多,以下介绍最主要的两个方面:

3.2.1 可以让上下游模块之间,进行更好地“解耦合”

耦合】指的是两个模块之间的关联关系是强还是弱,关联越强,耦合越高,关联越弱,耦合越低
打个比方,比如我家临时出了点状况,我就得立马回去~很明显,我家这边的变化对于我的影响是非常大的,说明耦合高,但是假如我同学家临时出了点状况,我可能等她回来后,多询问她的情况,耦合低

在这里顺便补充一下,啥叫内聚~
内聚】指的是模块内部各成分之间相关联程度的度量,相关联的代码没有放一块,东一块西一块,低内聚,相关联的代码,分门别类地规制起来,高内聚
打个比方,小万有个坏习惯,东西喜欢随手乱丢,东西可能出现在任何可能出现的地方,比如小万现在找半天钥匙,结果发现放在一个书包的小袋子里,低内聚,但是小丁喜欢把东西放在固定的位置,衣服就整理好放在衣柜里,文具就整理好放在书包里,要找到自己的东西,一下就找到了,这就是高内聚~

在编写代码的过程中,要追求低耦合,避免代码牵一发动全身!

接着考虑下列场景:
1)高耦合情况:
有两台服务器,A和B直接通信
在这里插入图片描述
此时如果A和B直接通信,如果B挂了,对于A有直接的影响,A也就跟着挂了,另外,如果要再加一个C,与A进行通信,此时对于A也有一个较大的调整,这就是耦合比较高的情况

2)低耦合情况:引入生产者消费者模型,耦合降低
A不知道B存在,B也不知道A存在,两者彼此不知道对方的存在,它们俩个只认识队列,只与阻塞队列服务器进行通信,此时B挂了对于A没有影响,如果要再加一个C,此时仅需让C从队列中取请求即可,对于A的影响非常小!
消息队列
由于阻塞队列非常好用,所以有些大佬把这个阻塞队列的功能单独拿出来,扩充上更多的功能做成单独的服务器,称为消息队列服务器
在这里插入图片描述
ABC作为业务服务器,和业务相关,要随时修改代码支持新的功能,而程序猿很容易写出新的bug,但队列和业务无关,代码不太会变化,更稳定,因此,阻塞队列挂的概率比ABC这种业务服务器要小的很多!!!
【注意】阻塞队列服务器会挂的!只是它更稳定,挂的概率很小~

3.2.2 削峰填谷

继续分析这两个服务器情况:
在这里插入图片描述
A收到的请求数量和用户行为相关,用户行为是随机情况,在有的情况下,请求会出现"峰值",突然爆发式的增长一波

如果A和B是直接调用的关系,A收到了请求峰值,B也同样会有这个请求峰值,假设A平时收到的请求是每秒钟 1w个,而在某个时间点突然收到了每秒钟 3w个,对于B来说,请求也是每秒钟 3w个,此时,如果B服务器在设计的时候没有考虑峰值的处理,可能就挂了(服务器处理每个请求,都需要消耗资源硬件,包括不限于CPU、内存、硬盘、带宽,如果某个硬件资源达到瓶颈,此时服务器就挂了)这就给系统的稳定性带来了风险!

引入生产者消费者模型,风险大大降低,稳定性提高:
在这里插入图片描述
A不直接向B发送请求,而是向阻塞队列发送请求,A收到的请求多了,队列里的元素也就多了,此时B仍然可以按照之前的速率来取元素,即队列帮B承担了压力 —— 削峰

假设流量高峰后还有一个波谷,收到的请求很少,此时B仍然可以按照原有速率消费队列中之前积压的数据 —— 填谷
在这里插入图片描述

【服务器是否容易挂】业务越简单,服务器越不容易挂,业务越复杂,服务器容易挂
1)队列没有业务,代码稳定,承担了压力,不容易挂
2)A作为入口服务器,一般来说起到的效果就是调用一起其它服务器,把结果进行汇总,统一返回,相当于工具人,业务较简单,A服务器也不容易挂
3)而B服务器是有具体业务,对于B来说承担的工作量更大,单个请求吃的资源更多,就更容易挂

引入生产者消费者模型,使用阻塞队列,起到削峰填谷的作用,这与三峡大坝的削峰填谷作用一致~
三峡大坝,功在当代,利在千秋,当发生洪灾的时候,即雨量达到了峰值,上游水量增长,大坝把水拦住,让下游仍然有一个平缓的流量,削峰,到了旱季,三峡大坝开闸放水,填谷,此时就能一直保持下游水量适中且变化平缓~ 起到了生产者消费者模型的效果 —— 削峰填谷
(打个广告,欢迎各位来到美丽的宜昌游玩,参观参观一下三峡大坝!)

4. 阻塞队列的使用

在多线程下使用阻塞队列 —— BlockingQueue,实现"生产者消费者模型"的一个代码案例:

在这里插入代码片//生产者消费者模型
public class ThreadDemo17 {
    public static void main(String[] args) {
        BlockingDeque<Integer> blockingDeque = new LinkedBlockingDeque<>();
        //消费者
        Thread t1 = new Thread(() -> {
            while(true) {
                try {
                    int value = blockingDeque.take();
                    System.out.println("消费元素:" + value);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();


        //生产者
        Thread t2 = new Thread(() -> {
            int value = 0;
            while(true) {
                try {
                    System.out.println("生产元素:" + value);
                    blockingDeque.put(value);
                    value++;
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t2.start();
        //上述代码让生产者每隔1s生产元素 消费者不受限制
    }
}

打印结果如下:
在这里插入图片描述
在上述运行结果中,每隔一秒,生产者会产出一个元素,进行 put 操作放进阻塞队列中,消费者消费元素没有时间间隔,一旦阻塞队列 blockingDeque 中有元素,进行 take 操作,立即消费元素,在阻塞队列中,没有元素的时候,take 操作进行阻塞等待,直到生产者生产出元素

注意】在生产者消费者模型中,生产者和消费者不一定只有一个,可以同时存在多个消费者和生产者

生产者消费者模型是阻塞队列的应用,如何使用 BlockingQueue是很简单的,下面我们一起来看看如何自己通过代码编写实现 BlockingQueue,这是我们需要重点掌握的!(一般难的都会是重点hhh)

5. 如何实现一个阻塞队列

在本期内容中介绍如何采用数组实现阻塞队列(即不带泛型)
(泛型很难把握,普通程序猿很少会在工作中使用到泛型,一般实现库的程序猿会涉及泛型~)

阻塞队列就是带有阻塞特性的队列,实现一个阻塞队列主要分以下三步:

1)先实现一个普通队列
2)加上线程安全
3)加上阻塞功能

5.1 实现一个普通队列

  1. 先定义三个变量:head 头 tail 尾 size 记录数组元素的个数
  2. 实现入队里面的操作 :
    先判断数组是否满了,满了直接return,没有满则将新元素放在数组尾部,并尾部后移动一个 tail++,如果 tail 达到数组末尾,则将 tail 从头开始,记录数组元素的个数 size++
  3. 实现出队里面的操作:
    先判断数组里面是否有元素,数组为空则直接返回 null,不为空,则队列头部 head 的元素放在一个变量 value 里,并将头部后移动一位 head++,如果 head 到达数组末尾,则将 head 从头开始,记录数组元素的个数 size减去1,同时返回 value
    在这里插入图片描述代码实现如下:
class MyBlockingQueue {
    private int[] items = new int[1000];
    //约定[head,tail) 队列的有效元素
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //入队列
 public void put(int elem) {
 		//数组满了
        if(size == items.length) {
            return;
        }
        //把新元素放到tail所在的位置上
        items[tail] = elem;
        tail++;
        //万一达到末尾,就需让tail从头来
        if(tail == items.length) {
            tail = 0;
        }
        //tail = tail % items.length; 不推荐
        size++;
    }

    //出队列
    public Integer take()  {
    //数组空
        if(size == 0) {
            return null;
        }
        int value = items[head];
        head++;
        if(head == items.length) {
            head = 0;
        }
        size--;
        return value;
    }
}

注意如何区分队列满还是空?
在 head 或者 tail 到达数组尾部时候,需要它们两个回到数组开头,以此循环,实现循环队列,有两种做法:
在这里插入图片描述

1)if 判断法

 if(tail == items.length) {
         tail = 0;
 }

 if(head == items.length) {
         head = 0;
}

如果 head 或者 tail 到达数组末端,直接通过判断,如果是到达了,将它们设置为 0,直接将 head 和 tail 返回到数组开头

2)取余法(不推荐)

tail = tail % items.length; 

取余数也可以达到这个目的,但是不推荐~ 这样的方式可行,但是效率不高

在编写代码的过程中,一般会追求开发效率和执行效率:
开发效率】指的是代码的可读性和可维护性
执行效率】指的是计算机跑得快慢
取余操作开发效率不好,执行效率不好,求余操作并不直观,开发效率不好,取余操作对于计算机来说并不是高效的操作,没有 if 来得快 ,表面看 if 语句有两行代码,但实际上,只有执行 1000 次 操作后,才会进入 if 语句中去,使 head 或 tail 回到开头,而取余操作,则是每次都要进行计算,效率是十分低的~

且在编写代码过程中,经常会牺牲执行效率,提高开发效率,因为程序猿人工成本更高,当然,有一些时候,也会牺牲开发效率,换来运行效率,特殊情况下,追求更高效!

在队列中,还有一个操作是取队首元素但不出队列 peek 操作,这里并未实现
原因是 BlockingQueue 阻塞队列,也提供 peek 方法,但是这个方法不会阻塞,只有 put 和 take 操作有阻塞特性,因此,我们在这里只实现 BlockingQueue 中两个核心方法:put 和 take

5.2 加上线程安全

加上线程安全,在这里即为加锁,用 synchronized 关键字修饰 put()方法和 take()方法,不难分析,在多线程情况下,put 和 take 方法内部有判定条件和修改操作,修改操作不是原子的,线程抢占式执行,因此,加锁即可解决线程不安全问题~

在这里的锁对象是 this,即 MyBlockingQueue 类的实例

在下述代码中,myBlockingQueue 对象被创建出来后,当它调用 take 或者 put 方法时,该对象就是锁对象,即this,分析锁对象是很有必要的,对后面实现阻塞功能具有重要意义~

class MyBlockingQueue {
    private int[] items = new int[1000];
    //约定[head,tail) 队列的有效元素
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //入队列
 synchronized public void put(int elem) {
 		//数组满了
        if(size == items.length) {
            return;
        }
        //把新元素放到tail所在的位置上
        items[tail] = elem;
        tail++;
        //万一达到末尾,就需让tail从头来
        if(tail == items.length) {
            tail = 0;
        }
        //tail = tail % items.length; 不推荐
        size++;
    }

    //出队列
    synchronized public Integer take()  {
    //数组空
        if(size == 0) {
            return null;
        }
        int value = items[head];
        head++;
        if(head == items.length) {
            head = 0;
        }
        size--;
        return value;
    }
}

5.3 加上阻塞功能

我们要实现一个阻塞队列,需要满足阻塞的特性,即队列满时,继续 put() 操作会阻塞,直到有线程有 take() 操作使队列不满;队列为空时,继续 take() 操作会阻塞,直到有线程 put() 操作,使队列不空

通过 wait() 方法 和 notify() 方法配合使用来实现上述阻塞功能!其中调用 wait() 方法 和 notify() 方法的对象必须是和加锁的对象一致,共享一个锁对象,否则会抛出 IllegalMonitorStateException 异常,由于 synchronized 直接加在成员方法上,锁对象就是 this,那么 wait() 方法 和 notify() 方法的对象也就是 this!

实现代码如下:

class MyBlockingQueue {
    private int[] items = new int[1000];
    //约定[head,tail) 队列的有效元素
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //入队列
 synchronized public void put(int elem) {
 		//数组满了
        if(size == items.length) {
            //return;
            this.wait();//队列满了再入队列就阻塞
        }
        //把新元素放到tail所在的位置上
        items[tail] = elem;
        tail++;
        //万一达到末尾,就需让tail从头来
        if(tail == items.length) {
            tail = 0;
        }
        //tail = tail % items.length; 不推荐
        size++;
        this.notify();//唤醒因队列空而阻塞的情况
    }

    //出队列
    synchronized public Integer take()  {
    //数组空
        if(size == 0) {
            //return null;
            this.wait();//队列空了再出队列就阻塞
        }
        int value = items[head];
        head++;
        if(head == items.length) {
            head = 0;
        }
        size--;
        this.notify(); // 唤醒因为队列满而阻塞的情况
        return value;
    }
}

在这里插入图片描述
【注意】一个队列不可能既空又满!

5.4 改进:将 if 改成 while

这里重点注意一下:Java官方并不建议这么使用 wait()方法,即并不建议使用 if 判断,而是使用 while 判断
在这里插入图片描述
wait() 方法是可能被其它方法给中断的,比如 interrupt() 方法,此时 wait() 方法的等待条件还没成熟就被提前唤醒了,因此代码就可能不符合预期了~
在这里插入图片描述
很有可能在别的代码中,暗含着 interrupt 方法,把 wait 方法提前唤醒,而明明条件还没满足,但是 wait() 方法唤醒后,就继续往下走了,在当前这个简单的实例代码中,没有 interrupt ,但是在更复杂的项目里,不能保证没有
interrupt 方法

更稳妥的做法是:在 wait()方法唤醒后,再判定一次条件,wait() 之前,发现条件不满足,开始 wait(),等到 wait() 唤醒后再确认一下条件是否满足,如果不满足,还可以继续 wait()!

应这样修改:
在这里插入图片描述

5.5 最终完整代码

5.5.1 实现阻塞队列完整代码

//就不写带泛型的 自己实现阻塞队列 直接写朴素代码 存储的元素是int
//基于数组来实现 循环队列
class MyBlockingQueue {
    private int[] items = new int[1000];
    //约定[head,tail) 队列的有效元素
    volatile private int head = 0;
    volatile private int tail = 0;
    volatile private int size = 0;

    //入队列
    synchronized public void put(int elem) throws InterruptedException {
        //不只判断一次 多确认
        while(size == items.length) {
            //队列满的插入失败
            //return;
            //加阻塞
            this.wait();
        }
        //把新元素放到tail所在的位置上
        items[tail] = elem;
        tail++;
        //万一达到末尾,就需让tail从头来
        if(tail == items.length) {
            tail = 0;
        }
        //tail = tail % items.length; 不推荐
        size++;
        this.notify();
    }

    //出队列
    synchronized public Integer take() throws InterruptedException {
        while(size == 0) {
            //size为0则没有元素可以出
            this.wait();
        }
        int value = items[head];
        head++;
        if(head == items.length) {
            head = 0;
        }
        size--;
        //有元素出队列 队列不为满 则要唤醒
        this.notify();
        return value;
    }
}

5.5.2 实现生产者消费者模型代码

public class ThreadDemo {

    public static void main(String[] args) {
    MyBlockingQueue queue = new MyBlockingQueue();
    // 消费者
        Thread t1 = new Thread(()-> {
            while(true) {
                try {
                    int value = queue.take();
                    System.out.println("消费:" + value);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 生产者
        Thread t2 = new Thread(()-> {
            int value = 0;
            while(true) {
                try {
                    System.out.println("生产:" + value);
                    queue.put(value);
                    Thread.sleep(1000);
                    value++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();
    }
}

上述代码中是在生产者中加入 Thread.sleep(1000),即每间隔1s,生产一个元素,与 前面 BlockingQueue 使用代码效果一致,生产慢,消费快,运行程序后,可以清楚观察到,元素生产一个,消费者立即消费一个,等待再次生产!

运行结果如下:
在这里插入图片描述
当然也可以在消费者中加入 Thread.sleep(1000),此时就是生产快,消费慢了,生产者迅速生产,很有可能一口气将队列整个元素全部生产出来了,使得队列满生产者进入阻塞状态,直到消费者消费1个元素,生产者才能继续生产~
在这里插入图片描述
💛💛💛本期内容回顾💛💛💛
在这里插入图片描述

✨✨✨本期内容到此结束啦~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值