Java JUC ConcurrentLinkedQueue解析

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

ConcurrentLinkedQueue 原理探究

介紹

ConcurrentLinkedQueue 是執行緒安全的無界非阻塞佇列,底層使用單向連結串列實現,對於入隊和出隊操作使用 CAS 實現執行緒安全。

類圖

ConcurrentLinkedQueue 內部的佇列使用單向連結串列方式實現,其中有兩個volatile型別的Node節點分別用來存放佇列的頭、尾節點。

下面無參建構函式中可以知道,預設頭尾節點都是指向 item 為 null 的哨兵節點,新元素插入隊尾,獲取元素從頭部出隊。

private transient volatile Node<E> head;
private transient volatile Node<E> tail;

public ConcurrentLinkedQueue() {
    head = tail = new Node<E>(null);
}

在 Node 節點中維護一個使用 volatile 修飾的變數 item,用來存放節點的值;next 存放連結串列的下一個節點;其內部則使用 UNSafe 工具類提供的 CAS 演算法來保證出入隊時操作連結串列的原子性。

offer 操作

offer 操作是在隊尾增加一個元素,如果傳遞的引數是 null 則丟擲異常,否則由於 ConcurrentLinkedQueue 是無界佇列,該方法會一直返回 true,另外,由於使用的是 CAS 演算法,所以該方法不會被阻塞掛起呼叫方執行緒。下面我們看原始碼:

public boolean offer(E e) {
        //(1)為null則丟擲異常
        checkNotNull(e);
       //(2)構造Node節點,內部呼叫unsafe.putObject
        final Node<E> newNode = new Node<E>(e);
        //(3)從尾節點插入
        for (Node<E> t = tail, p = t;;) {
            Node<E> q = p.next;
            //(4)如果q == null則說明p是尾節點,執行插入
            if (q == null) {
                //(5)使用CAS設定p節點的next節點
                if (p.casNext(null, newNode)) {
                    //(6)如果p != t,則將入隊節點設定成tail節點,更新失敗了也沒關係,因為失敗了表示有其他執行緒成功更新了tail節點
                    if (p != t) // hop two nodes at a time
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
            }
            else if (p == q)
                //(7)多執行緒操作時,由於poll操作移除元素後可能會把head變成自引用,也就是head的next變成了head,這裡需要重新找新的head
                p = (t != (t = tail)) ? t : head;
            else
                //(8)尋找尾節點
                p = (p != t && t != (t = tail)) ? t : q;
        }
}

我們解析一下這個方法的執行流程,首先當呼叫該方法時,程式碼(1)對傳參進行檢查,為 null 則丟擲異常,否則執行程式碼(2)並使用 item 作為建構函式引數建立一個新的節點,然後程式碼(3)從佇列尾部節點開始迴圈,打算從尾部新增元素,到程式碼(4)時佇列狀態如下圖。

佇列狀態

這時候節點 p、t、head、tail 都指向 item 為 null 的哨兵節點,由於哨兵節點 next 節點為 null,所以 q 也是 null。

程式碼(4)發現 q == null 則執行程式碼(5),使用 CAS 判斷 p 節點的 next 是否為 null,為 null 則使用 newNode 替換 p 的 next 節點,然後執行程式碼(6),但這時候 p == t 所以沒有設定尾部節點,然後退出 offer 方法。目前佇列狀態如下圖:

佇列狀態

剛才我們講解的是一個執行緒呼叫 offer 方法的情況,但是如果多個執行緒同時呼叫,就會存在多個執行緒同時執行到程式碼(5)的情況。

假設執行緒 1 呼叫了 offer(item1),執行緒 2 呼叫了 offer(item2),都同時執行到了程式碼(5),p.casNext(null,newNode)。由於 CAS 是原子性的,我們假設執行緒 1 先執行 CAS 操作,發現 p.next 為 null,則更新為 item1,這時候執行緒 2 也會判斷 p.next 是否為 null,發現不為 null,則跳轉到程式碼(3),然後執行程式碼(4)。這時佇列分佈如下圖:

佇列分佈

隨後執行緒 2 發現不滿足程式碼(4),則跳轉執行程式碼(8),然後把 q 賦值給了 p。佇列狀態如下:

佇列狀態

然後執行緒 2 再次跳轉到程式碼(3)執行,當執行到程式碼(4)時佇列狀態如下圖:

佇列狀態

這時候q == null,所以執行緒 2 會執行程式碼(5),通過 CAS 操作判斷 p.next 節點是否為 null,是 null 則使用 item2 替換,不是則繼續迴圈嘗試。假設 CAS 成功,那麼執行程式碼(6),由於p != t,所以設定 tail 節點為 item2,然後退出 offer 方法。這時候佇列分佈如下:

佇列狀態

到現在我們就差程式碼(7)沒有講過,其實在這一步主要是在執行 poll 操作後才會執行。在執行 poll 可能會發生如下情況:

佇列狀態

我們分析一下如果是這種狀態時呼叫 offer 新增元素,在執行到程式碼(4)時狀態圖如下:

佇列狀態

這裡由於 q 節點不為空並且p == q所以執行到程式碼(7)由於t == tail所以 p 被賦值為 head,然後重新迴圈,迴圈後執行到程式碼(4),佇列狀態如下:

佇列狀態

這時候由於q == null,所以執行程式碼(5)進行 CAS 操作,如果當前沒有其他執行緒執行 offer 操作,則 CAS 操作會成功,p.next 節點被設定為新增節點。然後執行程式碼(6),由於p != t所以設定新節點為佇列的尾部節點,現在佇列狀態如下圖:

佇列狀態

? 需要注意,這裡自引用節點會被垃圾回收掉

總結:offer 操作的關鍵步驟是程式碼(5),通過 CAS 操作來控制同一時刻只有一個執行緒可以追加元素到佇列末尾,而失敗的執行緒會進行迴圈嘗試 CAS 操作,直到 CAS 成功。

add 操作

add 操作是在連結串列末尾新增一個元素,內部還是呼叫的 offer 方法。

public boolean add(E e) {
    return offer(e);
}

poll 操作

poll 操作是在佇列頭部獲取並移除一個元素,如果佇列為空則返回 null。下面看看該方法的實現原理。

public E poll() {
    //(1)goto標記
    restartFromHead:
    //(2)無限迴圈
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            //(3)儲存當前節點
            E item = p.item;
                         //(4)當前節點有值切CAS變為null
            if (item != null && p.casItem(item, null)) {
                //(5)CAS成功後標記當前節點並移除
                if (p != h) // hop two nodes at a time
                    //更新頭節點
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
           //(6)當前佇列為空則返回null
            else if ((q = p.next) == null) {
                updateHead(h, p);
                return null;
            }
            //(7)如果當前節點被自引用,則重新尋找頭節點
            else if (p == q)
                continue restartFromHead;
            else  //(8)如果下一個元素不為空,則將頭節點的下一個節點設定成頭節點
                p = q;
        }
    }
}
final void updateHead(Node<E> h, Node<E> p) {
    if (h != p && casHead(h, p))
        h.lazySetNext(h);
}

