死磕java concurrent包系列(四)基于AQS的条件队列彻底理解ArrayBlockingQueue

阻塞队列概览

上篇文章我们分析了AQS中的同步队列和条件队列,而ArrayBlockingQueue和LinkedBlockingQueue正是基于AQS实现的,如果对AQS和ReentrantLock的条件队列不熟悉的话,建议去看https://juejin.im/post/5c053e546fb9a049fc034924,它与我们平时接触的LinkedList和ArrayList相比,最大的特点就是:

  • 阻塞添加 当阻塞队列的元素已经满的时,队列会阻塞加入元素的线程(让线程睡一会),等队列不满时再重新唤醒它执行入队操作
  • 阻塞移出 阻塞移出是在队列元素为空的时候,删除队列元素的线程会被阻塞,直到队列不为空再执行删除操作 我们先看一下代码,BlockingQueue继承自Queue接口:
public interface BlockingQueue<E> extends Queue<E> {


    boolean add(E e); 

    boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; 

    void put(E e) throws InterruptedException; 

    E take() throws InterruptedException; 

    E poll(long timeout, TimeUnit unit) throws InterruptedException; 

    boolean remove(Object o); 
}

    //除了上述方法还有继承自Queue接口的方法 
    //获取但不移除此队列的头元素,没有则跑异常NoSuchElementException 
    E element(); 

    //获取但不移除此队列的头;如果此队列为空,则返回 null。 
    E peek(); 

    //获取并移除此队列的头,如果此队列为空,则返回 null。 
    E poll();

复制代码

总结一下:

  • 插入方法
    • add(E e) :添加到队列,成功则返回true,失败则抛异常
    • offer(E e):成功返回true,如果队列满则返回false
    • put(E e):将元素添加到队列尾部,如果队列满则一直阻塞直到队列有空位为止
  • 删除方法
    • remove(E e) :删除指定元素,成功则返回true,失败则返回false
    • poll(E e):获取并移出队列的头元素,若队列为空,则返回null
    • take(E e):获取并移出队列头元素,若没有元素,则一直阻塞
  • 查询方法
    • element():获取但不移除此队列的头元素,没有则跑异常NoSuchElementException
    • peek():获取但不移除此队列的头;如果此队列为空,则返回 null。

这就是阻塞队列基本的增删查方法,接下来我们看一下如何使用它。 #ArrayBlockingQueue阻塞队列的使用方法 再次回到上一篇文章的场景,基于生产者-消费者,生产者产生烤鸡,消费者消费烤鸡,如果使用ArrayBlockingQueue来实现,会比直接通过condition队列实现简单一些:

package com.springsingleton.demo.Chicken;

import java.util.concurrent.ArrayBlockingQueue;

public class ArrayBlockingQueueTest {

  //定义吃鸡队列,队列大小是1
  private ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(1);

  @SuppressWarnings("unchecked")
  private void product() {
    Chicken chicken = new Chicken();
    try {
      arrayBlockingQueue.put(chicken);
      System.out.println(Thread.currentThread().getName()+" has produced a Chicken");
    }catch (InterruptedException e){
      System.out.println(e.getMessage());
    }
  }

  private void consume(){
    try {
      //每次消费前先睡一秒钟
      Thread.sleep(1000);
      arrayBlockingQueue.take();
      System.out.println(Thread.currentThread().getName()+" has eaten a Chicken");
    }catch (InterruptedException e){
      System.out.println(e.getMessage());
    }
  }

  public static void main(String args[]){
    ArrayBlockingQueueTest arrayBlockingQueueTest = new ArrayBlockingQueueTest();
    new Thread( ()->{
      while (true){
        Thread.currentThread().setName("生产者一号");
        arrayBlockingQueueTest.product();
      }
    }
    ).start();
    new Thread( ()->{
      while (true){
        Thread.currentThread().setName("生产者二号");
        arrayBlockingQueueTest.product();
      }
    }
    ).start();
    new Thread( ()->{
      while (true){
        Thread.currentThread().setName("吃鸡者一号");
        arrayBlockingQueueTest.consume();
      }
    }
    ).start();
    new Thread( ()->{
      while (true){
        Thread.currentThread().setName("吃鸡者二号");
        arrayBlockingQueueTest.consume();
      }
    }
    ).start();
  }
}

