面試侃集合 | ArrayBlockingQueue篇

碼農參上發表於2021-05-17

面試官:平常在工作中你都用過什麼什麼集合?

Hydra:用過 ArrayList、HashMap,呃…沒有了

面試官:好的,回家等通知吧…

不知道大家在面試中是否也有過這樣的經歷,工作中僅僅用過的那麼幾種簡單的集合,被問到時就會感覺捉襟見肘。在面試中,如果能夠講清一些具有特殊的使用場景的集合工具類,一定能秀的面試官頭皮發麻。於是Hydra苦學半月,再次來和麵試官對線

面試官:又來了老弟,讓我看看你這半個月學了些什麼

Hydra:那就先從ArrayBlockingQueue 中開始聊吧,它是一個具有執行緒安全性阻塞性的有界佇列

面試官:好啊,那先給我解釋一下它的執行緒安全性

Hydra:ArrayBlockingQueue的執行緒安全是通過底層的ReentrantLock保證的,因此在元素出入佇列操作時,無需額外加鎖。寫一段簡單的程式碼舉個例子,從具體的使用來說明它的執行緒安全吧

ArrayBlockingQueue<Integer> queue=new ArrayBlockingQueue(7,
        true, new ArrayList<>(Arrays.asList(new Integer[]{1,2,3,4,5,6,7})));

