ArrayBlockingQueue阻塞队列的原理及源码解析

292 阅读6分钟

一、常见的阻塞队列

BlockingQueue 接口的实现类都被放在了 juc 包中,它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于take与put操作的原理,却是类似的。

队列描述
ArrayBlockingQueue基于数组结构实现的一个有界阻塞队列
LinkedBlockingQueue基于链表结构实现的一个有界阻塞队列
PriorityBlockingQueue支持按优先级排序的无界阻塞队列
DelayQueue基于优先级队列(PriorityBlockingQueue)实现的无界阻塞队列
SynchronousQueue不存储元素的阻塞队列
LinkedTransferQueue基于链表结构实现的一个无界阻塞队列
LinkedBlockingDeque基于链表结构实现的一个双端阻塞队列

二、ArrayBlockingQueue

ArrayBlockingQueue是最典型的有界阻塞队列,其内部是用数组存储元素的,初始化时需要指定容量大小,利用 ReentrantLock 实现线程安全。

在生产者-消费者模型中使用时,如果生产速度和消费速度基本匹配的情况下,使用ArrayBlockingQueue是个不错选择;当如果生产速度远远大于消费速度,则会导致队列填满,大量生产线程被阻塞。

使用独占锁ReentrantLock实现线程安全,入队和出队操作使用同一个锁对象,也就是只能有一个线程可以进行入队或者出队操作;这也就意味着生产者和消费者无法并行操作,在高并发场景下会成为性能瓶颈。

image.png

2-1、ArrayBlockQueue的使用

BlockingQueue queue = new ArrayBlockingQueue(1024); 
//向队列中添加元素
queue.put("1"); 
//从队列中获取元素-使用take()从头节点获取并移除
queue.take();

2-2、ArrayBlockingQueue的原理

2-2-1、ArrayBlockQueue队列的基本结构

2-2-1-1、数据结构

ArrayBlockQueue底层使用数组存储,并且使用先进先出的(FIFO)的规则(使用栈存储一般使用FIFO规则)。

  • 通过看源码可以明确看到ArrayBlockQueue使用Object[]数组进行数据存储,使用takeIndex作为取值的指针用于take, poll, peek, remove操作;使用putIndex作为存储的指针用于put, offer, add。然后使用count用于存储队列中的值

image.png

2-2-1-2、利用独占锁进行读写互斥,使用条件队列进行阻塞

  • 在源码中还是用到了ReentrantLock独占锁用于处理ArrayBlockQueue的读写互斥锁,同时使用Condition notEmpty作为取值时,如果队列中无值时,进行条件判断阻塞操作。使用Condition notFull最为入队时,队列已满,然后阻塞操作。

简单的来说队列已满时,使用notFull进行入队阻塞,当队列值为空时,使用notEmpty进行出队阻塞

image.png

2-2-1-3、构造函数

ArrayBlockQueue有三个构造函数,先说前两个

第一个可以指定队列的大小,默认使用非公平锁
第二个可以同时指定队列的大小和锁类型,第二个构造会完成独占锁的初始化,以及两个条件队列notEmpty和notFull的初始化 image.png

第三个可以指定队列的大小,锁类型,同时值支持传入集合,直接放入队列中

第三个队列会将传入的集合通过for循环的方式将值依次放入队列数组中,同时完成了数组大小count的计算,以及putIndex的指向

image.png

2-2-2、数据的入队及出队

2-2-2-1、put()入队操作

put()入队方法举例来看一下ArrayBlockQueue的操作

image.png

加上注释如下:

public void put(E e) throws InterruptedException {
    //检查是否为空
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    //加一把可中断锁,如果线程中断抛出异常 
    lock.lockInterruptibly();
    try {
        //阻塞队列已满,则将生产者挂起,等待消费者唤醒
        //设计注意点: 用while不用if是为了防止虚假唤醒
        while (count == items.length)
            notFull.await(); //队列满了,使用notFull等待(生产者阻塞)
        // 入队
        enqueue(e);
    } finally {
        lock.unlock(); // 唤醒消费者线程
    }
}

通过以上代码我们可以看到最终使用enqueue()进行入队操作,如下:

private void enqueue(E x) {
    final Object[] items = this.items;
    //入队   使用的putIndex
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;  //设计的精髓: 环形数组,putIndex指针到数组尽头了,返回头部
    count++;
    //notEmpty条件队列转同步队列,准备唤醒消费者线程,因为入队了一个元素,肯定不为空了
    notEmpty.signal();
}

2-2-2-2、take()出队操作

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    //加锁,如果线程中断抛出异常 
    lock.lockInterruptibly();
    try {
        //如果队列为空,则消费者挂起
        while (count == 0)
            notEmpty.await();
        //出队
        return dequeue();
    } finally {
        lock.unlock();// 唤醒生产者线程
    }
}

最终使用dequeue()进行出队操作

private E dequeue() {
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex]; //取出takeIndex位置的元素
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0; //设计的精髓: 环形数组,takeIndex 指针到数组尽头了,返回头部
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    //notFull条件队列转同步队列,准备唤醒生产者线程,此时队列有空位
    notFull.signal();
    return x;
}

通过以上入队出队代码的解析,我们很容易发现,在入队出队的过程中,并没有对数组队列大小的扩容或者删除。

入队的时候通过putIndex指针,向对应的下标放入值,同时++putIndex然后判断++之后的putIndex是否和数组的大小一致,如果一致则将putIndex=0

出队的时候首先通过takeIndex获得值,然后将当前takeIndex指向的下标值设置为null,然后进行++takeIndex,当++takeIndex之后的值和数组大小一致的时候,则takeIndex=0

通过以上入队出队的方式就形成了一个**环形数组**,取值从0到n;存值也是从0到n; 这样的好处就是不用对数组进行删除删除,减少了数组移位带来的影响,比如数组有1000个位置,删了第200个,那么后面800的下标都需要向前进行移位。

image.png

2-2-1-4、takeIndex和putIndex的指向

  • 1、数组指针默认的指向

image.png

  • 2、put入队时候putIndex指针变化

指针默认指向值为null的下标,当值入队成功的时候,指针会下移指向下一个值为null的下标

image.png

  • 3、take获取元素时候指针的变化

获取元素之后,会将当前下标的值设置为null,同时指针下移指向下一个不为null的下标

image.png

2-2-3、小结

使用put入队的时候:

  • 当入队成功的时候会使用notEmpty.signal()对出队的队列进行唤醒,当队列中的值已经满的时候,会使用notFull.await()进行阻塞,让后面入队的线程进行等待,同时++putIndex==items.length?putIndex=0

当使用take出队的时候:

  • 当出队成功的时候会使用notFull.signal()唤醒入队的线程,当队列中的值已经全部完成出队的时候,会使用notEmpty.await()进行阻塞,让后面出队的线程等待。同时++takeIndex==items.length?takeIndex=0

这样入队和出队就形成了互补,互相唤醒,同时在入队和出队的时候,都是用了ReentrantLock独占锁,这样读写就形成了互斥。