Java JUC LinkedBlockingQueue解析

神祕傑克發表於2022-02-01

阻塞佇列 LinkedBlockingQueue

介紹

上篇介紹了使用CAS演算法實現的非阻塞佇列ConcurrentLinkedQueue,本篇介紹的是使用獨佔鎖實現的阻塞佇列LinkedBlockingQueue。

類圖

該類圖可以看到 LinkedBlockingQueue 也是使用單向連結串列實現的,其中包含head Node,last Node,用來存放頭尾節點;並且還有一個初始值為 0 的原子變數count,用來記錄佇列元素個數;另外還包含兩個 ReentrantLock 例項,分別用來控制元素入隊和出隊的原子性,其中 takeLock 用來控制同一時刻只有一個執行緒可以從佇列頭部獲取元素,putLock 就是控制同一時刻只有一個執行緒可以從佇列尾部新增元素。

notEmpty 和 notFull 是條件變數,他們內部都有一個條件佇列來存放進隊和出隊時被阻塞的執行緒。

LinkedBlockingQueue 的建構函式如下:

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

可以看到預設情況下,LinkedBlockingQueue 佇列容量為 int 最大值,當然我們也可以直接指定容量大小,所以在一定程度上可以說明 LinkedBlockingQueue 是有界阻塞佇列

offer 操作

向佇列尾部插入一個元素,如果佇列有空閒則插入成功返回 true,如果佇列已滿則丟棄當前元素返回 false。如果插入的元素為 null 則丟擲異常。並且該方法是非阻塞的。

public boolean offer(E e) {
    //(1)元素為空則丟擲異常
    if (e == null) throw new NullPointerException();
     //(2)如果佇列已滿則丟棄元素
    final AtomicInteger count = this.count;
    if (count.get() == capacity)
        return false;
    int c = -1;
    //(3)構建新節點,然後獲取putLock獨佔鎖
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        //(4)如果佇列不滿則進入佇列,並增加計數
        if (count.get() < capacity) {
            enqueue(node);
            c = count.getAndIncrement();
            //(5)
            if (c + 1 < capacity)
                notFull.signal();
        }
    } finally {
        //(6)釋放鎖
        putLock.unlock();
    }
      //(7)
    if (c == 0)
        signalNotEmpty();
      //(8)
    return c >= 0;
}

程式碼(1)首先判斷入隊元素是否空,為空丟擲異常。

程式碼(2)則判斷是否佇列已滿,滿的話就丟棄並返回 false。

程式碼(3)構建一個新的 Node 節點,然後獲取 putLock 鎖,當獲取鎖後其他執行緒呼叫 put 或 offer 方法都會被阻塞,放入 putLock 的 AQS 阻塞佇列中。

程式碼(4)判斷佇列是否已滿,為什麼要再判斷一次呢?因為在執行程式碼(2)到獲取鎖之間可能有其它執行緒通過 put 或 offer 新增了新元素,所以再判斷一次是否佇列已滿,不滿則新元素入隊並且增加計數器。

程式碼(5)判斷如果新元素入隊後還有剩餘空間,則喚醒 notFull 條件佇列中正在阻塞的一個執行緒,(喚醒因為呼叫 notFull 的 await 操作的執行緒,比如執行了 put 方法而佇列已滿的時候)。

程式碼(6)釋放獲取的 putLock 鎖,這裡要注意,鎖的釋放一定要在 finally 裡面做,因為即使 try 塊丟擲異常了,finally 也是會被執行到。另外釋放鎖後其他因為呼叫 put 操作而被阻塞的執行緒將會有一個獲取到該鎖。

程式碼(7)c == 0說明在執行程式碼(6)釋放鎖時佇列至少有一個元素,佇列裡面有元素則執行 signalNotEmpty 方法。

private void signalNotEmpty() {
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
}

該方法的作用就是啟用 notEmpty 的條件佇列中因為呼叫 notEmpty 的 await 方法(比如呼叫 take 方法並且佇列為空的時候)而被阻塞的一個執行緒,這也說明了呼叫條件變數的方法前要獲取對應的鎖。

總結:offer 方法通過 putLock 鎖來保證新增的原子性,另外需要注意的就是在呼叫條件變數的時候需要先獲取到對應的鎖,並且入隊只操作連結串列的尾結點。

put 操作

