面試官:來了啊小夥子,以前經常有小菜鳥被我虐個兩三輪就不敢來了,看你忍耐力還不錯,以後應該挺能加班的樣子。
Hydra:那可是,我捲起來真的是連我自己都害怕啊!
面試官:那我們們今天就繼續死磕佇列,聊聊PriorityBlockingQueue
吧。
Hydra:沒問題啊,PriorityBlockingQueue
是一個支援優先順序的無界阻塞佇列,之前介紹的佇列大多是FIFO
先進先出或LIFO
後進先出的,PriorityBlockingQueue
不同,可以按照自然排序或自定義排序的順序在佇列中對元素進行排序。
我還是先寫一個例子吧,使用offer
方法向佇列中新增5個隨機數,然後使用poll
方法從佇列中依次取出:
PriorityBlockingQueue<Integer> queue=new PriorityBlockingQueue<Integer>(5);
Random random = new Random();
System.out.println("add:");
for (int i = 0; i < 5; i++) {
int j = random.nextInt(100);
System.out.print(j+" ");
queue.offer(j);
}
System.out.println("\r\npoll:");
for (int i = 0; i < 5; i++) {
System.out.print(queue.poll()+" ");
}
檢視執行結果,可以看到輸出順序與插入順序是不同的,預設情況下最終會按照自然排序的順序進行輸出:
add:
68 34 40 31 44
poll:
31 34 40 44 68
PriorityBlockingQueue
佇列就像下面這個神奇的容器,不管你按照什麼順序往裡塞資料,在取出的時候一定是按照排序完成後的順序出隊的。
面試官:怎麼感覺這功能有點雞肋啊,很多情況下我不想用自然排序怎麼辦?
Hydra:一看你就沒仔細聽我前面講的,除了自然排序外,也可以自定義排序順序。如果我們想改變排序演算法,也可以在構造器中傳入一個Comparator
物件,像下面這麼一改就可以變成降序排序了:
PriorityBlockingQueue queue=new PriorityBlockingQueue<Integer>(10, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
面試官:我就隨口問一句你還真以為我不知道啊,說一下底層是怎麼實現的吧?
Hydra:在講底層的原理之前,就不得不先提一下二叉堆的資料結構了。二叉堆是一種特殊的堆,它的結構和完全二叉樹非常類似。如果父節點的值總小於子節點的值,那麼它就是一個最小二叉堆,反之則是最大二叉堆,並且每個節點的左子樹和右子樹也是一個二叉堆。
以一個最小二叉堆為例:
這個最小二叉堆儲存在陣列中的順序是這樣的:
[1,2,3,4,5,6,7,8,9]
根據它的特性,可以輕鬆的計算出一個節點的父節點或子節點在陣列中對應的位置。假設一個元素在陣列中的下標是t
,那麼父節點、左右子節點的下標計算公式如下:
parent(t) = (t - 1) >>> 1
left(t) = t << 1 + 1
right(t) = t << 1 + 2
以上面的二叉堆中的元素6為例,它在陣列中的下標是5,可以計算出它的父節點下標為2,對應元素為3:
parent(5) = 100 >>> 1 = 2
如果要計算元素4的左右子節點的話,它的下標是3,計算出的子節點座標分別為7,8,對應的元素為8,9:
left(3) = 11 << 1 + 1 = 7
right(3) = 11 << 1 + 2 = 8
在上面計算元素的陣列位置過程中使用了左移右移操作,是不是感覺非常酷炫?
面試官:行了別貧了,鋪墊了半點,趕緊說佇列的底層原理。
Hydra:別急,下面就講了,在PriorityBlockingQueue
中,關鍵的屬性有下面這些:
private transient Object[] queue;
private transient int size;
private transient Comparator<? super E> comparator;
private final ReentrantLock lock;
private final Condition notEmpty;
前面我們也說了,二叉堆可以用陣列的形式儲存,所以佇列的底層仍然是使用陣列來存放元素的。在無參建構函式中,佇列的初始容量是11,comparator
為空,也就是使用元素自身的compareTo
方法來進行比較排序。和ArrayBlockingQueue
類似,底層通過ReentrantLock
實現執行緒間的併發控制, 並使用Condition
實現執行緒的等待及喚醒。
面試官:這麼一看,屬性和ArrayBlockingQueue
還真是基本差不多啊,那結構就介紹到這吧,說重點,元素是怎麼按照排序方法插入的?
Hydra:我們先對offer
方法的執行流程進行分析,如果佇列中元素未滿,且在預設情況下comparator
為空時,按照自然順序排序,會執行siftUpComparable
方法:
private static <T> void siftUpComparable(int k, T x, Object[] array) {
Comparable<? super T> key = (Comparable<? super T>) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = array[parent];
if (key.compareTo((T) e) >= 0)
break;
array[k] = e;
k = parent;
}
array[k] = key;
}
如果佇列為空,那麼元素直接入隊,如果佇列中已經有元素了,那麼就需要判斷插入的位置了。首先獲取父節點的座標,將自己的值和父節點進行比較,可以分為兩種情況:
- 如果新節點的值比父節點大,那麼說明當前父節點就是較小的元素,不需要進行調整,直接將元素新增到隊尾
- 如果新節點的值比父節點小的話,那麼就要進行上浮操作。先將父節點的值複製到子節點的位置,下一次將新節點的值與父節點的父節點進行比較。這一上浮過程會持續進行,直到新節點的值比父節點大,或新節點上浮成為根節點為止
還是以上面資料插入過程為例,來演示二叉樹的構建過程:
在將新元素新增到佇列中後,佇列中元素的計數加1,並且去喚醒阻塞在notEmpty
上的等待執行緒。
面試官:那麼如果不是自然排序的時候,邏輯會發生改變嗎?
Hydra:如果comparator
不為空的話,邏輯與上面的方法基本一致,唯一不同的是在進行比較時呼叫的是傳入的自定義comparator
的compare
方法。
面試官:剛才你在講offer
方法的時候,強調了佇列中元素未滿這一個條件,開始的時候不是說PriorityBlockingQueue
是一個無界佇列麼,那為什麼還要加這一個條件?
Hydra:雖然說它是一個無界佇列,但其實佇列的長度上限是Integer.MAX_VALUE - 8
,並且底層是使用的陣列儲存元素,在初始化陣列的時候也會指定一個長度,如果超過這個長度的話,那麼就需要進行擴容,執行tryGrow
方法:
private void tryGrow(Object[] array, int oldCap) {
lock.unlock(); // 釋放鎖
Object[] newArray = null;
if (allocationSpinLock == 0 &&
//cas 加鎖
UNSAFE.compareAndSwapInt(this, allocationSpinLockOffset,0, 1)) {
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 {
//釋放cas鎖標誌位
allocationSpinLock = 0;
}
}
//其他執行緒正在擴容,讓出CPU
if (newArray == null) // back off if another thread is allocating
Thread.yield();
//加獨佔式鎖,拷貝原先佇列中的資料
lock.lock();
if (newArray != null && queue == array) {
queue = newArray;
System.arraycopy(array, 0, newArray, 0, oldCap);
}
}
先說鎖的操作,在進行擴容前,會先釋放獨佔式的lock
,因為擴容操作需要一定的時間,如果在這段時間內還持有鎖的話會降低佇列的吞吐量。因此這裡使用cas
的方式保證擴容這一操作本身是排他性的,即只有一個執行緒來實現擴容。在完成新陣列的申請後,會釋放cas
鎖的標誌位,並在拷貝佇列中原有資料到新陣列前,再次加獨佔式鎖lock
,保證執行緒間的資料安全。
至於擴容操作也很簡單,假設當前陣列長度為n
,如果小於64的話那麼陣列長度擴為2n+2
,如果大於64則擴為1.5n
,並且擴容後的陣列不能超過上面說的上限值。申請完成新的陣列空間後,使用native
方法實現資料的拷貝。
假設初始長度為5,當有新元素要入隊時,就需要進行擴容,如圖所示:
面試官:ok,講的還不賴,該說出隊的方法了吧?
Hydra:嗯,有了前面的基礎,出隊過程理解起來也非常簡單,還是以自然排序為例,看一下dequeue
方法(省略了部分不重要的程式碼):
private E dequeue() {
int n = size - 1;
// ...
Object[] array = queue;
E result = (E) array[0];
E x = (E) array[n];
array[n] = null;
// ...
siftDownComparable(0, x, array, n);
// ...
size = n;
return result;
}
如果佇列為空,dequeue
方法會直接返回null
,否則返回陣列中的第一個元素。在將隊尾元素儲存後,清除隊尾節點,然後呼叫siftDownComparable
方法,調整二叉堆的結構,使其成為一個新的最小二叉堆:
private static <T> void siftDownComparable(int k, T x, Object[] array,int n) {
if (n > 0) {
Comparable<? super T> key = (Comparable<? super T>)x;
int half = n >>> 1; // loop while a non-leaf
while (k < half) {
int child = (k << 1) + 1; // assume left child is least
Object c = array[child];
int right = child + 1;
if (right < n &&
((Comparable<? super T>) c).compareTo((T) array[right]) > 0)
c = array[child = right];
if (key.compareTo((T) c) <= 0)
break;
array[k] = c;
k = child;
}
array[k] = key;
}
}
首先解釋一下half
的作用,它用來尋找佇列的中間節點,所有非葉子節點的座標都不會超過這個half
值。分別以樹中含有奇數個節點和偶數個節點為例:
[n=9] 1001 >>> 1 =100 =4
[n=8] 1000 >>> 1 =100 =4
可以看到,奇數和偶數的情況下計算出的half
值都是4,即非葉子節點的下標不會超過4,對應上圖中的元素為5。
面試官:計算二叉樹最後非葉子節點座標這點知識,大一學過資料結構的新生都知道,趕緊說正題!
Hydra:著什麼急啊,前面我們也說了,在將堆頂元素取出後,堆頂位置的元素出現空缺,需要調整堆結構使二叉堆的結構特性保持不變。這時候比較簡單的方法就是將尾結點直接填充到堆頂,然後從堆頂開始調整結構。
因此在程式碼中,每次執行堆頂節點的出隊後,都將尾節點取出,然後從根節點開始向下比較,這一過程可以稱為下沉。下沉過程從根節點開始,首先獲取左右子節點的座標,並取出儲存的元素值較小的那個,和key
進行比較:
- 如果
key
比左右節點都要小,那麼說明找到了位置,比較結束,直接使用它替換父節點即可 - 否則的話,調整二叉堆結構,將較小的子節點上浮,使用它替換父節點。然後將用於比較的父節點座標
k
下移調整為較小子節點,準備進行下一次的比較
別看我白話這麼一大段,估計你還是不明白,給你畫個圖吧,以上面的佇列執行一次poll
方法為例:
後面的操作也是以此類推,分析到這出隊操作也就結束了,PriorityBlockingQueue
也沒什麼其他好講的了。
面試官:我發現你現在開始偷懶了,前面的面試裡你還分一下阻塞和非阻塞方法,現在不說一下這兩種方式的區別就想矇混過關了?
Hydra:嗨,在PriorityBlockingQueue
裡阻塞和非阻塞的區別其實並不大,首先因為它是一個無界的佇列,因此新增元素的操作是不會被阻塞的,如果看一下原始碼,你就會發現其他的新增方法add
、put
也是直接呼叫的offer
方法。
而取出元素操作會受限制於佇列是否為空,因此可能會發生阻塞,阻塞方法take
和非阻塞的poll
會稍有不同,如果出現佇列為空的情況,poll
會直接返回null
,而take
會將執行緒在notEmpty
上進行阻塞,等待佇列中被新增元素後喚醒。
面試官:嗯,優先順序佇列我們也聊的差不多了,反正都聊了這麼久的佇列了,不介意我們把剩餘的幾個也說完吧?
Hydra:沒問題啊,畢竟我能有什麼選擇呢?
最後
如果覺得對您有所幫助,小夥伴們可以點贊、轉發一下~非常感謝
微信搜尋:碼農參上,來加個好友,點贊之交也好啊~
公眾號後臺回覆“面試”、“導圖”、“架構”、“實戰”,獲得免費資料哦~