死磕Java併發編程(9):無界線程安全隊列ConcurrentLinkedQueue源碼解析

這篇文章理解起來不難,相比於 ConcurrentHashMap 比較簡單,因爲不涉及擴容以及數據遷移等操作,相信你讀完一定會有收穫的。

本文是死磕Java併發編程系列文章的第 9 篇,主角就是 java 併發包中提供的 ConcurrentLinkedQueue 這是一個線程安全且無界的隊列(因爲是基於鏈表實現的,所以無界) ,在併發編程中經常需要用到線程安全的隊列,面試線程池時,其中的隊列也可以採用這個隊列來實現,它線程安全,且採用先進先出的規則排序。

通過整個併發系列文章的學習,我們能想到如果要實現一個線程安全的隊列那麼有兩種方式:一種是使用阻塞方式,即鎖的形式,在出隊、入隊方法加上 synchronized 或者獲取 lock 鎖 等方式實現。另一種是非阻塞方式,使用自旋CAS的方式來實現。 而 Java 併發包中你會看到 Concurrent 開頭的類都是支持併發的,也就是非阻塞的。

這篇文章我們一起來從源碼分析下 併發大師 Doug Lea 是如何使用非阻塞的方式實現線程安全隊列 ConcurrentLinkedQueue 的,相信從大師身上我們能學到不少併發技巧。

ConcurrentLinkedQueue 結構

通過 ConcurrentLinkedQueue 的類圖來分析一下它的結構:

看以看出,ConcurrentLinkedQueue 由 head 節點和 tail 節點組成,每個節點即子類 Node 由 節點屬性 item 和 next(指向下一個節點Node的引用)組成。 即節點之間通過 next 關聯,從而組成一張鏈表。

入隊即每次將元素包裝成節點放到鏈表結尾,出隊從鏈表頭刪除一個元素返回。

瞭解了整體結構,你應該也看出來了,併發隊列 ConcurrentLinkedQueue 最重要的就是倆操作,入隊和出隊,接下來我們直接從源碼層面學習。

入隊操作

入隊過程其實就是將入隊節點添加到隊列尾部,這個入隊操作,其實 Doug Lea 大師做了一些優化,爲了一會看源碼更加清晰,這裏先看一組入隊的過程圖,直觀的瞭解一下優化點。假設現在要插入四個節點:

通過上面入隊的操作,觀察到 head 和 tail 節點的變化,總結其實就是幹了兩件事:第一是將入隊節點設置到當前隊尾節點的 next 節點上;第二就是更新 tail 節點,如果 tail 節點的 next 節點不爲空,則將入隊節點設置爲 tail 節點,如果 tail 節點的 next 節點爲空,則將入隊節點設爲 tail 節點的 next 節點。也就是說 tail 節點並不一定是尾結點,一定要清楚記着這一點,這對理解下面的入隊源碼非常有用。

下面我就不多說了,直接看代碼,理解上面的描述,在結合代碼註釋,相信你一定能看懂:


