前言
前面我們介紹了ArrayBlockingQueue,LinkedBlockingQueue,LinkedBlockingDeque三種阻塞隊列,今天繼續介紹PriorityBlockingQueue和DelayQueue兩個阻塞隊列,在介紹這兩個阻塞隊列之前,需要先了解一種數據結構:二叉堆。因爲PriorityBlockingQueue內部使用了最小二叉堆算法來保證每次彈出的元素是最小元素,而DelayQueue又依賴於PriorityBlockingQueue。
二叉堆
堆的數據結構是一顆完全二叉樹,完全二叉樹指的是除了最後一層,其餘層均有左右子節點。二叉堆又可以分爲最大二叉堆和最小二叉堆。
最大二叉堆的某個結點的值最多與其父結點一樣大,最小堆則是某個結點的值最多與其父結點一樣小。所以最大堆中最大的結點永遠是根結點,最小堆中最小的結點永遠是根節點。
用數組(下標從1開始算)來存儲二叉堆的話有以下幾個特性:
- 第n個位置的子節點分別在index[2n]和index[2n+1]。如index[1]位置的子節點在index[2]和index[3],而index[2]位置的子節點爲index[4]和[5],以此類推。
- 葉子節點的下標爲index[n/2+1]到index[n]。如一個長度爲9的數組中,index[5]到index[9]位置的元素均屬於葉子節點。
- 第n個位置(非根節點)的父節點爲:n/2
如下圖就是一個二叉堆(圓圈內的數字表示數組下標,並不表示真實元素的值):
比如說4這個位置,他的左右子節點就是24和24+1,即8和9
n/2+1=5,說明從5到n即9都是葉子節點。
堆有三種基本操作:初始化,上沉,下浮(下面以最小二叉堆爲例來說明):
- 初始化:將一個無序的數組初始化成堆。從最後一個非葉子結點開始,將父節點和子節點進行比較,如果父節點大於子節點,則將父節點和子節點替換,確保父節點<=子節點
- 上沉:用於插入元素。在數組的末尾插入新的元素,然後和父節點比較,如果比父節點大,則插入完成,如果比父節點小,則交換位置,並以此類推。
- 下浮:用於移除元素。移除頭節點元素,此時會將數組末尾的數據拿過來先放到頭節點,然後和子節點進行比較,如果比子節點大,則交換位置,以此類推。
注意:二叉堆和二叉樹不一樣,二叉堆並不保證左右節點的大小
PriorityBlockingQueue
PriorityBlockingQueue是一個支持優先級的無界阻塞隊列(大小受限於內存)。和前面介紹的三種有界隊列相比,無界隊列的最大區別是即使初始化的時候指定了長度,那麼當隊列元素達到上限後隊列也會自動進行擴容,所以PriorityBlockingQueue在添加元素的時候不會發生阻塞,而如果擴容後的大小超過了內存限制,會拋出OutOfMemoryError錯誤。
默認情況下PriorityBlockingQueue隊列元素採取自然順序升序排列。也可以自定義類實現compareTo()方法來指定元素排序規則,或者在初始化時,可以指定構造參數Comparator來對元素進行排序。
注意:PriorityBlockingQueue不能保證相同優先級元素的順序(即兩個值排序一樣時,不保證順序)。
下面還是先來看看PriorityBlockingQueue類圖:
可以看到提供了4個 構造器:
- PriorityBlockingQueue():
初始化一個默認大小(11)長度的隊列,並使用默認自然排序。 - PriorityBlockingQueue(int):
初始化一個指定大小的長度的隊列,並使用默認自然排序。 - PriorityBlockingQueue(int,Comparator):
初始化一個指定大小的隊列,並按照指定比較器進行排序。 - PriorityBlockingQueue(Collection):
根據傳入的集合進行初始化並堆化,如果當前集合是SortedSet或者PriorityBlockingQueue類型,則保持原有順序,否則使用自然排序進行堆化。
初始化
前面兩個構造器最後都會調用第三個構造器去初始化一個隊列:
我們看到只有一個Condition隊列,這個是用來阻塞出隊線程的,入隊線程不會被阻塞。
接下來我們主要看看第4個構造器,是如何初始化一個隊列的:
public PriorityBlockingQueue(Collection<? extends E> c) {
this.lock = new ReentrantLock();
this.notEmpty = lock.newCondition();
boolean heapify = true; //true表示需要堆化即需要重排序
boolean screen = true; //true表示需要篩選空值
if (c instanceof SortedSet<?>) {
SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
this.comparator = (Comparator<? super E>) ss.comparator();
heapify = false;//如果比較器是SortedSet類型則不需要堆化
}
else if (c instanceof PriorityBlockingQueue<?>) {
PriorityBlockingQueue<? extends E> pq =
(PriorityBlockingQueue<? extends E>) c;
this.comparator = (Comparator<? super E>) pq.comparator();
screen = false;//如果比較器是PriorityBlockingQueue類型則不需要篩選空值
if (pq.getClass() == PriorityBlockingQueue.class) // exact match
heapify = false;//如果pq就是一個PriorityBlockingQueue則不需要堆化
}
Object[] a = c.toArray();
int n = a.length;
// If c.toArray incorrectly doesn't return Object[], copy it.
if (a.getClass() != Object[].class)
a = Arrays.copyOf(a, n, Object[].class);//如果c.torray()失敗,重新複製一個數組
if (screen && (n == 1 || this.comparator != null)) {//??
for (int i = 0; i < n; ++i)
if (a[i] == null)
throw new NullPointerException();
}
this.queue = a;
this.size = n;
if (heapify)
heapify();//堆化(排序)
}
上面標註了?的if判斷,沒想到什麼場景會發生,如果有知道的,懇請留言告知,非常感謝!
這一段代碼看起來很長,實際上就是將指定的集合賦值給隊列,並確認排序規則,如果需要排序,則調用heapify()方法,這個初始化排序纔是關鍵:
下圖就是一個數組[8,5,2,7,6,4,1,9,3]的二叉堆表現形式:
首先看到代碼437行,從二叉堆的特性知道,二叉堆的初始化會從最後一個非葉子節點開始,也就是n/2開始,但是因爲這種算法是基於元素從1開始算的,而數組是從0開始,所以這裏需要減1,也就是從下圖中的位置3(元素7)開始往前面循環。
後面就是兩個排序規則判斷,代碼邏輯是一樣的,我們進入siftDownComparable方法,這個方法主要就是完成元素的下沉操作
主要邏輯爲:
- 將當前循環節點的左右子節點比較,確保拿到最小子節點的下標child
- 再將child對應的元素和父節點比較,確保父節點<最小子節點
- 最後會再次確認當前元素與最小子節點(可能是左也可能是右)的子節點(如果有的話)進行大小比較,依此類推,完成元素下沉。
注意:除了阻塞隊列,我還分享了最新Java架構項目實戰教程+大廠面試題庫,有興趣的點擊此處免費獲取,沒基礎勿進!
第一次下沉
從元素7開始循環,首先將元素9和元素3比較,發現9>3,臨時變量替換一下,
然後將元素7和元素3比較,發現7>3,所以直接將父節點和右子節點替換,完成了第一次循環(因爲子節點已經是葉子節點了,所以不滿足二次循環條件)。
第二次下沉
第二次循環就到了下標2的位置,也就是元素2,和第一次循環類似,因爲子節點是葉子節點,所以也是一次循環就結束,直接完成了父節點和最小子節點的替換,升級過程如下:
第三次下沉
第三次循環就到了下標1的位置,也就是元素5,這時候因爲左子節點本來就小於右子節點,所以不需要臨時替換,直接比較左子節點和父子節點,注意這裏圖2是一個臨時過程,因爲是首先將左子節點賦值給父節點,然後發現左子節點下面還有子節點會再進行一次循環,直到通過break跳出循環之後纔會將5賦值給左子節點,完成替換:
第四次下沉
第四次循環就到了下標0的位置,也就是元素8,首先完成1和3的替換,然後完成3和8的替換:
這時候因爲最小子節點的下標是2,2<half,所以會再次循環(注意再次循環的時候還是拿最開始的元素8來和左右子節點進行比較),然後又會將8和2進行替換,將元素2賦值到下標2的位置,然後這時候不滿足循環條件了,結束循環,這時候才正式將元素8賦值到下標6的位置:
上圖中兩個流程可以看到,元素8會一路下沉到最後。
到這裏完成了初始化排序,最終數組由:[8,5,2,7,6,4,1,9,3]變爲[1,3,2,5,6,4,8,9,7]。
添加元素(生產者)
put(E)方法會調用offer(E)方法,上一篇阻塞隊列的文章中,我們知道,offer(E)方法是不阻塞的,而這裏是無界數組也不會阻塞,所以直接調用offer(E)方法就可以了:
這裏邏輯比較簡單,首先看有沒有越界,越界了就先進行擴容,擴容放在後面講。
然後添加元素主要就是進行上浮過程,進入默認的排序規則上浮方法siftUpComparable:
還是用上面排序後的二叉堆,假如我們現在添加一個元素4,會得到下面這樣一個二叉堆:
這時候爲了確保新添加的元素按照排序規則不會比根節點小,需要將新添加的元素進行上浮操作。
第一次上浮
發現4<6,所以將6放到隊尾,注意這時候4並不會賦值到隊列中,因爲4還需要繼續上浮確認放在哪個位置
第二次上浮
第二次上浮會發現4<3,不滿足所以會跳出循環,確認將4放在了下標4的位置,完成插入元素操作
獲取元素(消費者)
調用take()方法獲取元素
主要看dequeue()方法:
這個方法的主要邏輯爲:
1、先拿到第一個元素(需要返回)和最後一個元素
2、然後將最後一個元素置爲空
3、用存好的最後一個元素的值從頭開始下沉
最後一步下沉操作和初始化的最後一步下沉操作是一樣的處理方式,直到完成下沉就會誕生一個最小的元素重新放到頭節點
擴容
最後我們來分析下擴容tryGrow方法
private void tryGrow(Object[] array, int oldCap) {
lock.unlock(); //擴容前先釋放鎖(擴容可能會費時,先讓出鎖,讓出隊線程可以正常操作)
Object[] newArray = null;
if (allocationSpinLock == 0 &&
UNSAFE.compareAndSwapInt(this, allocationSpinLockOffset,
0, 1)) {//通過CAS操作確保只有一個線程可以擴容
try {
int newCap = oldCap + ((oldCap < 64) ?
(oldCap + 2) : // grow faster if small
(oldCap >> 1));
if (newCap - MAX_ARRAY_SIZE > 0) {//大於當前最大容量則可能溢出
int minCap = oldCap + 1;
if (minCap < 0 || minCap > MAX_ARRAY_SIZE)//擴大一個元素也溢出或者超過最大容量則拋出異常
throw new OutOfMemoryError();
newCap = MAX_ARRAY_SIZE;//擴容後如果超過最大容量,則只擴大到最大容量
}
if (newCap > oldCap && queue == array)
newArray = new Object[newCap];//根據最新容量初始化一個新數組
} finally {
allocationSpinLock = 0;
}
}
if (newArray == null) //如果是空,說明前面CAS失敗,有線程在擴容,讓出CPU
Thread.yield();
lock.lock();//這裏重新加鎖是確保數組複製操作只有一個線程能進行
if (newArray != null && queue == array) {
queue = newArray;
System.arraycopy(array, 0, newArray, 0, oldCap);//將舊的元素複製到新數組
}
}
DelayQueue
DelayQueue是一個支持延時獲取元素的無界阻塞隊列。隊列中使用PriorityQueue來實現。隊列中的元素必須實現Delayed接口:
接口裏定義了一個getDelay方法來獲取當前剩餘的過期時間,另外因實現了Comparable接口,所以還會有一個compareTo方法。
DelayQueue使用示例
1、新建一個對象,實現Delayed ,並重寫getDelay和compareTo
package com.zwx.concurrent.queue.block.model;
import java.sql.Time;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class MyElement implements Delayed {
private long expireTime;//過期時間(毫秒)
private int id;
public long getExpireTime() {
return expireTime;
}
public void setExpireTime(long expireTime) {
this.expireTime = expireTime;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public MyElement(int id, long expireTime) {
this.id = id;
this.expireTime = System.currentTimeMillis() + expireTime;
}
@Override
public long getDelay(TimeUnit unit) {
//類裏面接收的是毫秒,但是getDelay方法在DelayQeue裏面傳的是納秒,所以這裏需要進行一次單位轉換
return unit.convert(expireTime - System.currentTimeMillis(),TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
//注意,這裏的排序要確定最先到期的放在第一位,否則會阻塞住後面未到期的
return Long.valueOf(expireTime).compareTo(((MyElement) o).expireTime);
}
}
package com.zwx.concurrent.queue.block;
import com.zwx.concurrent.queue.block.model.MyElement;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.DelayQueue;
public class DelayQueueDemo {
public static void main(String[] args) {
List<MyElement> list = new ArrayList<>();
for (int i=1;i<=5;i++){
MyElement myElement = new MyElement(i,i*1000);
list.add(myElement);
}
DelayQueue delayQueue = new DelayQueue(list);
while (true){
try {
MyElement myElement = (MyElement) delayQueue.take();
System.out.println(myElement.getId());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
DelayQueue類圖
接下來看看類圖
只有兩個構造器,第一個是空的構造器,第二個是默認初始化一個集合。
初始化
通過循環調用add(e)方法進行添加,然後add方法又去調用了offer(e)方法:
添加元素(消費者)
DelayQueue隊列的元素是存在其內部維護的PriorityQueue上,所以上面調用了q.offer(e)方法。
leader表示獲取到鎖的線程。q.peek()==e表示當前第一個元素就是剛剛添加進去的元素,所以需要將leader設置爲空,喚醒出隊(消費者)線程重新爭搶鎖。
q.offer(e)方法的處理方式基本和上面講的PriorityBlockingQueue中邏輯一致
獲取元素(消費者)
take方法會依次獲取元素,如果第一個元素沒到期,則會一直阻塞:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
E first = q.peek();
if (first == null)
available.await();//隊列爲空,則阻塞
else {
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0)
return q.poll();//如果到期了,則調用poll方法取元素並直接返回
first = null; // don't retain ref while waiting
if (leader != null)
available.await();//頭節點不爲空,說明有線程持有鎖並正在等待到期時間,所以直接阻塞
else {//leader==null
Thread thisThread = Thread.currentThread();
leader = thisThread;//設置頭節點爲當前線程,表名有線程在等待頭節點元素過期
try {
available.awaitNanos(delay);//阻塞指定時間
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}
Leader-Follower線程模型
在Leader-follower線程模型中每個線程有三種模式:
- leader:只有一個線程成爲leader,如DelayQueue如果有一個線程在等待元素到期,則其他線程就會阻塞等待
- follower:會一直嘗試爭搶leader,搶到leader之後纔開始幹活
- processing:處理中的線程
DelayQueue隊列中有一個leader屬性:private Thread leader = null;用到的就是Leader-Follower線程模型。
當有一個線程持有鎖,設置了leader屬性,正在等待元素到期時,則成爲了leader,其他線程就直接阻塞。