复制代码

输出如下:

我们稍微瞥一眼它的构造方法:

//默认非公平阻塞队列
ArrayBlockingQueue queue = new ArrayBlockingQueue(666);
//公平阻塞队列
ArrayBlockingQueue queue1 = new ArrayBlockingQueue(666,true);

//构造方法源码
public ArrayBlockingQueue(int capacity) {
     this(capacity, false);
 }

public ArrayBlockingQueue(int capacity, boolean fair) {
     if (capacity <= 0)
         throw new IllegalArgumentException();
     this.items = new Object[capacity];
     lock = new ReentrantLock(fair);
     notEmpty = lock.newCondition();
     notFull =  lock.newCondition();
 }
复制代码

通过构造方法发现:它的内部通过一个ReentrantLock和两个条件队列构成,既然是ReentrantLock,那么就有公平和非公平之分了,不懂ReetrantLock的去看上一篇文章:juejin.im/post/5c021b… ArrayBlockingQueue中的元素存在公平访问与非公平访问的区别,对于公平访问队列,被阻塞的线程可以按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。而非公平队列,当队列可用时,阻塞的线程将进入争夺访问资源的竞争中,也就是说谁先抢到谁就执行,没有固定的先后顺序。

ArrayBlockingQueue源码分析

ArrayBlockingQueue的内部是通过一个可重入锁ReentrantLock和两个Condition条件对象来实现阻塞,这里先看看其内部成员变量

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    /** 存储数据的数组 */
    final Object[] items;

    /**获取数据的索引,主要用于take,poll,peek,remove方法 */
    int takeIndex;

    /**添加数据的索引,主要用于 put, offer, or add 方法*/
    int putIndex;

    /** 队列元素的个数 */
    int count;


    /** 控制并非访问的锁 */
    final ReentrantLock lock;

    /**notEmpty条件对象,用于通知take方法队列已有元素,可执行获取操作 */
    private final Condition notEmpty;

    /**notFull条件对象,用于通知put方法队列未满,可执行添加操作 */
    private final Condition notFull;


}

复制代码

ArrayBlockingQueue内部确实是通过数组对象items来存储所有的数据,ArrayBlockingQueue通过一个ReentrantLock来同时控制添加线程与移除线程的并发访问,这点与LinkedBlockingQueue区别很大(稍后会分析)。 notEmpty条件队列则是用于存放等待或唤醒调用take方法的线程,告诉他们队列已有元素,可以执行获取操作。 同理notFull条件对象是用于等待或唤醒调用put方法的线程,告诉它们,队列未满,可以执行添加元素的操作。 takeIndex代表的是下一个方法(take,poll,peek,remove)被调用时获取数组元素的索引,putIndex则代表下一个方法(put, offer, or add)被调用时元素添加到数组中的索引。图示如下

ArrayBlockingQueue的阻塞添加

我们先来看看非阻塞的情况,也就是之前总结过得add和offer方法,都是非阻塞的添加到队列,只是一个失败返回fase,另一个会抛异常:

//add方法实现,内部间接调用了offer(e)
public boolean add(E e) {
        if (offer(e))
            return true;
        else
            throw new IllegalStateException("Queue full");
    }

//offer方法
public boolean offer(E e) {
     //非空校验
     checkNotNull(e);
     final ReentrantLock lock = this.lock;
     //加锁
     lock.lock();
     try {
         //判断队列是否满
         if (count == items.length)
             return false;
         else {
              //入队
             enqueue(e);
             return true;
         }
     } finally {
         lock.unlock();
     }
 }

//入队操作
private void enqueue(E x) {
    //获取当前存放数据的数组
    final Object[] items = this.items;
    //通过putIndex索引对数组进行赋值
    items[putIndex] = x;
    //索引自增,如果已是最后一个位置,重新设置 putIndex = 0;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;//队列中元素数量加1
    //唤醒调用take()方法的线程,执行元素获取操作。
    notEmpty.signal();
}
复制代码

