本文中多次提到CAS演算法,先做個CAS演算法的簡單描述 CAS(非阻塞演算法)說明
CAS有3個運算元,記憶體值V,舊的預期值A,要修改的新值B。若且唯若預期值A和記憶體值V相同時,將記憶體值V修改為B,否則什麼都不做。
ConcurrentLinkedQueue是一種安全執行緒的隊列。他是使用非阻塞演算法(CAS)來實現安全執行緒的。ConcurrentLinkedQueue是一個基於連結節點的無界安全執行緒隊列,它採用先進先出的規則對節點進行排序,當我們添加一個元素的時候,它會添加到隊列的尾部;當我們擷取一個元素時,它會返回隊列頭部的元素。 ConcurrentLinkedQueue不變式
在後面的原始碼分析中,我們將會看到隊列有時會處於不一致狀態。為此,ConcurrentLinkedQueue 使用三個不變式 ( 基本不變式,head 的不變式和 tail 的不變式 ),來約束隊列中方法的執行。通過這三個不變式來維護非阻塞演算法的正確性 基本不變式
在執行方法之前和之後,隊列必須要保持的不變式:
當入隊插入新節點之後,隊列中有一個 next 域為 null 的(最後)節點。
從 head 開始遍曆隊列,可以訪問所有 item 域不為 null 的節點。 head 的不變式和可變式
在執行方法之前和之後,head 必須保持的不變式:
所有“活著”的節點(指未刪除節點),都能從 head 通過調用 succ() 方法遍曆可達。
head 不能為 null。
head 節點的 next 域不能引用到自身。
在執行方法之前和之後,head 的可變式:
head 節點的 item 域可能為 null,也可能不為 null。
允許 tail 滯後(lag behind)於 head,也就是說:從 head 開始遍曆隊列,不一定能到達 tail。 tail 的不變式和可變式
在執行方法之前和之後,tail 必須保持的不變式:
通過 tail 調用 succ() 方法,最後節點總是可達的。
tail 不能為 null。
在執行方法之前和之後,tail 的可變式:
tail 節點的 item 域可能為 null,也可能不為 null。
允許 tail 滯後於 head,也就是說:從 head 開始遍曆隊列,不一定能到達 tail。
tail 節點的 next 域可以引用到自身。
在接下來的原始碼分析中,在初始化 ConcurrentLinkedQueue 之後及調用入隊 / 出隊方法之前和之後,我們都會參照上面三個不變式來分析它們的正確性。 ConcurrentLinkedQueue結構
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E> implements Queue<E>, java.io.Serializable
ConcurrentLinkedQueue由head節點和tail節點群組成,每個節點(Node)由節點元素(item)和指向下一個節點(next)的引用組成,節點與節點之間就是通過這個next關聯起來,從而組成一張鏈表結構的隊列。預設情況下head節點儲存的元素為空白,tail節點等於head節點 節點類實現及隊列初始化 節點類定義
ConcurrentLinkedQueue 是用節點連結成的鏈表來實現的。首先,讓我們來看看節點類的原始碼
private static class Node<E> { private volatile E item; // 聲明為 volatile 型 private volatile Node<E> next; // 聲明為 volatile 型 Node(E item) { // 建立新節點 lazySetItem(item); // 惰性設定 item 域的值 } E getItem() { return item; } boolean casItem(E cmp, E val) { // 使用 CAS 指令設定 item 域的值 return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val); } void setItem(E val) { // 使用“volatile 寫”的方式,設定 item 域的值 item = val; } voidlazySetItem(E val) { //惰性設定 item 域的值 UNSAFE.putOrderedObject(this, itemOffset, val); } void lazySetNext(Node<E> val) { // 惰性設定 next 域的值 UNSAFE.putOrderedObject(this, nextOffset, val); } Node<E> getNext() { return next; } //CAS 設定 next 域的值 boolean casNext(Node<E> cmp, Node<E> val) { return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val); } private static final sun.misc.Unsafe UNSAFE= // 域更新器 sun.misc.Unsafe.getUnsafe(); private static final long nextOffset= //next 域的位移量 objectFieldOffset(UNSAFE, "next", Node.class); private static final long itemOffset= //item 域的位移量 objectFieldOffset(UNSAFE, "item", Node.class); }
在 ConcurrentLinkedQueue 的實際應用中,會頻繁分配大量生命週期短暫的節點對象。為了降低開銷,Node 類的 item 域和 next 域被聲明為普通的 volatile 類型。它們通過原子參考網域更新器(AtomicReferenceFieldUpdater),使用反射來更新 節點類型說明
有效節點:從 head 向後遍曆可達的節點當中,item 域不為 null 的節點。
無效節點:從 head 向後遍曆可達的節點當中,item 域為 null 的節點。
以刪除節點:從 head 向後遍曆不可達的節點。
哨兵節點:連結到自身的節點(哨兵節點同時也是以刪除節點)。
前端節點:隊列中的第一個有效節點(如果有的話)。
尾節點:隊列中 next 域為 null 的節點(可以是無效節點)。
如下圖所示:
對比 head 的不變式和 tail 的不變式可以看出,head 只能指向有效節點和無效節點,而 tail 可以指向任意節點,包括以刪除節點和哨兵節點。在 ConcurrentLinkedQueue 中,入隊時只能把新節點連結到尾節點的後面,出隊時只能刪除前端節點 入隊列 入隊列過程
入隊列就是將入隊節點添加到隊列的尾部,在 ConcurrentLinkedQueue 中,插入新節點時,不用考慮尾節點是否為有效節點,直接把新節點插入到尾節點的後面即可。由於 tail 可以指向任意節點,所以入隊時必須先通過 tail 找到尾節點,然後才能執行插入操作。如果插入不成功(說明其他線程已經搶先插入了一個新的節點)就繼續向後推進。重複上述迭代過程,直到插入成功為止。
下面讓我們看看入隊的源碼
public boolean offer(E e) { if (e == null) throw new NullPointerException(); // 入隊前建立一個入隊新節點 Node<E> n = new Node<E>(e); // 建立新節點 // 死迴圈,入隊不成功反覆入隊。 retry: for (;;) { // 建立一個指向tail節點的引用 Node<E> t = tail; // p用來表示隊列的尾節點,預設情況下等於tail節點 Node<E> p = t; for (int hops = 0;; hops++) { // 找到 tail 的下一個節點 next Node<E> next = succ(p); // next節點不為空白,說明p不是尾節點,需要更新p後在將它指向next節點 if (next != null) { // 如果已經至少越過了兩個節點,且 tail 被修改 (tail 被修改,說明其他線程向隊列添加了新的節點,且更新 tail 成功 ), // 並且當前節點還是不等於尾節點 if (hops > HOPS && t != tail) // 跳出內外兩層迴圈,重新開始迭代(因為 tail 剛剛被其他線程更新了) continue retry; // B2 // 向後推進到下一個節點 p = next; // B3 // 如果p是尾節點,則設定p節點的next節點為入隊節點 } else if (p.casNext(null, n)) { // C // 如果tail節點有大於等於1個next節點,則將入隊節點設定成tail節點, // 更新失敗了也沒關係,因為失敗了表示有其他線程成功更新了tail節點 if (hops >= HOPS) // C1 // 使用 CAS 原子指令更新 tail 指向這個新插入的節點,允許失敗 casTail(t, n); // C2 return true; // C3 } else {// p有next節點,表示p的next節點是尾節點,則重新設定p節點 p = succ(p); // D1 } } } }
從原始碼角度來看,整個入隊過程主要做兩件事情:第一是定位出尾節點;第二是使用CAS演算法將入隊節點設定成尾節點的next節點,如不成功則重試 定位尾節點
tail節點並不總是尾節點,所以每次入隊都必須先通過tail節點來找到尾節點。尾節點可能是tail節點,也可能是tail節點的next節點。代碼中迴圈體中的第一個if就是判斷tail是否有next節點,有則表示next節點可能是尾節點。擷取tail節點的next節點需要注意的是p節點等於p的next節點的情況,只有一種可能就是p節點和p的next節點都等於空,表示這個隊列剛初始化,正準備添加節點,所以需要返回head節點
擷取p節點的next節點源碼如下:
/** * Returns the successor of p, or the first node if p.next has been * linked to self, which will only be true if traversing with a * stale pointer that is now off the list. */ final Node<E> succ(Node<E> p) { // TODO: should we skip deleted nodes here? Node<E> q = p.next; return (p == q) ? first() : q; }
下面針對tail可能出現的情況分別通過圖示說明
1、tail指向尾節點
開始時,tail 指向 D 節點,首先尋找 D 節點的後繼節點。由於 D 的後繼節點為 null,所以插入新節點到 D 節點的後面。如果插入成功就退出方法;如果插入失敗(說明其他線程剛剛插入了一個新節點),就向後推進到新插入的節點,然後重新開始迭代。下圖是插入成功後的示意圖:
在上圖中,由於 tail 滯後於尾節點的節點數還沒有達到 HOPS 指定的閾值,所以 tail 沒有被更新。
2、tail指向非尾節點
開始時,tail 指向 C 節點。首先找到 C 的後繼節點 D,然後向後推進到節點 D,後面代碼執行路徑與上面的“tail 指向尾節點 ”的代碼執行路徑相同。下圖是插入成功後的結構示意圖:
上圖中的 tail 更新了位置。因為在添加 E 節點後,tail 滯後的節點數達到了 HOPS 指定的閾值。這觸發執行更新 tail 的 CAS 操作。
private static final int HOPS = 1;
3、tail滯後於head
開始時,tail 指向 A 節點。首先找到 A 的後繼節點 B,然後向後推進到節點 B。由於 B 是哨兵節點,產生跳轉動作,跳過 C 節點,從 head 指向的 D 節點開始繼續向後遍曆。後面的代碼執行路徑與“tail 指向非尾節點”相同。下面是成功插入一個新節點後的結構示意圖
出隊列
出隊列的就是從隊列裡返回一個節點元素,並清空該節點對元素的引用。並不是每次出隊時都更新head節點,當head節點裡有元素時,直接彈出head節點裡的元素,而不會更新head節點。只有當head節點裡沒有元素時,出隊操作才會更新head節點。這種做法也是通過hops變數來減少使用CAS更新head節點的消耗,從而提高出隊效率。
下面我看下出隊的源碼實現:
public E poll() { Node<E> h = head; // p表示前端節點,需要出隊的節點 Node<E> p = h; for (int hops = 0; ; hops++) { // 擷取p節點的元素 E item = p.getItem(); // 如果p節點的元素不為空白,使用CAS設定p節點引用的元素為null if (item != null && p.casItem(item, null)) { // 如果迭代過程已經越過了不小於 1 個節點也就是 if (hops >= HOPS) { // 將p節點下一個節點設定成head節點 Node<E> q = p.getNext(); // 如果 q 不為 null,設定 head 指向後繼節點 q;否則設定 head 指向當前節點 p(此時隊列為空白,只有一個偽節點 p) updateHead(h, (q != null) ? q : p); } // 返回被移除節點 item 域的值 return item; } // 如果前端節點的元素為空白或前端節點發生了變化,這說明前端節點已經被另外 // 一個線程修改了。那麼擷取p節點的下一個節點 Node<E> next = succ(p); // 如果p的下一個節點也為空白,說明這個隊列已經空了 if (next == null) { // 設定 head 指向 p 節點(此時隊列為空白,只有一個偽節點 p) updateHead(h, p); // 退出迴圈 break; } // 如果下一個元素不為空白,則將前端節點的下一個節點設定成前端節點 p = next; } return null; }
首先擷取前端節點的元素,然後判斷前端節點元素是否為空白,如果為空白,表示另外一個線程已經進行了一次出隊操作將該節點的元素取走,如果不為空白,則使用CAS的方式將前端節點的引用設定成null,如果CAS成功,則直接返回前端節點的元素,如果不成功,表示另外一個線程已經進行了一次出隊操作更新了head節點,導致元素髮生了變化,需要重新擷取前端節點。 前端節點定位
根據 head 的不變式和可變式,在執行出隊操作前,head 在隊列中的位置共有兩種可能:
head 指向有效節點。
head 指向無效節點。
1、head指向有效節點
出隊時,首先取得 head 指向的 A 節點的 item 域的值,然後通過 CAS 設定 A 節點 item 域的值為 null。如果成功,由於此時越過的節點數為 0,所以直接返回 A 節點 item 域原有的值。如果不成功,說明其他線程已經搶先刪除了該節點,此時向後推進到 B 節點。重複這個過程,直到成功刪除一個節點;如果遍曆完隊列也沒有刪除成功,則返回 null。下面是成功刪除後的結構示意圖:
在上圖中,雖然 A 節點被設定成無效節點,但 head 依然指向它,因為刪除操作越過的節點數還沒有達到 HOPS 指定的閥值。
接下來,讓我們來看看第二種情形的結構示意圖
2、head指向無效節點
首先獲得 head 指向節點的 item 域的值,由於為 null,所以向後推進到 B 節點。獲得 B 節點 item 域的值後,通過 CAS 設定該值為 null。如果成功,由於已經達到 HOPS 指定的閥值,觸發執行 head 更新。如果不成功(說明其他線程已經搶先刪除了 B 節點),繼續向後推進到 C 節點。重複這個過程,直到刪除一個有效節點。如果遍曆完隊列也沒有刪除成功,則返回 null。下圖是成功刪除後的結構示意圖:
從上圖我們可以看到,在執行刪除操作過程中,head 越過的節點數達到閥值,觸發執行 head 的更新,使它指向 C 節點。
從上面刪除前端節點後的兩個結構示意圖可以看出,執行出隊操作後的隊列依然滿足三個不變式。 總結
ConcurrentLinkedQueue 的非阻塞演算法實現非常精巧,也非常複雜。它使用 CAS 原子指令來處理對資料的並發訪問。同時,它允許隊列處於不一致狀態。這個特性分離了入隊 / 出隊操作中包含的兩個需要一起原子執行的步驟,從而有效縮小了入隊 / 出隊時的原子化(更新值的)範圍為唯一變數。由於隊列可能處於不一致狀態,為此 ConcurrentLinkedQueue 使用三個不變式來維護非阻塞演算法的正確性。
參考書籍:
《Java並發編程》、JDK源碼