該方法向佇列尾部插入一個元素,如果佇列空閒則插入成功後直接返回,如果佇列已滿則阻塞當前執行緒,直到佇列有空閒後插入成功返回。如果在阻塞時被其他執行緒設定了中斷標誌,則被阻塞執行緒會丟擲異常然後返回。如果傳入的元素是 null 則丟擲異常

public void put(E e) throws InterruptedException {
    //(1)插入元素為空丟擲異常
    if (e == null) throw new NullPointerException();
    int c = -1;
      //(2)構建新節點,並獲取獨佔鎖putLock
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        //(3)如果佇列滿則等待
        while (count.get() == capacity) {
            notFull.await();
        }
        //(4)插入佇列並遞增計數
        enqueue(node);
        c = count.getAndIncrement();
         //(5)
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        //(6)
        putLock.unlock();
    }
    //(7)
    if (c == 0)
        signalNotEmpty();
}

我們可以看到在程式碼(2)中使用putLock.lockInterruptibly()獲取獨佔鎖,相比在 offer 方法中獲取獨佔鎖的方法這個方法可以被中斷。具體地說就是當前執行緒在獲取鎖的過程中,如果被其他執行緒設定了中斷標誌則當前執行緒會丟擲 InterruptedException 異常,所以 put 操作在獲取鎖的過程中是可被中斷的。

程式碼(3)判斷當前佇列已滿,則呼叫notFull.await()方法把當前執行緒放入 notFull 的條件佇列中,然後當前佇列會釋放 putLock 鎖,由於 putLock 鎖被釋放了,所以別的執行緒就有機會獲取到該鎖。

使用 while 而不是 if 是為了防止虛假喚醒問題

poll 操作

從佇列頭部獲取並移除一個元素,如果佇列為空則返回 null,該方法是非阻塞方法。

public E poll() {
    final AtomicInteger count = this.count;
    //(1)佇列為空則返回null
    if (count.get() == 0)
        return null;
    //(2)獲取獨佔鎖
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        //(3)佇列不為空則出隊並且遞減計數
        if (count.get() > 0) {
            x = dequeue();
            c = count.getAndDecrement();
            //(4)
            if (c > 1)
                notEmpty.signal();
        }
    } finally {
        //(5)
        takeLock.unlock();
    }
    //(6)
    if (c == capacity)
        signalNotFull();
    //(7)
    return x;
}
private E dequeue() {
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}

首先程式碼(1)很簡單,先判斷一下當前佇列是否為空,為空直接返回 null。

程式碼(2)則獲取獨佔鎖 takeLock,當前執行緒獲取該鎖後,其他執行緒在呼叫 poll 或 take 方法時會被阻塞。

程式碼(3)判斷如果當前佇列不為空則進行出隊操作,然後遞減計數器。

雖然程式碼(3)中的判斷佇列是否為空和獲取佇列元素不是原子性的,但只有在 poll、take 或者 remove 操作的地方會遞減 count 計數值,但是這三個方法都需要獲取到 takeLock 鎖才能進行操作,而當前執行緒已經獲取了 takeLock 鎖,所以其他執行緒沒有機會在當前情況下遞減 count 計數值,所以看起來不是原子性的,但是它們是執行緒安全的。

程式碼(4)判斷如果c > 1則說明當前執行緒移除掉佇列裡面的一個元素後佇列不為空(c 是刪除元素前佇列元素個數),那麼這時候就可以啟用因為呼叫 take 方法而被阻塞到 notEmpty 的條件佇列裡面的一個執行緒。

程式碼(6)說明當前執行緒移除隊頭元素前當前佇列是滿的,移除隊頭元素後當前佇列至少有一個空閒位置,那麼這時候就可以呼叫 signalNotFull 啟用因為呼叫 put 方法而被阻塞到 notFull 的條件佇列裡的一個執行緒,signalNotFull 的程式碼如下。

private void signalNotFull() {
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        notFull.signal();
    } finally {
        putLock.unlock();
    }
}

peek 操作

獲取佇列頭部元素但是不從佇列裡面移除它,如果佇列為空則返回 null。該方法是非阻塞方法。

public E peek() {
    //(1)
    if (count.get() == 0)
        return null;
     //(2)
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        Node<E> first = head.next;
        //(3)
        if (first == null)
            return null;
        else
            //(4)
            return first.item;
    } finally {
        takeLock.unlock();
    }
}