源码很简单:其中需要注意的是enqueue(E x)方法,这个方法内部通过putIndex索引直接将元素添加到数组items中,这里可能会疑惑的是当putIndex索引大小等于数组长度时,需要将putIndex重新设置为0,这是因为当前队列执行元素获取时总是从队列头部获取,而添加元素从中从队列尾部获取所以当队列索引(从0开始)与数组长度相等时,下次我们就需要从数组头部开始添加了,如下图演示 :

  • 假设队列总共长度length为5,putindex指向的是最后一个空的array:下标为4

  • 此时元素1被移出:takeindex指向元素2

  • 此时元素5被加入队列:下标为4的putindex自增后恰好等于队列长度5,那么下一次只能从队列头开始添加元素:

接下来我们看看阻塞添加方法put:

//put方法,阻塞时可中断
 public void put(E e) throws InterruptedException {
     checkNotNull(e);
      final ReentrantLock lock = this.lock;
      lock.lockInterruptibly();//该方法可中断
      try {
          //当队列元素个数与数组长度相等时,无法添加元素
          while (count == items.length)
              //将当前调用线程挂起,添加到notFull条件队列中等待唤醒
              notFull.await();
          enqueue(e);//如果队列没有满直接添加
      } finally {
          lock.unlock();
      }
  }

复制代码

put方法是一个阻塞的方法,如果队列元素已满,那么当前线程将会被notFull条件队列挂起加到条件队列中,直到队列有元素被移出才会唤醒执行添加操作。但如果队列没有满,那么就直接调用enqueue(e)方法将元素加入到数组队列中。

总结

三个添加方法即put,offer,add,其中offer,add在正常情况下都是无阻塞的添加,而put方法是阻塞添加。这就是阻塞队列的添加过程。说白了就是当队列满时通过条件对象Condtion来阻塞当前调用put方法的线程,直到线程又再次被唤醒执行。 为了方便理解,总得来说put方法的执行存在以下两种情况:

  • 队列已满,那么新到来的put线程将添加到notFull的条件队列中等待
  • 有移除线程执行移除操作,移除成功同时唤醒put线程,如下图所示 假设队列全满时:

接下来有5个线程通过put方法阻塞入队,他们全部被阻塞,而线程被包装为Node队列存在条件队列中:

此时元素1被移出了,那么会调用notfull.signal方法,唤醒条件队列的WaitNode,waitNode唤醒后,会调用enqueue()方法入队:

ArrayBlockingQueue的阻塞移出

同样的,我们先看非阻塞的移出,poll和remove。 其中:poll(),获取并删除队列头元素,队列没有数据就返回null,内部通过dequeue()方法删除头元素

public E poll() {
      final ReentrantLock lock = this.lock;
       lock.lock();
       try {
           //判断队列是否为null
           return (count == 0) ? null : dequeue();
       } finally {
           lock.unlock();
       }
    }
 //移除队列头元素并返回
 private E dequeue() {
     //拿到当前数组的数据
     final Object[] items = this.items;
      @SuppressWarnings("unchecked")
      //获取要删除的对象
      E x = (E) items[takeIndex];
      将数组中takeIndex索引位置设置为null
      items[takeIndex] = null;
      //takeIndex索引加1并判断是否与数组长度相等,
      //如果相等说明已到尽头,恢复为0
      if (++takeIndex == items.length)
          takeIndex = 0;
      count--;//队列个数减1
      if (itrs != null)
          //同时更新迭代器中的元素数据
          itrs.elementDequeued();
      //移出了元素说明队列有空位,唤醒notFull条件对象添加线程,执行添加操作
      notFull.signal();
      return x;
    }

复制代码

总结就是加锁之后获取要删除的对象(注意,这里的lock和添加时候的lock是同一个lock,意味着同一时间只能添加或者删除,不能并发执行),之后将数组的takeindex进行处理,并在有空位之后唤醒添加队列的线程执行添加操作,接下来看remove方法:

public boolean remove(Object o) {
    if (o == null) return false;
    //获取数组数据
    final Object[] items = this.items;
    final ReentrantLock lock = this.lock;
    lock.lock();//加锁
    try {
        //如果此时队列不为null,这里是为了防止并发情况
        if (count > 0) {
            //获取下一个要添加元素时的索引
            final int putIndex = this.putIndex;
            //获取当前要被删除元素的索引
            int i = takeIndex;
            //执行循环查找要删除的元素
            do {
                //找到要删除的元素
                if (o.equals(items[i])) {
                    removeAt(i);//执行删除
                    return true;//删除成功返回true
                }
                //当前删除索引执行加1后判断是否与数组长度相等
                //若为true,说明索引已到数组尽头,将i设置为0
                if (++i == items.length)
                    i = 0; 
            } while (i != putIndex);//继承查找
        }
        return false;
    } finally {
        lock.unlock();
    }
}