由於 poll 方法是從頭部獲取一個元素並移除,所以程式碼(2)內層迴圈是從 head 節點開始,程式碼(3)獲取當前佇列頭節點,佇列一開始為空時佇列狀態如下:

佇列狀態

由於 head 節點指向的 item 為 null 的哨兵節點,所以會執行到程式碼(6),假設這個過程中沒有執行緒呼叫 offer 方法,則此時 q 等於 null,這時佇列狀態如下:

佇列狀態

隨後執行 updateHead 方法,由於h == p,所以沒有設定頭節點,然後返回 null。

假設執行到程式碼(6)時,已經有其它執行緒呼叫了 offer 方法併成功新增了一個元素到佇列,這時候 q 指向的是新增加的元素節點,如下圖:

佇列狀態

然後不滿足程式碼(6)(q = p.next) == null,執行程式碼(7)的時候 p != q則執行程式碼(8),然後將 p 指向了節點 q,佇列狀態如下:

佇列狀態

然後程式又去執行程式碼(3),p 現在指向的不為 null,則執行p.casItem(item, null)通過 CAS 操作嘗試設定 p 的 item 值為 null,如果此時 CAS 成功,執行程式碼(5),此時p != h則設定頭節點為 p,並且設定 h 的 next 節點為自己,poll 然後返回被移除的節點 item。佇列圖如下:

佇列狀態

目前的佇列狀態就是我們剛才講 offer 操作時,執行到程式碼(7)的狀態。

現在我們還有程式碼(7)沒執行過,我們看一下什麼時候執行。假設執行緒 1 執行 poll 操作時,佇列狀態如下:

佇列狀態

那麼執行p.casItem(item, null)通過 CAS 操作嘗試設定 p 的 item 值為 null,假設 CAS 設定成功則標記該節點並從佇列中將其移除。佇列狀態如下:

佇列狀態

然後,由於p != h,所以會執行 updateHead 方法,假如執行緒 1 執行 updateHead 前另外一個執行緒 2 開始 poll 操作,這時候執行緒 2 的 p 指向 head 節點,但是還沒有執行到程式碼(6),這時候佇列狀態如下:

佇列狀態

然後執行緒 1 執行 updateHead 操作,執行完畢後執行緒 1 退出,這時候佇列狀態如下:

佇列狀態

然後執行緒 2 繼續執行程式碼(6), q = p.next,由於該節點是自引用節點,所以p == q,所以會執行程式碼(7)跳到外層迴圈 restartFromHead,獲取當前佇列頭 head,現在的狀態如下:

佇列狀態

總結:poll 方法在移除一個元素時,只是簡單地使用 CAS 操作把當前節點的 item 值設定為 null,然後通過重新設定頭節點將該元素從佇列裡面移除,被移除的節點就成了孤立節點,這個節點會在垃圾回收時被回收掉。另外,如果在執行分支中發現頭節點被修改了,要跳到外層迴圈重新獲取新的頭節點。

