JDK併發工具類源碼學習系列——ConcurrentLinkedQueue

歡迎閱讀原文:JDK併發工具類源碼學習系列目錄

上一篇文章介紹了JDK java.util.concurrent包下很重要的一個類:ConcurrentHashMap,今天來看下另一個重要的類——ConcurrentLinkedQueue。
在多線程編程環境下併發安全隊列是不可或缺的一個重要工具類,爲了實現併發安全可以有兩種方式:一種是阻塞式的,例如:LinkedBlockingQueue;另一種即是我們將要探討的非阻塞式,例如:ConcurrentLinkedQueue。相比較於阻塞式,非阻塞的最顯著的優點就是性能,非阻塞式算法使用CAS來原子性的更新數據,避免了加鎖的時間,同時也保證了數據的一致性。

簡單介紹

ConcurrentLinkedQueue是一個基於鏈接節點的無界線程安全隊列,它採用先進先出的規則對節點進行排序,當我們添加一個元素的時候,它會添加到隊列的尾部,當我們獲取一個元素時,它會返回隊列頭部的元素。它採用了“wait-free”算法來實現,該算法在Michael & Scott算法上進行了一些修改, Michael & Scott算法的詳細信息可以參見參考資料一

結構預覽

首先看看結構圖:

圖1:ConcurrentLinkedQueue結構圖:
ConcurrentLinkedQueue結構圖
從圖中可以看到ConcurrentLinkedQueue中包含兩個內部類:Node<E>和Itr。Node<E>用來表示ConcurrentLinkedQueue鏈表中的一個節點,通過Node<E>的next字段指向下一個節點,從而形成一個鏈表結構;Itr實現Iterator<E>接口,用來遍歷ConcurrentLinkedQueue。ConcurrentLinkedQueue中的方法不多,其中最主要的兩個方法是:offer(E)和poll(),分別實現隊列的兩個重要的操作:入隊和出隊。

方法 含義
offer(E) 插入一個元素到隊列尾部
poll() 從隊列頭部取出一個元素
add(E) 同offer(E)
peek() 獲取頭部元素,但不刪除
isEmpty() 判斷隊列是否爲空
size() 獲取隊列長度(元素個數)
contains(Object) 判斷隊列是否包含指定元素
remove(Object) 刪除隊列中指定元素
toArray(T[]) 將隊列的元素複製到一個數組
iterator() 返回一個可遍歷該隊列的迭代器

下面會着重分析offer(E)和poll()兩個方法,同時會講解remove(Object)和iterator()方法。

常用方法解讀

入隊——offer

首先看看入隊操作,由於是無阻塞的隊列,所以整個入隊操作是在無鎖模式下進行的,下面來分析下JDK到底是如何實現無鎖並保證安全性的。

/**
 * Inserts the specified element at the tail of this queue.
 *
 * @return <tt>true</tt> (as specified by {@link Queue#offer})
 * @throws NullPointerException if the specified element is null
 */
public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    Node<E> n = new Node<E>(e, null);
    for (;;) {//①
        Node<E> t = tail;//②
        Node<E> s = t.getNext();//②
        if (t == tail) {//③
            if (s == null) {//④
                if (t.casNext(s, n)) {//⑥
                    casTail(t, n);//⑦
                    return true;
                }
            } else {
                casTail(t, s);//⑤
            }
        }
    }
}

代碼不長,但是思路還是很巧妙的,下面我們逐句深入分析每一行代碼。if (e == null) throw new NullPointerException(); Node n = new Node(e, null);檢查NULL,避免NullPointerException,然後創建一個Node,該Node的item爲傳入的參數e,next爲NULL。for (;;) {}接着是一個死循環,死循環保證該入隊操作能夠一直重試直至入隊成功。Node t = tail; Node s = t.getNext();使用局部變量t引用tail節點,同時獲取tail節點的next節點,賦予變量s。if (t == tail) {}只有在t==tail的情況下才會執行入隊操作,否則進行下一輪循環,直到t==tail,因爲是無鎖模式,所以如果同時有多個線程在執行入隊操作,那麼在一個線程讀取了tail之後,很可能會有其他線程已經修改了tail(此處的修改是指將tail指向另一個節點,所以t還引用着原來的節點,導致t!=tail,而並非是修改了tail所指向的節點的值),此處的判斷避免了一開始的錯誤,但是並不能保證後續的執行過程中不會插入其他線程的操作,其實ConcurrentLinkedQueue的設計使得if內的代碼即使在有其他線程插入的情況下依舊能夠很好地執行,下面我們接着分析。