@AllArgsConstructor
class Task implements Runnable{
    String threadName;
    @Override
    public void run() {
        while(true) {
            try {
                System.out.println(threadName+" take: "+queue.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

private void queueTest(){
    new Thread(new Task("Thread 1")).start();
    new Thread(new Task("Thread 2")).start();
}

在程式碼中建立佇列時就往裡放入了7個元素,然後建立兩個執行緒各自從佇列中取出元素。對佇列的操作也非常簡單,只用到了操作佇列中出隊方法take,執行結果如下:

Thread 1 take: 1
Thread 2 take: 2
Thread 1 take: 3
Thread 2 take: 4
Thread 1 take: 5
Thread 2 take: 6
Thread 1 take: 7

可以看到在公平模式下,兩個執行緒交替對佇列中的元素執行出隊操作,並沒有出現重複取出的情況,即保證了多個執行緒對資源競爭的互斥訪問。它的過程如下:

面試官:那它的阻塞性呢?

Hydra:好的,還是寫段程式碼通過例子來說明

private static void queueTest() throws InterruptedException {
    ArrayBlockingQueue<Integer> queue=new ArrayBlockingQueue<>(3);
    int size=7;
    Thread putThread=new Thread(()->{
        for (int i = 0; i <size ; i++) {
            try {
                queue.put(i);
                System.out.println("PutThread put: "+i+" - Size:"+queue.size());
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread takeThread = new Thread(() -> {
        for (int i = 0; i < size+1 ; i++) {
            try {
                Thread.sleep(3000);
                System.out.println("TakeThread take: "+queue.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });

    putThread.start();
    Thread.sleep(1000);
    takeThread.start();
}

和第一個例子中的程式碼不同,這次我們建立佇列時只指定長度,並不在初始化時就往佇列中放入元素。接下來建立兩個執行緒,一個執行緒充當生產者,生產產品放入到佇列中,另一個執行緒充當消費者,消費佇列中的產品。需要注意生產和消費的速度是不同的,生產者每一秒生產一個,而消費者每三秒才消費一個。執行上面的程式碼,執行結果如下:

PutThread put: 0 - Size:1
PutThread put: 1 - Size:2
PutThread put: 2 - Size:3
TakeThread take: 0
PutThread put: 3 - Size:3
TakeThread take: 1
PutThread put: 4 - Size:3
TakeThread take: 2
PutThread put: 5 - Size:3
TakeThread take: 3
PutThread put: 6 - Size:3
TakeThread take: 4
TakeThread take: 5
TakeThread take: 6

來給你畫個比較直觀的圖吧:

分析執行結果,能夠在兩個方面體現出佇列的阻塞性:

  • 入隊阻塞:當佇列中的元素個數等於佇列長度時,會阻塞向佇列中放入元素的操作,當有出隊操作取走佇列中元素,佇列出現空缺位置後,才會再進行入隊
  • 出隊阻塞:當佇列中的元素為空時,執行出隊操作的執行緒將被阻塞,直到佇列不為空時才會再次執行出隊操作。在上面的程式碼的出隊執行緒中,我們故意將出隊的次數設為了佇列中元素數量加一,因此這個執行緒最後會被一直阻塞,程式將一直執行不會結束

面試官:你只會用puttake方法嗎,能不能講講其他的方法?

Hydra:方法太多了,簡單概括一下插入和移除相關的操作吧

面試官:方法記得還挺清楚,看樣子是個合格的 API caller。下面說說原理吧,先講一下ArrayBlockingQueue 的結構

Hydra:在ArrayBlockingQueue 中有下面四個比較重要的屬性

final Object[] items;
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

在建構函式中對它們進行了初始化:

  • Object[] items:佇列的底層由陣列組成,並且陣列的長度在初始化就已經固定,之後無法改變
  • ReentrantLock lock:用對控制佇列操作的獨佔鎖,在操作佇列的元素前需要獲取鎖,保護競爭資源
  • Condition notEmpty:條件物件,如果有執行緒從佇列中獲取元素時佇列為空,就會在此進行等待,直到其他執行緒向佇列後插入元素才會被喚醒
  • Condition notFull:如果有執行緒試圖向佇列中插入元素,且此時佇列為滿時,就會在這進行等待,直到其他執行緒取出佇列中的元素才會被喚醒

Condition是一個介面,程式碼中的notFullnotEmpty例項化的是AQS的內部類ConditionObject,它的內部是由AQS中的Node組成的等待鏈,ConditionObject中有一個頭節點firstWaiter和尾節點lastWaiter,並且每一個Node都有指向相鄰節點的指標。簡單的來說,它的結構是下面這樣的:

至於它的作用先賣個關子,放在後面講。除此之外,還有兩個int型別的屬性takeIndexputIndex,表示獲取元素的索引位置和插入元素的索引位置。假設一個長度為5的佇列中已經有了3個元素,那麼它的結構是這樣的:

面試官:說一下佇列的插入操作吧

Hydra:好的,那我們先說addoffer方法,在執行add方法時,呼叫了其父類AbstractQueue中的add方法。add方法則呼叫了offer方法,如果新增成功返回true,新增失敗時丟擲異常,看一下原始碼:

public boolean add(E e) {
    if (offer(e))
        return true;
    else
        throw new IllegalStateException("Queue full");
}

public boolean offer(E e) {
    checkNotNull(e);//檢查元素非空
    final ReentrantLock lock = this.lock; //獲取鎖並加鎖
    lock.lock();
    try {
        if (count == items.length)//佇列已滿
            return false;
        else {
            enqueue(e);//入隊
            return true;
        }
    } finally {
        lock.unlock();
    }
}

實際將元素加入佇列的核心方法enqueue

private void enqueue(E x) {
    final Object[] items = this.items;
    items[putIndex] = x; 
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    notEmpty.signal();
}

enqueue中,首先將元素放入陣列中下標為putIndex的位置,然後對putIndex自增,並判斷是否已處於佇列中最後一個位置,如果putIndex索引位置等於陣列的長度時,那麼將putIndex置為0,即下一次在元素入隊時,從佇列頭開始放置。

舉個例子,假設有一個長度為5的佇列,現在已經有4個元素,我們進行下面一系列的操作,來看一下索引下標的變化:

上面這個例子提前用到了佇列中元素被移除時takeIndex會自增的知識點,通過這個例子中索引的變化,可以看出ArrayBlockingQueue就是一個迴圈佇列,takeIndex就相當於佇列的頭指標,而putIndex相當於佇列的尾指標的下一個位置索引。並且這裡不需要擔心在佇列已滿時還會繼續向佇列中新增元素,因為在offer方法中會首先判斷佇列是否已滿,只有在佇列不滿時才會執行enqueue方法。

面試官:這個過程我明白了,那enqueue方法裡最後的notEmpty.signal()是什麼意思?

Hydra:這是一個喚醒操作,等後面講完它的掛起後再說。我還是先把插入操作中的put方講完吧,看一下它的原始碼:

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

put方法是一個阻塞方法,當佇列中元素未滿時,會直接呼叫enqueue方法將元素加入佇列中。如果佇列已滿,就會呼叫notFull.await()方法將掛起當前執行緒,直到佇列不滿時才會被喚醒,繼續執行插入操作。

當佇列已滿,再執行put操作時,就會執行下面的流程:

這裡提前劇透一下,當佇列中有元素被移除,在呼叫dequeue方法中的notFull.signal()時,會喚醒等待佇列中的執行緒,並把對應的元素新增到佇列中,流程如下:

做一個總結,在插入元素的幾個方法中,addoffer以及帶有超時的offer方法都是非阻塞的,會立即返回或超時後立即返回,而put方法是阻塞的,只有當佇列不滿新增成功後才會被返回。

面試官:講的不錯,講完插入操作了再講講移除操作吧

Hydra:還是老規矩,先說非阻塞的方法removepoll,父類的remove方法還是會呼叫子類的poll方法,不同的是remove方法在佇列為空時丟擲異常,而poll會直接返回null。這兩個方法的核心還是呼叫的dequeue方法,它的原始碼如下:

private E dequeue() {
    final Object[] items = this.items;
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        //更新迭代器中的元素
        itrs.elementDequeued();
    notFull.signal();
    return x;
}

dequeue中,在獲取到陣列下標為takeIndex的元素,並將該位置置為null。將takeIndex自增後判斷是否與陣列長度相等,如果相等還是按之前迴圈佇列的理論,將它的索引置為0,並將佇列的中的計數減1。

有一個佇列初始化時有5個元素,我們對齊分別進行5次的出隊操作,檢視索引下標的變化情況:

然後我們還是結合take方法來說明執行緒的掛起和喚醒的操作,與put方法相對,take用於阻塞獲取元素,來看一下它的原始碼:

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

take是一個可以被中斷的阻塞獲取元素的方法,首先判斷佇列是否為空,如果佇列不為空那麼就呼叫dequeue方法移除元素,如果佇列為空時就呼叫notEmpty.await()就將當前執行緒掛起,直到有其他的執行緒呼叫了enqueue方法,才會喚醒等待佇列中被掛起的執行緒。可以參考下面的圖來理解:

當有其他執行緒向佇列中插入元素後:

入隊的enqueue方法會呼叫notEmpty.signal(),喚醒等待佇列中firstWaiter指向的節中的執行緒,並且該執行緒會呼叫dequeue完成元素的出隊操作。到這移除的操作就也分析完了,至於開頭為什麼說ArrayBlockingQueue是執行緒安全的,看到每個方法前都通過全域性單例的lock加鎖,相信你也應該明白了

面試官:好了,ArrayBlockingQueue我懂了,我先去吃個飯,回來我們們再聊聊別的集合

Hydra:……

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

相關文章