用Java如何設計一個阻塞佇列,然後說說ArrayBlockingQueue和LinkedBlockingQueue

紀莫發表於2021-06-16

前言

用Java如何設計一個阻塞佇列,這個問題是在面滴滴的時候被問到的。當時確實沒回答好,只是說了用個List,然後消費者再用個死迴圈一直去監控list的是否有值,有值的話就處理List裡面的內容。回頭想想,自己真是一個大傻X,也只有我才會這麼設計一個阻塞佇列(再說,我這也不是阻塞的佇列)。
結果自己面試完之後,也沒去總結這部分知識,然後過了一段時間,某教育機構的面試又被問到類似的問題了,只不過是換了一個形式,“請用wait方法和notify方法實現一套有生產者和消費者的這種邏輯”。然後我就又蒙圈了,追悔莫及,為啥我沒有去了解一下這部分知識,所以這次我準備好好總結一下這部分內容。

具體實現

如果說實現一個佇列,那麼一個LinkedList的這種實現了Queue介面的都可以直接使用,或者自己寫一個先進先出的Array都可以。
但是要做到阻塞就還需要進行阻塞的實現,就是說當佇列是空時,如果再繼續從佇列中獲取資料,將會被阻塞,直到有新的資料入佇列才停止阻塞;還有當佇列已經滿了(到達設定的最大容量),再往佇列裡新增元素的操作也會被阻塞,直到有資料從佇列中被移除

這裡首先要有一個鎖,保證同時只能有一個執行緒執行出佇列、同時只能有一個執行緒執行入佇列。而執行出佇列和入佇列的執行緒的阻塞和喚醒,是靠wait()方法和notifyAll()方法來實現的。

程式碼實現如下:

public class MyBlockQueue {

    /**
     * 佇列長度預設為10
     */
    private int limit = 10;
    private Queue queue = new LinkedList<>();

    /**
     * 初始化佇列容量
     * @param limit 佇列容量
     */
    public MyBlockQueue(int limit){
        this.limit = limit;
    }

    /**
     * 入佇列
     * @param object    佇列元素
     * @throws InterruptedException
     */
    public synchronized boolean push(Object object) throws InterruptedException{
        // 如果佇列已滿,再來新增佇列的執行緒就直接阻塞等待。
        while (this.queue.size() == this.limit){
            wait();
        }
        // 如果佇列為空了,就喚醒所有阻塞的執行緒。
        if(this.queue.size() == 0){
            notifyAll();
        }
        // 入隊
        boolean add = this.queue.offer(object);
        return add;
    }

    /**
     * 出佇列
     * @return
     * @throws InterruptedException
     */
    public synchronized Object pop() throws InterruptedException{
        // 如果出佇列時,佇列為空,則阻塞佇列。
        while (this.queue.size() == 0){
            wait();
        }
        // 如果佇列重新滿了之後,喚醒阻塞的所有執行緒。
        if(this.queue.size() == this.limit){
            notifyAll();
        }
        Object poll = this.queue.poll();
        return poll;
    }

}

Java中阻塞佇列的實現

首先我們先來歸納一下,Java中有哪些已經實現好了的阻塞佇列:

佇列 描述
ArrayBlockingQueue 基於陣列結構實現的一個有界阻塞佇列
LinkedBlockingQueue 基於連結串列結構實現的一個有界阻塞佇列
PriorityBlockingQueue 支援按優先順序排序的無界阻塞佇列
DelayQueue 基於優先順序佇列(PriorityBlockingQueue)實現的無界阻塞佇列
SynchronousQueue 不儲存元素的阻塞佇列
LinkedTransferQueue 基於連結串列結構實現的一個無界阻塞佇列
LinkedBlockingDeque 基於連結串列結構實現的一個雙端阻塞佇列

我們這次主要來看一下ArrayBlockingQueueLinkedBlockingQueue這兩個阻塞佇列。
在介紹這兩個阻塞佇列時,先普及兩個知識,就是ReentrantLockCondition的幾個方法。因為JDK中的這些阻塞佇列加鎖時基本上都是通過這兩種方式的API來實現的。