if (s == null) {} else { casTail(t, s); }這裏判斷s(tail的next是否爲NULL),如果不爲NULL,則直接將tail指向s。這裏需要說明一下:由於tail指向的是隊列的尾部,所以tail的next應該始終是NULL,那麼當發生tail的next不爲NULL,則說明當前隊列處於不一致狀態,這時當前線程需要幫助隊列進入一致性狀態,這就是ConcurrentLinkedQueue設計的巧妙之處!那麼如果幫助隊列進入一致性狀態呢?這個問題我們先留着,繼續看什麼情況下會導致隊列進入不一致狀態!

if (t.casNext(s, n)) {
    casTail(t, n);
    return true;
}

這幾句代碼完成了入隊的操作,第一步CAS的設置t(指向tail)的next爲n(新創建的節點),該更新操作能夠完成的前提是t的next值==s,即tail的next值在該線程首次讀取期間並未發生變化。此處的CAS操作保證了tail的next值更新的原子性,所以不會出現不一致情況。當成功更新了tail的next節點之後,接下來就是原子性的更新tail爲n,此處如果更新成功,則入隊順利完成完成,但是奇怪的是如果此處更新失敗,入隊依舊是成功的!爲什麼呢?看下文。

我們試想如果一個線程成功的原子性更新了tail的next值爲新創建的節點,由於Node的next是volatile修飾的,所以會立即被之後的所有線程可見,那麼就會出現tail未變化但是tail的next已經不是NULL了,此時就會出現上面提到的tail的next不爲NULL的情況了,現在我們再來看看上面是如何處理這種情況的,casTail(t, s);,從這句可以看出當一個線程看到tail的next不爲NULL時就會直接將tail更新成s(tail的next所指向的節點),即將tail指向其next節點,當然這裏的更新也是CAS保證的原子性更新。爲什麼敢這麼大膽,正是因爲如果當前線程(T1)看到tail的next不爲NULL,那麼必然是有一個線程(T2)處於入隊操作中,且成功執行了t.casNext(s, n)(將新創建的節點賦到tail的next上),正準備執行casTail(t, n);(將tail執行其next指向的節點),那麼T1直接將T2準備做的工作完成,然後再進入循環重新進行入隊操作,而T2也不在乎自己這一步是否順利完成,反正只要有人完成了就行,所以T2就直接返回入隊成功,最終T1幫助T2順利完成了入隊操作,並且全程無鎖,此設計真的是巧妙啊~~~

下面我們使用流程圖形象的描繪下入隊過程,整個入隊方法被劃分成7步(見上面的代碼中的註釋)。說明:雖然入隊是在無鎖模式下進行,但是由於使用CAS進行原子性更新,所以很多地方其實還是實現了線程安全的,除了⑥->⑦,下面的圖描繪的也正是⑥->⑦這一步可能出現的衝突情況。

圖2:ConcurrentLinkedQueue入隊流程圖:
ConcurrentLinkedQueue入隊流程圖

上面介紹了ConcurrentLinkedQueue是如何實現無鎖入隊的,但是我們只說明瞭多個線程同時入隊操作是線程安全的,但是如果多個線程同時進行入隊和出隊,以及刪除操作呢?這個問題在下面分析另外兩個方法時會提到,同時最後也會進行一個總結,下面我們先看看刪除操作是如何實現的。

刪除——remove

先介紹刪除,是因爲出隊操作有個地方需要在這裏提前介紹下。

public boolean remove(Object o) {
    if (o == null) return false;// ①
    for (Node<E> p = first(); p != null; p = p.getNext()) {// ②
        E item = p.getItem();// ③
        if (item != null &&
            o.equals(item) &&
            p.casItem(item, null))// ④
            return true;
    }
    return false;
}

源碼中的註釋申明瞭remove方法會使用equals()判斷兩個節點的值與待刪除的值是否相同,同時如果隊列有多個與待刪除值相同的節點則只刪除最前面的一個節點。

同樣remove()方法也是無鎖模式,①判斷是否爲NULL,②從隊列頭部開始查找,③獲取每個節點的item值,用於跟o進行equals比較,前面三步都很平常,重點在④,if (item != null && o.equals(item) && p.casItem(item, null))這裏首先判斷item不爲NULL,然後判斷item與o相等,前面兩個都滿足的話,那說明已經查找到一個節點的值與待刪除的值一樣,後面就是刪除該節點,這裏刪除其實並非真的刪除,而只是原子性的將節點的item值設置爲NULL。從上面的分析可以看出ConcurrentLinkedQueue的刪除只是將隊列中的某個節點值置爲NULL,由於Node的item是volatile的,所以不存在線程安全問題,同時由於remove並未修改隊列的結構,所以多個線程同時進行remove,或者同其他方法一起進行也不會發生線程安全性問題。

出隊——poll

出隊從邏輯上來說就是從隊列的頭部往外取出數據並刪除,下面看看ConcurrentLinkedQueue是如何實現無鎖出隊的。