// 將指定的元素插入到此隊列的末尾,因爲隊列是無界的,所以這個方法永遠不會返回 false 。
public boolean offer(E e) {
    checkNotNull(e);
    // 入隊前,創建一個入隊節點
    final Node<E> newNode = new Node<E>(e);
    // 死循環,入隊不成功反覆入隊
    // 創建一個指向tail節點的引用,p用來表示隊列的尾節點,默認情況下等於tail節點
    for (Node<E> t = tail, p = t;;) {
        // 獲得p節點的下一個節點
        Node<E> q = p.next;
        // next節點爲空,說明p是尾節點
        if (q == null) {
            // p is last node
            // p是尾結點,則設置p節點的next節點爲入隊節點
            if (p.casNext(null, newNode)) {
                // 首先要知道入隊操作不是每次都設置tail節點爲尾結點,爲了減少CAS操作提高性能,也就是說tail節點不總是尾節點
                // 如果tail節點有大於等於1個next節點,則將入隊節點設置成tail節點,
                // p != t 這個條件者結合下面 else 分支看,下面在衝突的時候會修改 p 指向 p.next,所以導致p不等於 tail,
                // 即tail節點有大於等於1個的next節點
                if (p != t) // hop two nodes at a time
                    // 如果tail節點有大於等於1個next節點,則將入隊節點設置成tail節點,
                    // 這裏允許失敗,更新失敗了也沒關係,因爲失敗了表示有其他線程成功更新了tail節點
                    casTail(t, newNode);  // Failure is OK.
                return true;
            }
            // Lost CAS race to another thread; re-read next
        }
        // 這個分支要想進去,即 p 節點等於 p.next 節點,只有一種可能,就是 p 節點和 p.next 節點都爲空
        // 表示這隊列剛初始化,正準備添加節點,所以需要返回head
        else if (p == q)
            // 如果tail變了,說明被其他線程添加成功了,則 p 取新的 tail,否則 p 從 head 開始
            p = (t != (t = tail)) ? t : head;
        else
            // Check for tail updates after two hops.
            // 進行這個分支說明next節點不爲空,說明p不是尾節點,需要更新p後在將它指向next節點
            // 執行 p != t 說明尾結點不等於tail,t != (t = tail)) 說明tail做了變動,
            // 同時滿足說明tail已經重新設置了,即結尾就是tail,否則尾結點取tail.next
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

從源代碼角度來看,整個入隊過程主要做兩件事情:第一是定位出尾節點;第二是使用 CAS 算法將入隊節點設置成尾節點的 next 節點,如不成功則重試。

這裏我們思考一下,上面我們分析出入隊操作在先進先出隊列中就是將其設置爲尾結點, Doug Lea 大師的代碼寫的有點複雜,我們可以不可以用下面的代碼來替代呢?

public boolean offer(E e) {
    if (e == null) 
        throw new NullPointerException();
    Node<E> n = new Node<E>(e); 
    for (;;) {
        Node<E> t = tail;
        if (t.casNext(null, n) && casTail(t, n)) { 
            return true; 
        } 
    } 
}

上面的代碼每次入隊都將 tail 設置爲尾結點,這樣能節省很多的代碼量,並且更加容易理解。 但是這樣做的缺點就是每次都要使用 循環 CAS 來進行設置 tail 節點。如果能減少 CAS 更新 tail 節點則能提高入隊的效率。 但是我們同樣要考慮到,由於 並不是 tail 一定等於尾結點,所以在入隊定位末尾節點時就要多一次循環操作。

但是這樣效率還是高的,因爲 tail 節點它是 volatile 變量,本質上來看是通過增加 volatile 變量的讀來減少 volatile 變量的寫,而 對於 volatile 寫作的開銷是遠遠大於讀操作的,所以入隊效率會提升。大神對於性能的追求真實到了極致,源碼讀起來還是有用的吧!!

出隊操作

說到出隊操作,你肯定會想到,出隊是不是也要減少更新 head 節點,而直接彈出 隊首元素 從而減少 CAS 更新操作以提升性能呢?

帶着這個問題,我們一起往下看,實現爲了便於讀者理解,這裏還是先放一組出隊操作的快照圖。

從圖中得知,並不是每次出隊都會 更新 head 節點,如果 head 節點的有元素時,則直接彈出 head 中的元素,清空該節點對元素的引用。如果 head 節點中元素爲空,纔會更新 head 節點。

有了大概的理解,然後就可以去讀源碼來分析了:

// 從隊頭出隊
public E poll() {
    restartFromHead:
    for (;;) {
        // p 表示頭節點,需要出隊的節點
        for (Node<E> h = head, p = h, q;;) {
            // 獲取p節點的元素
            E item = p.item;
            // 如果頭節點p中元素不爲空,則進行CAS清空p節點對元素的應用,返回p節點的元素
            if (item != null && p.casItem(item, null)) {
                // CAS 設爲成功後進入到這裏,需要判斷頭節點p是否和head節點不是同一個了,即頭節點p已經變更了
                if (p != h) // hop two nodes at a time
                    // 更新head節點,將p節點的next節點設爲head節點,如果p.next不爲空則設置p.next,否則設置p本身
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            // 到這說明頭節點p中的元素爲null或者發生衝突CAS失敗,CAS失敗也說明被別的線程取走了當前元素,所以就該取一個節點即next節點
            // 如果隊列頭節點p的next節點爲空,說明隊列已空,將則head設爲p,返回null
            else if ((q = p.next) == null) {
                updateHead(h, p);
                return null;
            }
            // 進到這裏說明 (q = p.next) == null 返回false,即p的next不爲空
            // 同時q=p.next,而這個分支是說p=p.next,只有隊列初始化時滿足條件,兩者都爲空,則返回head,重頭開始賦值
            else if (p == q)
                continue restartFromHead;
            else
                // 說明p節點的next不爲空,且隊列不是初始化狀態,所以頭節點p指向p.next
                p = q;
        }
    }
}

首先獲取頭節點的元素,然後判斷頭節點元素是否爲空,如果爲空,表示另外一個線程已經進行了一次出隊操作將該節點的元素取走,如果不爲空,則使用CAS的方式將頭節點的引用設置成null,如果CAS成功,則直接返回頭節點的元素,如果不成功,表示另外一個線程已經進行了一次出隊操作更新了head節點,導致元素髮生了變化,需要重新獲取頭節點。

出隊操作時,也是當 head 節點不等於 頭節點 p 時,再次出隊,纔會將 head 設置爲 最新的隊頭節點,減少了 CAS 操作,提升了效率。

總結

  1. ConcurrentLinkedQueue 無界是因爲結構是用鏈表組成的,天生無界,當然受到系統資源大小限制;
  2. ConcurrentLinkedQueue 在入隊和出隊時,均採用了減少 CAS 更新 head 和 tail 的操作,提升了性能;
  3. ConcurrentLinkedQueue 採用非阻塞模式實現,即無鎖,通過自旋和 CAS 實現線程安全;

今天學習的併發包中線程安全的無界隊列 ConcurrentLinkedQueue 源碼也不難,相信會讓你有更深的瞭解,方便以後在工作中使用和應付面試。

筆者水平有限,文章難免會有紕漏,如有錯誤歡迎一起交流探討,我會第一時間更正的。都看到這裏了,碼字不易,可愛的你記得 "點贊" 哦,我需要你的正向反饋。

(全文完)fighting!

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