ReentrantLock

  • lock():加鎖操作,如果此時有競爭會進入等待佇列中阻塞直到獲取鎖。
  • lockInterruptibly():加鎖操作,但是優先支援響應中斷。
  • tryLock():嘗試獲取鎖,不等待,獲取成功返回true,獲取不成功直接返回false。
  • tryLock(long timeout, TimeUnit unit):嘗試獲取鎖,在指定的時間內獲取成功返回true,獲取失敗返回false。
  • unlock():釋放鎖。

Condition

通常和ReentrantLock一起使用的

  • await():阻塞當前執行緒,並釋放鎖。
  • signal():喚醒一個等待時間最長的執行緒。

ArrayBlockingQueue

構造方法

首先來看一下ArrayBlockingQueue的初始化方法
ArrayBlockingQueue初始化
ArrayBlockingQueue初始化
ArrayBlockingQueue是有三個構造方法的,但是都是基於ArrayBlockingQueue(int capacity, boolean fair)來實現的,所以只要瞭解這一個構造方法即可。
主要是:

  • 採用陣列結構來初始化佇列,並定義佇列長度;
  • 然後建立全域性鎖,出隊和入隊時都要先獲取鎖再執行操作;
  • 建立阻塞執行緒的非空等待佇列;
  • 建立阻塞執行緒的非滿等待佇列;

入佇列

下面來看一下入佇列操作
ArrayBlockingQueue的put
ArrayBlockingQueue的offer
無論put()方法還是offer()方法,在入佇列時都是先加鎖,然後最終入佇列都是呼叫的enqueue()方法,只不過put方法是阻塞入佇列,就是說如果佇列已滿,入佇列的執行緒會被阻塞,而offer方法則不會阻塞入佇列不成功的執行緒,offer執行入佇列不成功的執行緒直接返回失敗,其實還有一個add方法也是入佇列,和offer方法一直都是非阻塞入隊

下面來一下enqueue()方法。
在這裡插入圖片描述
enqueue()方法其實步驟也不復雜,主要是入佇列操作是從陣列的尾部入,然後出佇列是從佇列的頭部出,這樣當佇列滿了的時候,下一次再入佇列時的位置應該從佇列的頭部開始入了。所以才會有重置putIndex的操作。

如果不能理解可以看下面的圖片,正常佇列未滿時,從陣列尾部入佇列,頭部出佇列。
陣列佇列未滿時正常入隊操作
當佇列滿了之後,入佇列就要從陣列頭部位置開始了。
在這裡插入圖片描述

出佇列

下面來看一下ArrayBlockingQueue的出佇列方法
ArrayBlockingQueue的poll方法
ArrayBlockingQueue的take方法
我們通過上面兩張原始碼的截圖可以看出來,無論是poll()方法還是take()方法,最終出佇列呼叫的都是dequeue()方法,只不過take()是阻塞的方式出佇列,當佇列為空時直接將出佇列執行緒阻塞並放到等待佇列中。

那麼dequeue()是如何出佇列的呢?
ArrayBlockingQueue的dequeue()
我們通過原始碼可知,出佇列是根據出佇列索引takeIndex來決定該出哪一個元素了,如果當前出佇列的元素的索引正好是陣列容量的最後一個元素,那麼出佇列索引takeIndex也要重新從頭開始記錄了。後面再更新迭代器中的資料,以及喚醒阻塞中的入隊執行緒。

還有兩個出佇列的方法remove(Object o)removeAt(final int removeIndex)這兩個方法稍微複雜一些,因為首先要定位到要移除的元素的位置,然後再執行出隊操作,remove最終執行的出隊方法是依賴removeAt(final int removeIndex),而removeAt的出隊操作是定位到要移除的元素位置後,將takeIndex位置的元素替換掉要移除的元素,就完成了出隊操作 。

LinkedBlockingQueue

構造方法

LinkedBlockingQueue的初始化佇列的資料資訊時是在構造方法中進行的,但是實現阻塞佇列需要的核心能力是在JVM為物件分配空間時就初始化好了的。
LinkedBlockingQueue資料初始化
LinkedBlockingQueue的構造方法

入佇列