//根据索引删除元素,实际上是把删除索引之后的元素往前移动一个位置
void removeAt(final int removeIndex) {

     final Object[] items = this.items;
      //先判断要删除的元素是否为当前队列头元素
      if (removeIndex == takeIndex) {
          //如果是就简单了:直接删除
          items[takeIndex] = null;
          if (++takeIndex == items.length)
              takeIndex = 0;
          count--;//队列元素减1
          if (itrs != null)
              itrs.elementDequeued();//更新迭代器中的数据
      } else {
      //如果要删除的元素不在队列头部,
      //那么只需循环迭代把删除元素后面的所有元素往前移动一个位置
          //获取下一个要被添加的元素的索引,作为循环判断结束条件
          final int putIndex = this.putIndex;
          //执行循环
          for (int i = removeIndex;;) {
              //获取要删除节点索引的下一个索引
              int next = i + 1;
              //判断是否已为数组长度,如果是从数组头部(索引为0)开始找
              if (next == items.length)
                  next = 0;
               //如果查找的索引不等于要添加元素的索引,说明元素可以再移动
              if (next != putIndex) {
                  items[i] = items[next];//把后一个元素前移覆盖要删除的元
                  i = next;
              } else {
              //在removeIndex索引之后的元素都往前移动完毕后清空最后一个元素
                  items[i] = null;
                  this.putIndex = i;
                  break;//结束循环
              }
          }
          count--;//队列元素减1
          if (itrs != null)
              itrs.removedAt(removeIndex);//更新迭代器数据
      }
      notFull.signal();//唤醒添加线程
    }

复制代码

remove(Object o)方法的删除过程相对复杂些,因为该方法并不是直接从队列头部删除元素,而是删除指定的位置。 首先线程先获取锁,再一步判断队列count>0,这点是保证并发情况下删除操作安全执行。接着获取下一个要添加源的索引putIndex以及takeIndex索引 ,作为后续循环的结束判断,因为只要putIndex与takeIndex不相等就说明队列没有结束。然后通过while循环找到要删除的元素索引,执行removeAt(i)方法删除,在removeAt(i)方法中实际上做了两件事:

  • 一是如果删除的元素正好在队列头,那么就不需要对后面的数组做任何操作,直接删除,并唤醒添加线程即可
  • 二是如果要删除的元素并不是队列头元素,删除之后需要将数组重新reformat一样:从要删除元素的索引removeIndex之后的元素都往前移动一个位置,那么要删除的元素就被removeIndex之后的元素替换,从而也就完成了删除操作。

接着看take()方法,是一个阻塞方法,直接获取队列头元素并删除。

//从队列头部删除,队列没有元素就阻塞,可中断
 public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
      lock.lockInterruptibly();//中断
      try {
          //如果队列没有元素
          while (count == 0)
              //执行阻塞操作
              notEmpty.await();
          return dequeue();//如果队列有元素执行删除操作
      } finally {
          lock.unlock();
      }
    }
复制代码

take方法其实很简单,有就删除没有就阻塞,注意这个阻塞是可以中断的,如果队列没有数据那么就加入notEmpty条件队列等待(有数据就直接取走,dequeue方法之前分析过了),如果有新的put线程添加了数据,那么put操作将会唤醒take线程,执行take操作。图示如下 假设队列全空时:

这个时候有五个线程调用take方法拿元素:
这个时候有有一个元素666被put进队列:

总结

ArrayBlockingQueue内部通过一把锁ReentrantLock和两个AQS条件队列实现了阻塞的入队和删除:

  • 元素满时,阻塞put线程,封装为node节点在notFull条件队列中,此时如果有线程移出元素,在移出后会唤醒notFull条件队列,让条件队列中的put线程继续尝试进行put
  • 元素空时,阻塞take线程,封装为node节点在notEmpty条件队列中,此时如果有线程加入元素,在移出后会唤醒notEmpty条件队列,让条件队列中的take线程继续尝试进行take
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值