面試侃集合 | PriorityBlockingQueue篇

碼農參上發表於2021-06-15

面試官:來了啊小夥子,以前經常有小菜鳥被我虐個兩三輪就不敢來了,看你忍耐力還不錯,以後應該挺能加班的樣子。

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不為空的話,邏輯與上面的方法基本一致,唯一不同的是在進行比較時呼叫的是傳入的自定義comparatorcompare方法。

面試官:剛才你在講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裡阻塞和非阻塞的區別其實並不大,首先因為它是一個無界的佇列,因此新增元素的操作是不會被阻塞的,如果看一下原始碼,你就會發現其他的新增方法addput也是直接呼叫的offer方法。

而取出元素操作會受限制於佇列是否為空,因此可能會發生阻塞,阻塞方法take和非阻塞的poll會稍有不同,如果出現佇列為空的情況,poll會直接返回null,而take會將執行緒在notEmpty上進行阻塞,等待佇列中被新增元素後喚醒。

面試官:嗯,優先順序佇列我們也聊的差不多了,反正都聊了這麼久的佇列了,不介意我們把剩餘的幾個也說完吧?

Hydra:沒問題啊,畢竟我能有什麼選擇呢?

最後

如果覺得對您有所幫助,小夥伴們可以點贊、轉發一下~非常感謝

微信搜尋:碼農參上,來加個好友,點贊之交也好啊~

公眾號後臺回覆“面試”、“導圖”、“架構”、“實戰”,獲得免費資料哦~

相關文章