死磕java concurrent包系列(四)基於AQS的條件佇列徹底理解ArrayBlockingQueue

lyowish發表於2018-12-11

阻塞佇列概覽

上篇文章我們分析了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();
  }
}

複製程式碼

輸出如下:

QQ20181210-212319.gif
我們稍微瞥一眼它的構造方法:

//預設非公平阻塞佇列
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)被呼叫時元素新增到陣列中的索引。圖示如下

image.png

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

    image.png

  • 此時元素5被加入佇列:下標為4的putindex自增後恰好等於佇列長度5,那麼下一次只能從佇列頭開始新增元素:

    image.png

接下來我們看看阻塞新增方法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執行緒,如下圖所示 假設佇列全滿時:
    image.png

接下來有5個執行緒通過put方法阻塞入隊,他們全部被阻塞,而執行緒被包裝為Node佇列存在條件佇列中:

image.png

此時元素1被移出了,那麼會呼叫notfull.signal方法,喚醒條件佇列的WaitNode,waitNode喚醒後,會呼叫enqueue()方法入隊:

image.png

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操作。圖示如下 假設佇列全空時:

image.png
這個時候有五個執行緒呼叫take方法拿元素:
image.png
這個時候有有一個元素666被put進佇列:
image.png

總結

ArrayBlockingQueue內部通過一把鎖ReentrantLock和兩個AQS條件佇列實現了阻塞的入隊和刪除:

  • 元素滿時,阻塞put執行緒,封裝為node節點在notFull條件佇列中,此時如果有執行緒移出元素,在移出後會喚醒notFull條件佇列,讓條件佇列中的put執行緒繼續嘗試進行put
  • 元素空時,阻塞take執行緒,封裝為node節點在notEmpty條件佇列中,此時如果有執行緒加入元素,在移出後會喚醒notEmpty條件佇列,讓條件佇列中的take執行緒繼續嘗試進行take

相關文章