【併發編程系列9】阻塞隊列之PriorityBlockingQueue,DelayQueue原理分析 前言 二叉堆 PriorityBlockingQueue DelayQueue

前言

前面我們介紹了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方法,這個方法主要就是完成元素的下沉操作


主要邏輯爲:

  1. 將當前循環節點的左右子節點比較,確保拿到最小子節點的下標child
  2. 再將child對應的元素和父節點比較,確保父節點<最小子節點
  3. 最後會再次確認當前元素與最小子節點(可能是左也可能是右)的子節點(如果有的話)進行大小比較,依此類推,完成元素下沉。

注意:除了阻塞隊列,我還分享了最新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,其他線程就直接阻塞。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章