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 方法不是很有用。