目錄
1 前言
本人jdk版本1.8。在併發編程中,有時候需要使用線程安全的隊列。如果要實現一個線程安全的隊列有兩種方式:
- 使用阻塞算法:使用阻塞算法的隊列可以用一個鎖(入隊和出隊用同一把鎖如ArrayBlockingQueue)或兩個鎖(入隊和出隊用不同的鎖如LinkedBlockingQueue)等方式來實現。
- 使用非阻塞算法:非阻塞的實現方式則可以使用循環CAS的方式來實現。
ConcurrentLinkedQueue
是一個基於鏈接節點的無界線程安全隊列,它採用FIFO
的規則對節點進行排序,當我們添加一個元素的時候,它會添加到隊列的尾部;當我們獲取一個元素時,它會返回隊列頭部的元素。它採用了“wait-free
”算法(即CAS算法)來實現,該算法在Michael&Scott
算法上進行了一些修改。
2 隊列結構
ConcurrentLinkedQueue
由head
節點和tail
節點組成,每個節點Node
由節點元素item
和指向下一個節點next
的引用組成,節點與節點之間就是通過這個next
關聯起來,從而組成一張鏈表結構的隊列。默認情況下head
節點存儲的元素爲空,tail
節點等於head
節點。
private static class Node<E> {
volatile E item; // 數據
volatile Node<E> next; // 後繼節點
Node(E item){...}
// cas的修改節點item屬性,若節點的item爲cmp,則設置爲val
boolean casItem(E cmp, E val) {...}
// cas的修改節點的next屬性
boolean casNext(Node<E> cmp, Node<E> val) {...}
}
private transient volatile Node<E> head; // 隊列“頭”指針
private transient volatile Node<E> tail; // 隊列“尾”指針
3 入隊——offer()方法
3.1 入隊過程
下圖是元素入隊的過程,初始時head=tail=null:
添加元素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
節點不總是尾節點。
3.2 offer()源碼
結合上面入隊過程,看看jdk源碼:
public boolean offer(E e) {
checkNotNull(e); // 若e爲空,拋出NullPointerException
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) { // 指針p用來尋找尾節點
Node<E> q = p.next;
if (q == null) {
// p.next == null 說明p是尾結點,使用casNext令p.next = newNode
if (p.casNext(null, newNode)) {
// 若初始時t不指向尾節點,p=t。因爲q!=null,經過幾次循環,p向後移動指向了尾結點,故p!=t
if (p != t) // 若tail沒有指向尾節點
casTail(t, newNode); // 令tail指向尾結點。失敗了沒事
return true;
}
// 執行到這裏說明CAS操作中輸給了其它線程,再讀p.next
}
else if (p == q)
// 多線程操作時候,由於poll時候會把舊的head變爲自引用,然後將head的next設爲新的head。
// 所以這裏需要重新找新的head,因爲新的head後面的節點纔是激活的節點
p = (t != (t = tail)) ? t : head;
else
// 一般情況下令p = p.next,以此來尋找尾結點
p = (p != t && t != (t = tail)) ? t : q;
}
}
可以看到castTail方法的調用是有條件的,即p!=t,tail沒有指向尾結點。上面源代碼主要做了兩件事:
- 第一是定位出尾結點。
- 第二十使用CAS算法將入隊節點設置成尾結點的next節點,如不成功則重試。
3.3 tail並非一直指向尾結點的意圖
讓tail節點永遠作爲隊列的尾節點,這樣實現代碼量非常少,而且邏輯非常清楚和易懂。但是這麼做有個缺點就是每次都需要使用循環CAS更新tail節點。如果能減少CAS更新tail節點的次數,就能提高入隊的效率。
在JDK 1.7的實現中,doug lea使用hops變量來控制並減少tail節點的更新頻率,並不是每次節點入隊後都將 tail節點更新成尾節點,而是當tail節點和尾節點的距離大於等於常量HOPS的值(默認等於1)時才更新tail節點,tail和尾節點的距離越長使用CAS更新tail節點的次數就會越少,但是距離越長帶來的負面效果就是每次入隊時定位尾節點的時間就越長,因爲循環體需要多循環一次來定位出尾節點,但是這樣仍然能提高入隊的效率,因爲從本質上來看它通過增加對volatile變量的讀操作(循環查找尾結點時需要讀取tail)來減少了對volatile變量的寫操作(tail一直指向尾結點時每次添加元素都要令tail = 添加元素),而對volatile變量的寫操作開銷要遠遠大於讀操作,所以入隊效率會有所提升。
在JDK 1.8的實現中,tail的更新時機是通過p和t是否相等來判斷的,其實現結果和JDK 1.7相同,即當tail節點和尾節點的距離大於等於1時,更新tail。
4 出隊——poll()方法
4.1 出隊過程
並不是每次出隊時都更新head節點,當head節點裏有元素時,直接彈出head節點裏的元素,而不會更新head節點。只有當head節點裏沒有元素時,出隊操作纔會更新head節點。採用這種方式也是爲了減少使用CAS更新head節點的消耗,從而提高出隊效率。
4.2 poll()源碼
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null && p.casItem(item, null)) {
// 只有初始head不指向頭結點時才更新head
if (p != h) // hop two nodes at a time
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
} // p.next == null說明隊列爲空,返回null
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
continue restartFromHead;
else // p = p.next
p = q;
}
}
}
5 其它方法
5.1 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;
}