public E poll() {
    for (;;) {// ①
        Node<E> h = head;// ②
        Node<E> t = tail;// ②
        Node<E> first = h.getNext();// ②
        if (h == head) {// ③
            if (h == t) {// ④
                if (first == null)// ⑤
                    return null;
                else
                    casTail(t, first);// ⑥
            } else if (casHead(h, first)) {// ⑦
                E item = first.getItem();// ⑧
                if (item != null) {// ⑨
                    first.setItem(null);// ⑩
                    return item;
                }
                // else skip over deleted item, continue loop,
            }
        }
    }
}

出隊的步驟略多些,不過理解了也就很簡單了。首先①是一個死循環;②的三步分別是獲取head/tail/head.next三個節點;③判斷h==head,避免操作過程中已有其他線程移動了head;④判斷head是否等於tail,即隊列是否爲NULL,說到這裏我們先來看看head和tail在隊列中到底處於什麼位置。我們用一個隊列入隊出隊的時序圖來描繪下在入隊和出隊過程中head和tail到底是如何變化的。

圖3:ConcurrentLinkedQueue隊列時序圖:
ConcurrentLinkedQueue隊列
從圖中我們可以看出head的next指向的是隊列的第一個元素,我們出隊也是將head的next指向的元素出隊,同時head==tail說明隊列已經沒有元素了。明白了這兩點我們再接着④分析,如果④這裏爲真,說明隊列已經爲NULL,接着⑤判斷f(head的next指向的節點)是否爲NULL,不爲NULL則執行⑥將tail指向f,到這裏如果理解了上面入隊操作,那麼應該是可以理解這一步的用意的——幫助其他線程執行入隊操作,跟入隊時的⑤是一樣的,因爲head==tail,head的next不爲NULL,則說明tail的next不爲NULL,所以要將tail重新指向他的next,幫助正在執行入隊的線程完成入隊工作。理解了這一步那麼出隊操作就已經理解了一大半了,下面繼續看⑦⑧⑨⑩。

如果head!=tail,則隊列不爲NULL,那麼直接將head指向下一個節點,將當前節點踢出隊列即可,當然需要CAS保證原子性更新,然後將踢出隊列的節點的item取出返回,並置爲NULL即完成了出隊操作。這裏需要注意的是如果被踢出隊列的節點的item是NULL,說明該節點已經被刪除了(因爲remove()方法只是將節點的item設置爲NULL,而不將節點踢出隊列),那就只能再次循環了。再提一點,爲什麼⑦⑧⑨⑩能夠被線程安全的執行,因爲在⑦這一步是原子更新的,而且更新之後這個節點就立即不會被其他任何線程訪問到了,所以後面⑧⑨⑩想怎麼處理都是安全的。

到這裏出隊操作應該很清楚了,下面就來綜合分析下爲什麼針對ConcurrentLinkedQueue的整個入隊/出隊/刪除都是不需要鎖的。
1. 上面已經分析瞭如果多個線程同時訪問其中任一個方法(offer/poll/remove)都是無需加鎖而且線程安全的
2. 由於remove方法不修改ConcurrentLinkedQueue的結構,所以跟其他兩個方法都不會有衝突
3. 如果同時兩個線程,一個入隊,一個出隊,在隊列不爲NULL的情況下是不是有任何問題的,因爲一個操作tail,一個操作head,完全不相關。但是如果隊列爲NULL時還是會發生衝突的,因爲tail==head。這裏我們在分析出隊時也提到了,如果出隊線程發現tail的next不爲NULL,那麼就會感知到當前有一個線程在執行入隊操作,所以出隊線程就會幫助入隊線程完成入隊操作,而且每個操作都是通過CAS保證原子性更新,所以就算同時兩個線程,一個入隊,一個出隊也不會發生衝突。

綜上,ConcurrentLinkedQueue最終實現了無鎖隊列。

使用場景

ConcurrentLinkedQueue適合在對性能要求相對較高,同時對隊列的讀寫存在多個線程同時進行的場景,即如果對隊列加鎖的成本較高則適合使用無鎖的ConcurrentLinkedQueue來替代。下面我們來簡單對比下ConcurrentLinkedQueue與我們常用的阻塞隊列LinkedBlockingQueue的性能。
表1:入隊性能對比

線程數 ConcurrentLinkedQueue耗時(ms) LinkedBlockingQueue耗時(ms)
5 22 29
10 50 59
20 99 112
30 139 171

測試數據:N個線程,每個線程入隊10000個元素。


參考文章

聊聊併發(六)——ConcurrentLinkedQueue的實現原理分析
非阻塞算法在併發容器中的實現


歡迎訪問我的個人博客~~~

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