前言
上節我們介紹了線程安全的HashMap,今天我們再來介紹一個線程安全的併發容器:ConcurrentLinkedQueue,它是一個線程安全的隊列,在Java中如果要實現一個線程安全的隊列由2種方式:一個是使用阻塞算法的隊列,用一個鎖(入隊和出隊列共享同一把鎖)或者兩個鎖(入隊和出隊各持一把鎖)來實現,另一個是使用非阻塞的CAS方式來實現,今天我們我們就來一起看看如何使用非阻塞方式實現ConcurrentLinkedQueue,OK,讓我們揚帆起航,開始今天的併發之旅吧。
什麼是ConcurrentLinkedQueue?
ConcurrentLinkedQueue是一個適用於高併發場景的隊列,通過無鎖的方式,實現了高併發下狀態下的高性能,性能一般要比BlockingQueue(阻塞隊列)性能好,它是一個基於鏈表節點的無界限安全隊列(不允許Null元素存在,有null存在則鏈表就會斷了)。該隊列的元素遵循先進先出(FIFO)原則,頭是最先加入的,尾是最近加入的,單向管道一頭進另一頭出,它採用了無等待算法來實現(CAS)。
ConcurrentLinkedQueue的結構(tair應該爲tail,圖是網上引的所以特意說明下)
初始化時head爲空,tair=head,我們可以從源碼看出這點:
private transient volatile Node<E> tail;
public ConcurrentLinkedQueue() {
head = tail = new Node<E>(null);
}
ConcurrentLinkedQueue主要方法:
入隊列
入隊列的過程:入隊列就是將入隊列的節點添加到隊列的尾部的過程,例如我們添加向一個空隊列,我們來看下,節點的插入過程,如下圖所示:
從上面的示例我們可以看出,tail不一定是隊列的尾節點,再元素入隊列的時候,會先判斷當前tail節點的next節點是否爲空,如果爲空,則將新增元素節點設置爲tail的next節點,tail保持不變,如果不爲空,則將新增節點設置爲tail,當前tail的next則爲空。
多線程環境下,情況比上面要更復雜,例如一個線程正在入隊,那麼它必須先獲取尾節點,然後設置尾節點的下一個節點爲入隊節點,但這時可能有另外一個線程插隊了,先完成了入隊,那麼隊列的尾節點就會發生變化,此時當前線程就必須暫停入隊操作,然後重新獲取尾節點,再進行入隊,下面我們來看下源碼實現:
public boolean offer(E e) {
// 檢查入隊節點是否爲空,爲空則拋出異常(進入checkNotNull方法可查閱)
checkNotNull(e);
// 入隊前,創建一個入隊節點newNode
final Node<E> newNode = new Node<E>(e);
// 死循環,入隊不成功反覆入隊 p用來表示隊列的尾節點,默認情況下等於tail節點,t是一個指向tail的引用
for (Node<E> t = tail, p = t;;) {
// 獲得p節點的下一個節點q
Node<E> q = p.next;
// 如果q爲空,,說明p爲尾節點,則將新增節點newNode設置爲tail的next節點
if (q == null) {
// 將新節點以CAS方式加到隊列末尾
if (p.casNext(null, newNode)) {
// 如果p和tail引用p不同,說明引用t指向的節點不是最新尾節點,讓t指向最新的節點
if (p != t)
// 更新tail節點引用
casTail(t, newNode);
return true;
}
}
// 說明p節點和其尾節點都是null,這表明這個隊列剛剛初始化,因此返回head節點
else if (p == q)
p = (t != (t = tail)) ? t : head;
// 否則說明p不爲空,將新增的尾節點節點設置爲tail節點
else
p = (p != t && t != (t = tail)) ? t : q;
}
}
入隊還有一個方法:add,我們再來看下add方法,其本質還是offer,如下:
public boolean add(E e) {
return offer(e);
}
小結:從源碼我們得出入隊列主要做2件事:定位出尾節點和使用CAS算法能將入隊節點設置成尾節點的next節點,如不成功則重試。此外我們從源碼能看出來offer方法總是返回true,所以我們不能感覺返回值來判斷是否真正入隊列成功,如果要判斷,可以入隊列之前和完成之後分別獲取下隊列的大小,再相比較;
出隊列
出隊列過程:出隊列就是一個從隊列裏返回頭節點元素,並清空該節點對元素的引用的過程,和入隊列過程相反,這裏就不舉例子說明了,我們直接往下看,出隊列有2個方法,poll和peek都是取頭元素節點,區別在於前者會刪除元素,後者不會,我們看源碼如下:
public E poll() {
// 設置起始點
restartFromHead:
for (;;) {
// h指向head,p表示頭結點,需要出隊的節點,q表示next節點
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
// 如果p節點裏面有元素,則該元素直接出隊列,並將p元素節點CAS更新爲NUll
if (item != null && p.casItem(item, null)) {
// p不爲h的時候說明h還在前一個項爲NUll的位置,而p在後一個項有item的位置,則更新h指向
if (p != h)
// 若p的後驅不爲null,則將h指向p的後驅,因爲當前p的項也是要出隊的
// 若p的後驅爲NUll,則將h指向p,此時他們的item都爲Null
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
// p.item=null p的後驅爲null,用P更新h值
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
continue restartFromHead;
// q在p後面,p中節點已經出隊,則讓p指向q
else
p = q;
}
}
}
我們再看下peek的源碼,跟poll差不多,只是減少了刪除出隊列的節點操作,如下:
public E peek() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null || (q = p.next) == null) {
updateHead(h, p);
return item;
}
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}
小結:出隊列過程中首先獲取頭節點的元素,然後判斷頭節點元素是否爲空,如果爲空,表示另外一個線程已經進行了一次出隊操作,將該節點的元素取走,如果不爲空,則使用CAS的方式將頭節點的引用設置成null,如果CAS成功,則直接返回頭節點的元素,如果不成功,表示另外一個線程已經進行了一次出隊操作更新了head節點,導致元素髮生了變化,需要重新獲取頭節點。
Size方法
這裏我將size方法做個說明,主要是因爲size比較特殊:用循環獲取,判斷節點是否有值。然後累加,這是一種非常低效的方式,我們在判斷一個ConcurrentLinkedQueue是否爲空時,儘量使用isEmpty()方法,而避免使用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;
}