peek 操作

peek 操作是獲取佇列頭部一個元素(只獲取不移除),如果佇列為空則返回 null。下面看下其實現原理。

public E peek() {
   //1.
    restartFromHead:
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            //2.
            E item = p.item;
            //3.
            if (item != null || (q = p.next) == null) {
                updateHead(h, p);
                return item;
            }
            //4.
            else if (p == q)
                continue restartFromHead;
            //5.
            else
                p = q;
        }
    }
}

peek 操作的程式碼結構與 poll 操作類似,不同之處在於程式碼(3)中少了 castItem 操作。其實這很正常,因為 peek 只是獲取佇列頭元素值,並不清空其值。根據前面的介紹我們知道第一次執行 offer 後 head 指向的是哨兵節點(也就是 item 為 null 的節點),那麼第一次執行 peek 時在程式碼(3)中會發現item == null,然後執行q = p.next,這時候 q 節點指向的才是佇列裡面第一個真正的元素,或者如果佇列為 null 則 q 指向 null。

當佇列為空時,佇列如下:

佇列狀態

這時候執行 updateHead,由於 h 節點等於 p 節點,所以不進行任何操作,然後 peek 操作會返回 null。

當佇列中至少有一個元素時(假設只有一個),佇列狀態如下:

佇列狀態

這時候執行程式碼(5), p 指向了 q 節點,然後執行程式碼(3),此時佇列狀態如下:

佇列狀態

執行程式碼(3)時發現 item 不為 null,所以執行 updateHead 方法,由於h != p,所以設定頭節點,設定後佇列狀態如下:

佇列狀態

最後剔除哨兵節點。

總結:peek 操作的程式碼與 poll 操作類似,只是前者只獲取佇列頭元素但是並不從佇列裡將它刪除,而後者獲取後需要從佇列裡面將它刪除。另外,在第一次呼叫 peek 操作時,會刪除哨兵節點,並讓佇列的 head 節點指向佇列裡面第一個元素或者為 null

size 操作

計算當前佇列元素個數,在併發環境下不是很有用,因為 CAS 沒有加鎖,所以從呼叫 size 方法到返回結果期間有可能增刪元素,導致統計的元素個數不精確。

public int size() {
    int count = 0;
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            // Collection.size() spec says to max out
            if (++count == Integer.MAX_VALUE)
                break;
    return count;
}
//獲取第一個佇列元素,(剔除哨兵節點),沒有則為null
Node<E> first() {
    restartFromHead:
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            boolean hasItem = (p.item != null);
            if (hasItem || (q = p.next) == null) {
                updateHead(h, p);
                return hasItem ? p : null;
            }
            else if (p == q)
                continue restartFromHead;
            else
                p = q;
        }
    }
}

remove 操作

如果佇列裡面存在該元素則刪除該元素,如果存在多個則刪除第一個,並返回 true,否則返回 false。

public boolean remove(Object o) {
    if (o != null) {
        Node<E> next, pred = null;
        for (Node<E> p = first(); p != null; pred = p, p = next) {
            boolean removed = false;
            E item = p.item;
            if (item != null) {
              // 若不匹配,則獲取next節點繼續匹配
                if (!o.equals(item)) {
                    next = succ(p);
                    continue;
                }
              //相等則使用CAS設定為null
                removed = p.casItem(item, null);
            }
            // 獲取刪除節點的後繼節點
            next = succ(p);
            //如果有前驅節點,並且next節點不為null,則連結前驅節點到next節點
            if (pred != null && next != null) // unlink
              // 將被刪除的節點通過CAS移除佇列
                pred.casNext(p, next);
            if (removed)
                return true;
        }
    }
    return false;
}

contains 操作

判斷佇列裡面是否含有指定物件,由於是遍歷整個佇列,所以像 size 操作一樣結果也不是那麼精確,有可能呼叫該方法時元素還在佇列裡面,但是遍歷過程中其他執行緒才把該元素刪除了,那麼就會返回 false。

public boolean contains(Object o) {
    if (o == null) return false;
    for (Node<E> p = first(); p != null; p = succ(p)) {
        E item = p.item;
        if (item != null && o.equals(item))
            return true;
    }
    return false;
}

總結

ConcurrentLinkedQueue 的底層使用單向連結串列資料結構來儲存佇列元素,每個元素被包裝成一個 Node 節點。佇列是靠頭、尾節點來維護的,建立佇列時頭、尾節點指向一個 item 為 null 的哨兵節點。第一次執行 peek 或者 first 操作時會把 head 指向第一個真正的佇列元素。由於使用非阻塞 CAS 演算法,沒有加鎖,所以在計算 size 時有可能進行了 offer、poll 或者 remove 操作,導致計算的元素個數不精確,所以在併發情況下 size 方法不是很有用。

相關文章