多线程学习总结(五)

目录

一.前言:

二.阻塞队列的作用及常见应用:

1.阻塞队列的作用:

2.阻塞队列的应用——生产者消费者模型:

三.阻塞队列的基础实现:

四.总结: 


一.前言:

在上一篇笔记中,初步总结了线程池的相关知识点,对线程池有了一个基本的认知,而在总结线程池的自主实现环节中,提到了一个新的结构——阻塞队列,不过在上一个章节中,对于阻塞队列只是初步讲述了一下它的概念而已,那么,它具体的作用和实现,还有上一章中提到的削峰填谷是怎么一回事呢?本章将会进行具体总结。

二.阻塞队列的作用及常见应用:

1.阻塞队列的作用:

在上一章中我们提到,阻塞队列的引入是为了更好的接收和拿去任务线程,防止线程池在一时间接收到过量的任务,而导致一些问题出现,所以我们让任务线程先存储在阻塞队列当中,执行时再一个一个地去取,这里提出一个问题,为什么必须是阻塞队列呢?我随便放到一个队列结构中不也可以起到一个缓冲作用吗?理解这个问题正是理解阻塞队列的关键所在。原因有以下几个:

(1)如果采用一般的队列结构,单看存储和拿取似乎没有什么大问题,但是,既然有拿取就涉及到一个问题,你的结构中它得先有,如果它压根就没有,它为空,那此时进行拿取访问,就会造成非法访问的后果,同理,存储也是一样的,如果队列已满,此时再进行存储就会导致队列结构的越界访问,导致程序运行异常。但这是不是就意味着普通的队列就不能不能用呢?当然不是,只要通过各种判断使其达到阻塞队列的效果,也不是不能用。

(2)为什么阻塞队列可以避免上述问题呢?这里就需要提到阻塞队列的put和take方法了,在上一章的线程池实现中,读取任务线程时我们使用的就是put方法,在阻塞队列的put方法中其实涉及到了一个wait的操作,在前面的学习总结中曾提到,wait方法是由锁对象在加锁操作的内部进行调用的,其功能是使线程进入阻塞等待的状态,直到被notify唤醒或者被interrupt强行唤醒,线程才会恢复到执行状态,而阻塞队列则正是通过wait来避免的队列越界访问。在put方法的内部会进行一个判断,判断队列是否满了,当队列中元素已满的时候,再添加元素,put就会执行wait方法,使线程进入阻塞等待,直到用户进行了take操作,将元素从队列中拿走了,线程才会被唤醒,重新进入执行状态,同理,take方法也是一样,在它的内部也有一个wait方法,跟put也是类似的执行逻辑。

(3)既然有wait和notify就必然有加锁操作,既然有加锁操作,就会涉及到锁竞争问题,但是,在上一章的实现中,我们似乎并没有考虑到这个问题,而且在阻塞队列的内部似乎也没有涉及到,但假如一旦put和take产生了锁竞争,那程序不就没办法执行了吗,那不就卡死了吗?但是真的会这样吗?其实并不会如此,我们来回顾一下(2),put方法的等待条件是什么?队列为满,而take的等待条件又是什么?队列为空。而在等待的过程中,一旦程序读到了take或者put,等待就会被唤醒,加锁就会结束,锁就会被释放。而在这个唤醒的过程中,我认为是不会产生锁竞争的,因为,队列怎么做到即是满的同时又得是空的呢?既然不会出现这种情况,就自然也不会产生锁竞争,也不会出现程序卡死的情况。

通过对以上三点的了解,我们对阻塞队列也就有了一个更深刻的体会,对它的作用也就更加熟知了,阻塞队列正是通过这种阻塞等待起到了一个即能保证程序安全执行又可以达到任务缓冲的效果,而前面所说的“削峰填谷”,正是这种缓冲效果,让过多的请求进行等待,以确保程序安全有效的执行。

2.阻塞队列的应用——生产者消费者模型:

阻塞队列的应用场景有很多,其中最为常见的就是“生产者消费者模型”。何为生产者消费者模型呢?顾名思义,就是有生产元素和消费元素的结构模型,以下总结将会以画图的方式进行,以便理解。 在现实中,一般情况下,生产者和消费者之间是直接联系的,生产者负责产出,消费者则直接取走使用即可,但是,在编程中,生产者和消费者直接联系就可能会造成一些问题,如下图

上图中,多个生产者线程同时向一个消费者线程提供任务,但消费者线程的接收空间有限,导致溢出的任务只能被丢弃,此时就会使得程序出现bug,至于解决方法,在上一章的线程池总结中就已经使用过了,那就是阻塞队列,通过引入一个第三方空间来缓解接收方的压力,如下图,

以上就是一个最简单的生产者消费者模型(简易表示),通过阻塞队列起到一个我们常说的削峰填谷的效果。

三.阻塞队列的基础实现:

