阻塞佇列概覽
上篇文章我們分析了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