從初始化資料的時候可以看到,LinkedBlockingQueue是有兩個鎖的,入佇列有入佇列的鎖,出佇列有出佇列的鎖,是兩個獨立的重入鎖。這樣入佇列和出佇列相互對立的處理,大大的提高了佇列的吞吐量。
LinkedBlockingQueue的put方法
LinkedBlockingQueue的offer方法
我們看到LinkedBlockingQueue的入佇列的兩個方法put和offer(其實還有一個add方法,但是具體實現也是呼叫的offer方法),put方法是阻塞入隊,即當佇列滿了的時候阻塞入佇列的執行緒,而offer則不是阻塞入隊,入佇列成功即返回true否則返回false。

這兩個方法底層呼叫的都是enqueue()方法,我們看一下這個方法具體是怎麼執行的入佇列。
LinkedBlockingQueue的enqueue方法
enqueue()方法邏輯比較簡單,就是將元素新增到連結串列的尾部。
LinkedBlockingQueue

出佇列

LinkedBlockingQueue的出佇列方法,是先獲取出佇列的takeLock,然後再執行出佇列方法。
LinkedBlockingQueue的take方法
LinkedBlockingQueue的poll方法
take方法和poll方法前者在佇列為空後,會阻塞出佇列的執行緒,後者poll方法則不會在佇列為空時阻塞出佇列執行緒,會直接返回null。

無論是take方法還是poll方法都是呼叫的dequeue()方法執行的出佇列,那麼看一下dequeue()方法的實現吧。一直忘記說了,我這次貼出來的原始碼都是JDK1.8版本的。
LinkedBlockingQueue的dequeue方法
我們看到dequeue()執行了一個比較繞的邏輯,主要意思是將頭節點後的第一個不為null的節點移除佇列了,並設定了新的頭節點位置。

我們來仔細拆分一下步驟,就好理解了,初始時,頭節點的值是null(new Node(null))但是next指向的是佇列中的第二個節點。

  • 第一步把head節點會把自己的next節點從指向第二節點,改成指向自己,這樣,本來head節點的值就是null,然後現在next也是一個空節點了,這樣的節點GC的時候就會被優先回收掉了。
  • 第二步把原先head節點的下一個節點的值賦值給head,這樣原先的第二節點就成為了head節點,然後將新head節點的資料返回。
  • 將新head節點的值設定為null,這樣就新的節點的也就和原先的head節點的資料形式一樣了。

我們可以通過下面圖來更清晰的看一下:
LinkedBlockingQueue的出佇列
我們再來看一下出佇列的另一個方法remove。
LinkedBlockingQueue的remove方法
執行remove()方法的時候,要將出佇列鎖和入佇列的鎖都加上,這兩個操作要等待remove()方法執行完畢後再操作。為了就是保證在remove()方法尋找指定元素時有入隊和出隊操作導致遍歷操作混亂。

我們再來看一下unlink()方法,主要還是將元素從連結串列中移除,若移除的元素為last元素,做一些處理等。
在這裡插入圖片描述

總結

  • 自己實現了阻塞佇列,首先要有鎖來保證入佇列和出佇列的執行緒在佇列滿和佇列為空時阻塞主入佇列執行緒和出佇列執行緒。然後再佇列有空間後喚醒入佇列執行緒,在佇列有資料時喚醒出佇列執行緒。
  • ArrayBlockingQueueLinkedBlockingQueue都是有界的阻塞佇列(LinkedBlockingQueue的預設長度為Int的最大值也暫且歸為是有界),ArrayBlockingQueue是通過資料來實現阻塞佇列的,並且是依賴ReentrantLockCondition來進行加鎖的。LinkedBlockingQueue是通過連結串列來實現阻塞佇列的,也是依賴ReentrantLockCondition來完成加鎖的。
  • ArrayBlockingQueue採用的全域性唯一鎖,入佇列和出佇列只能有一個操作同時進行,LinkedBlockingQueue入佇列和出佇列分別採用對立的重入鎖,入佇列和出佇列可分開執行,所以吞吐量比ArrayBlockingQueue更高。
  • ArrayBlockingQueue採用陣列來實現佇列,執行過程中並不會釋放記憶體空間,所以需要更多的連續記憶體;LinkedBlockingQueue雖然不需要大量的聯絡記憶體,但是在併發情況下,會建立和置空大量的物件,很依賴GC的處理效率。

相關文章