在前面阻塞队列作用的总结中其实就已经总结了阻塞队列的主要原理,它主要靠的是其中的put和take方法,而这两个方法则靠的是阻塞等待这样一个效果,前面我们说普通的队列也可以代替阻塞队列,只要实现这样一个效果即可——队列满了再调用put就阻塞等待,队列为空再调用take也要阻塞等待。既然有了以上思路,就可以进行代码上的实际操作了:

第一步,先创建出一个普通的队列,如下:

class my_BlockingQueue{
    private String[] str = new String[1000];
    private int head = 0;
    private int tail = 0;
    private int size = 0;
    public void put(String s) {
        if(size >= str.length) {
            return;
        }
        str[tail] = s;
        tail++;
        if(tail >= str.length) {
            tail = 0;
        }
        size++;
    }
    public String take() {
        if(size == 0) {
            return null;
        }
        String string = str[head];
        head++;
        if(head >= str.length) {
            head = 0;
        }
        size--;
        return string;
    }
}

以上就是一个非常常见的元素为字符串类型的普通队列的实现,这里就讲一下head、tail和size的含义,head表示为队列头,在取元素时使用,当head值和队列长度一致时说明已经按顺序拿完最后一个元素了(但并不是说明队列就为空了),此时再要取就要重新回到队列头去拿。tail表示队列的尾,放元素时使用,和head的执行逻辑类似。size表示队列当前的实际长度,就是里面有多少个元素。

第二步,添加阻塞等待逻辑,当队列满再执行put时就要进入阻塞等待,当队列空再执行take时,也要进入阻塞等待,同时,当队列满后执行take时,就会将原本阻塞等待的线程唤醒,同理,put也是类似的功能,将这些改动代码化后,就是以下效果

class my_BlockingQueue{
    private Object object = new Object();
    private String[] str = new String[1000];
    private int head = 0;
    private int tail = 0;
    private int size = 0;
    public void put(String s) throws InterruptedException {
        synchronized (object) {
            if(size >= str.length) {
                object.wait();
            }
            str[tail] = s;
            tail++;
            if(tail >= str.length) {
                tail = 0;
            }
            size++;
            object.notify();
        }
    }
    public String take() throws InterruptedException {
        synchronized (object) {
            if(size == 0) {
                object.wait();
            }
            String string = str[head];
            head++;
            if(head >= str.length) {
                head = 0;
            }
            size--;
            object.notify();
            return string;
        }
    }
}

经过以上修改,就可以基本实现阻塞队列的效果,不过,阻塞队列的应用场景都是在多线程中,既然涉及到了多线程就不得不考虑到线程安全问题,首先就要看修改操作是否原子,而在上面的代码中,修改操作不仅是非原子的,同时还有很多个修改操作,所以,不妨干脆让整段代码都在加锁操作内部,这样就可以有效避免修改操作的非原子性,其次要考虑的是内存可见性的问题和一些逻辑上的问题,而这些问题就是我们第三步要进行的操作了。

第三步,对于内存可见性问题,这里的put和take方法都涉及到了对head、tail和size的值的频繁修改,而在这种修改过程中就有可能会引发内存可见性的问题,为了彻底避免这个问题的出现,我们只要在这三个变量的定义前加上一个volatile关键字即可。修改完了内存可见性问题我们再来考虑一个逻辑上的问题,注意看wait操作的执行条件——size大于等队列全长,在上面的代码考虑中,我们一直认为的是,队列满后(或者为空)就可以进行加锁操作,然后执行一次相反的操作就可以将其唤醒,但是,如果出现特殊情况,线程被唤醒后,可能依旧为满的或者为空,此时如果只是单纯的if判断就会导致程序执行出现异常,所以,为了避免这种情况,在线程被唤醒后,我们仍需要进行判断,若此时判断条件不满足了,线程才可以真的向下继续执行,综上所述,我们只要将if换成while即可,将以上修改全部代码化后,得到如下代码:

class my_BlockingQueue{
    private Object object = new Object();
    private String[] str = new String[1000];
    private volatile int head = 0;
    private volatile int tail = 0;
    private volatile int size = 0;
    public void put(String s) throws InterruptedException {
        synchronized (object) {
            while(size >= str.length) {
                object.wait();
            }
            str[tail] = s;
            tail++;
            if(tail >= str.length) {
                tail = 0;
            }
            size++;
            object.notify();
        }
    }
    public String take() throws InterruptedException {
        synchronized (object) {
            while(size == 0) {
                object.wait();
            }
            String string = str[head];
            head++;
            if(head >= str.length) {
                head = 0;
            }
            size--;
            object.notify();
            return string;
        }
    }
}

而此时,我们的阻塞队列才算是真正的实现了,不过此处的实现,其接收的元素只能为字符串类型,和真正的阻塞队列还是有一定区别的。

四.总结: 

对于阻塞队列重点在于理解它的原理和应用环境,本身并没有什么太难的知识点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值