peek 方法不是很複雜,需要注意的就是這裡需要再判斷一下first == null,不能直接返回 first.item,因為程式碼(1)和程式碼(2)不是原子性的,很可能在程式碼(1)判斷佇列不為空後在獲取鎖前有其它執行緒執行了 poll 或 take 操作導致佇列為空,然後直接返回 fist.item 就會空指標異常。

take 操作

該方法獲取當前佇列頭部元素並從佇列中移除它,如果佇列為空則阻塞當前執行緒直到佇列不為空然後獲取並返回元素,如果在阻塞時被其他執行緒設定了中斷標誌,則阻塞執行緒會丟擲異常。

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    //(1)獲取鎖
    takeLock.lockInterruptibly();
    try {
           //(2)佇列為空則阻塞掛起
        while (count.get() == 0) {
            notEmpty.await();
        }
        //(3)出隊並遞減計數
        x = dequeue();
        c = count.getAndDecrement();
        //(4)
        if (c > 1)
            notEmpty.signal();
    } finally {
        //(5)
        takeLock.unlock();
    }
    //(6)
    if (c == capacity)
        signalNotFull();
    return x;
}

在程式碼(1)中,當前執行緒獲取到獨佔鎖,其他呼叫 take 或者 poll 操作的執行緒將會被阻塞掛起。

程式碼(2)判斷如果佇列為空則阻塞掛起當前執行緒,並把當前執行緒放入 notEmpty 的條件佇列中。

程式碼(3)進行出隊操作並遞減計數。

程式碼(4)判斷如果c > 1則說明當前佇列不為空,那麼喚醒 notEmpty 的條件佇列裡面的一個因為呼叫 take 操作而被阻塞的執行緒。

程式碼(5)釋放鎖。

程式碼(6)判斷如果c == capacity則說明當前佇列至少有一個空閒位置,那就啟用 notFull 的條件佇列裡面的一個因為呼叫 put 操作而被阻塞的執行緒。

remove 操作

刪除佇列裡面指定的元素,有則刪除並返回 true,沒有則返回 false。

public boolean remove(Object o) {
    if (o == null) return false;
    //(1)獲取putLock 和 takeLock
    fullyLock();
    try {
        //(2)遍歷尋找要刪除的元素
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            //(3)
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        //(4)
        return false;
    } finally {
        //(5)
        fullyUnlock();
    }
}
void fullyLock() {
    putLock.lock();
    takeLock.lock();
}

首先程式碼(1)獲取到雙重鎖,然後其他執行緒的入隊和出隊操作都會被掛起。

程式碼(2)遍歷佇列尋找要刪除的元素,找不到返回 false,找到執行 unlink 方法,我們看一下這個方法。

void unlink(Node<E> p, Node<E> trail) {
        p.item = null;
        trail.next = p.next;
        if (last == p)
            last = trail;
        //如果當前佇列滿,再刪除後也要喚醒等待的執行緒
        if (count.getAndDecrement() == capacity)
            notFull.signal();
}

trail 為刪除元素的前驅節點,刪除元素後,如果發現當前佇列有空閒空間,則喚醒 notFull 的條件佇列中的一個因為呼叫 put 方法而被阻塞的執行緒。

程式碼(5)呼叫 fullyUnlock 來進行釋放雙重鎖。

void fullyUnlock() {
    takeLock.unlock();
    putLock.unlock();
}

總結:由於 remove 方法在刪除指定元素前加了兩把鎖,所以在遍歷佇列查詢指定元素的過程中是執行緒安全的,並且此時其他呼叫入隊、出隊操作的執行緒全部會被阻塞。注意,獲取多個資源鎖的順序與釋放的順序是相反的

size 操作

獲取當前佇列元素個數。

public int size() {
    return count.get();
}

由於出隊和入隊操作的 count 都是加鎖的,所以結果相比 ConcurrentLinkedQueue 的 size 方法來說是準確的。

總結

總結佇列圖

LinkedBlockingQueue 的內部是通過單向連結串列實現的,使用頭、尾節點來進行入隊和出隊操作,也就是入隊操作都是對尾節點進行操作,出隊操作都是對頭節點進行操作。

對頭、尾節點的操作分別使用了單獨的獨佔鎖從而保證了原子性,所以出隊和入隊操作是可以同時進行的。另外對頭、尾節點的獨佔鎖都配備了一個條件佇列,用來存放被阻塞的執行緒,並結合入隊、出隊操作實現了一個生產消費模型。

相關文章