相關文章
Java并發編程(一)線程定義、狀態和屬性
Java并發編程(二)同步
Java并發編程(三)volatile域
Java并發編程(四)Java內存模型
Java并發編程(五)ConcurrentHashMap的實現原理和源碼分析
Java并發編程(六)阻塞隊列
前言
我們要實現一個線程安全的隊列有兩種實現方式一種是使用阻塞算法,另一種是使用非阻塞算法。使用阻塞算法的隊列可以用一個鎖(入隊和出隊用同一把鎖)或兩個鎖(入隊和出隊用不同的鎖)等方式來實現,而非阻塞的實現方式則可以使用循環CAS的方式來實現,本節我們就來研究下ConcurrentLinkedQueue是如何保證線程安全的同時又能高效的操作的。
1.ConcurrentLinkedQueue的結構
ConcurrentLinkedQueue是一個基于鏈接節點的無界線程安全隊列,它采用先進先出的規則對節點進行排序,當我們添加一個元素的時候,它會添加到隊列的尾部,當我們獲取一個元素時,它會返回隊列頭部的元素。基于CAS的“wait-free”(常規無等待)來實現,CAS并不是一個算法,它是一個CPU直接支持的硬件指令,這也就在一定程度上決定了它的平臺相關性。
當前常用的多線程同步機制可以分為下面三種類型:
- volatile 變量:輕量級多線程同步機制,不會引起上下文切換和線程調度。僅提供內存可見性保證,不提供原子性。
- CAS 原子指令:輕量級多線程同步機制,不會引起上下文切換和線程調度。它同時提供內存可見性和原子化更新保證。
- 互斥鎖:重量級多線程同步機制,可能會引起上下文切換和線程調度,它同時提供內存可見性和原子性。
ConcurrentLinkedQueue 的非阻塞算法實現主要可概括為下面幾點:
- 使用 CAS 原子指令來處理對數據的并發訪問,這是非阻塞算法得以實現的基礎。
- head/tail 并非總是指向隊列的頭 / 尾節點,也就是說允許隊列處于不一致狀態。 這個特性把入隊 /
出隊時,原本需要一起原子化執行的兩個步驟分離開來,從而縮小了入隊 /
出隊時需要原子化更新值的范圍到唯一變量。這是非阻塞算法得以實現的關鍵。 - 以批處理方式來更新head/tail,從整體上減少入隊 / 出隊操作的開銷。
ConcurrentLinkedQueue由head節點和tail節點組成,每個節點(Node)由節點元素(item)和指向下一個節點的引用(next)組成,節點與節點之間就是通過這個next關聯起來,從而組成一張鏈表結構的隊列。默認情況下head節點存儲的元素為空,tail節點等于head節點。
2.入隊列
入隊列就是將入隊節點添加到隊列的尾部,假設我們要在一個隊列中依次插入4個節點,來看看下面的圖來方便理解:
- 添加元素1。隊列更新head節點的next節點為元素1節點。又因為tail節點默認情況下等于head節點,所以它們的next節點都指向元素1節點。
- 添加元素2。隊列首先設置元素1節點的next節點為元素2節點,然后更新tail節點指向元素2節點。
- 添加元素3,設置tail節點的next節點為元素3節點。
- 添加元素4,設置元素3的next節點為元素4節點,然后將tail節點指向元素4節點。
入隊主要做兩件事情,第一是將入隊節點設置成當前隊列尾節點的下一個節點。第二是更新tail節點,在入隊列前如果tail節點的next節點不為空,則將入隊節點設置成tail節點,如果tail節點的next節點為空,則將入隊節點設置成tail的next節點,所以tail節點不總是尾節點。
上面的分析從單線程入隊的角度來理解入隊過程,但是多個線程同時進行入隊情況就變得更加復雜,因為可能會出現其他線程插隊的情況。如果有一個線程正在入隊,那么它必須先獲取尾節點,然后設置尾節點的下一個節點為入隊節點,但這時可能有另外一個線程插隊了,那么隊列的尾節點就會發生變化,這時當前線程要暫停入隊操作,然后重新獲取尾節點。讓我們再通過源碼來詳細分析下它是如何使用CAS方式來入隊的(JDK1.8):
public boolean offer(E e) {
checkNotNull(e);
//創建入隊節點
final Node<E> newNode = new Node<E>(e);
//t為tail節點,p為尾節點,默認相等,采用失敗即重試的方式,直到入隊成功
for (Node<E> t = tail, p = t;;) {
//獲得p的下一個節點
Node<E> q = p.next;
// 如果下一個節點是null,也就是p節點就是尾節點
if (q == null) {
//將入隊節點newNode設置為當前隊列尾節點p的next節點
if (p.casNext(null, newNode)) {
//判斷tail節點是不是尾節點,也可以理解為如果插入結點后tail節點和p節點距離達到兩個結點
if (p != t)
//如果tail不是尾節點則將入隊節點設置為tail。
// 如果失敗了,那么說明有其他線程已經把tail移動過
casTail(t, newNode);
return true;
}
}
// 如果p節點等于p的next節點,則說明p節點和q節點都為空,表示隊列剛初始化,所以返回 head節點
else if (p == q)
p = (t != (t = tail)) ? t : head;
else
//p有next節點,表示p的next節點是尾節點,則需要重新更新p后將它指向next節點
p = (p != t && t != (t = tail)) ? t : q;
}
}
從源代碼我們看出入隊過程中主要做了三件事情,第一是定位出尾節點;第二個是使用CAS指令將入隊節點設置成尾節點的next節點,如果不成功則重試;第三是重新定位tail節點。
從第一個if判斷就來判定p有沒有next節點如果沒有則p是尾節點則將入隊節點設置為p的next節點,同時如果tail節點不是尾節點則將入隊節點設置為tail節點。如果p有next節點則p的next節點是尾節點,需要重新更新p后將它指向next節點。還有一種情況p等于p的next節點說明p節點和p的next節點都為空,表示這個隊列剛初始化,正準備添加數據,所以需要返回head節點。
3.出隊列
出隊列的就是從隊列里返回一個節點元素,并清空該節點對元素的引用。讓我們通過每個節點出隊的快照來觀察下head節點的變化。
從上圖可知,并不是每次出隊時都更新head節點,當head節點里有元素時,直接彈出head節點里的元素,而不會更新head節點。只有當head節點里沒有元素時,出隊操作才會更新head節點。讓我們再通過源碼來深入分析下出隊過程(JDK1.8):
public E poll() {
// 設置起始點
restartFromHead:
for (;;) {
//p表示head結點,需要出隊的節點
for (Node<E> h = head, p = h, q;;) {
//獲取p節點的元素
E item = p.item;
//如果p節點的元素不為空,使用CAS設置p節點引用的元素為null
if (item != null && p.casItem(item, null)) {
if (p != h) // hop two nodes at a time
//如果p節點不是head節點則更新head節點,也可以理解為刪除該結點后檢查head是否與頭結點相差兩個結點,如果是則更新head節點
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
//如果p節點的下一個節點為null,則說明這個隊列為空,更新head結點
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
//結點出隊失敗,重新跳到restartFromHead來進行出隊
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}
更新head節點的updateHead方法:
final void updateHead(Node<E> h, Node<E> p)
{
// 如果兩個結點不相同,嘗試用CAS指令原子更新head指向新頭節點
if (h != p && casHead(h, p))
//將舊的頭結點指向自身以實現刪除
h.lazySetNext(h);
}
首先獲取head節點的元素,并判斷head節點元素是否為空,如果為空,表示另外一個線程已經進行了一次出隊操作將該節點的元素取走,如果不為空,則使用CAS的方式將head節點的引用設置成null,如果CAS成功,則直接返回head節點的元素,如果CAS不成功,表示另外一個線程已經進行了一次出隊操作更新了head節點,導致元素發生了變化,需要重新獲取head節點。如果p節點的下一個節點為null,則說明這個隊列為空(此時隊列沒有元素,只有一個偽結點p),則更新head節點。
4.隊列判空
有些人在判斷隊列是否為空時喜歡用queue.size()==0,讓我們來看看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;
}
可以看到這樣在隊列在結點較多時會依次遍歷所有結點,這樣的性能會有較大影響,因而可以考慮empty函數,它只要判斷第一個結點(注意不一定是head指向的結點)。
public boolean isEmpty() {
return first() == null;
}
參考資料
《Java并發編程的藝術》
JDK1.8源碼
http://www.zsfblues.com/2016/06/15/ConcurrentLinkedQueue%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/