併發容器-ConcurrentLinkedQueue詳解
作者:王一飛,叩丁狼高階講師。原創文章,轉載請註明出處。
概念
並程式設計中,一般需要用到安全的佇列,如果要自己實現安全佇列,可以使用2種方式: 方式1:加鎖,這種實現方式就是我們常說的阻塞佇列。 方式2:使用迴圈CAS演算法實現,這種方式實現佇列稱之為非阻塞佇列。 先對而已,加鎖佇列的實現較為簡單,這裡就略過,我們來重點來解讀一下非阻塞佇列。 從點到面, 下面我們來看下非阻塞佇列經典實現類:ConcurrentLinkedQueue (JDK1.8版)
ConcurrentLinkedQueue
根據API解釋,ConcurrentLinkedQueue 是一個基於連結節點的無界執行緒安全的佇列,按照先進先出原則對元素進行排序。新元素從佇列尾部插入,而獲取佇列元素,則需要從佇列頭部獲取。
看下ConcurrentLinkedQueue的結構圖
從內圖可以瞭解ConcurrentLinkedQueue一個大概,ConcurrentLinkedQueue內部持有2個節點:head頭結點,負責出列, tail尾節點,負責入列。而元素節點Node,使用item儲存入列元素,next指向下一個元素節點。
private static class Node<E> {
volatile E item;
volatile Node<E> next;
//....
}
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E> implements Queue<E>, java.io.Serializable { private transient volatile Node<E> head; private transient volatile Node<E> tail; //.... }
ConcurrentLinkedQueue使用特點
ConcurrentLinkedQueue使用約定: 1:不允許null入列 2:在入隊的最後一個元素的next為null 3:佇列中所有未刪除的節點的item都不能為null且都能從head節點遍歷到 4:刪除節點是將item設定為null, 佇列迭代時跳過item為null節點 5:head節點跟tail不一定指向頭節點或尾節點,可能存在滯後性
之所以有這奇葩約定,全因ConcurrentLinkedQueue是併發非阻塞佇列決定的。 我們從原始碼上看一下ConcurrentLinkedQueue實現過程
入列
我們印象中連結串列特點:tail節點表示最後一個節點, head表示第一個節點。ConcurrentLinkedQueue 跟傳統的連結串列有點區別,在單執行緒環境下符合傳統連結串列特點,但涉及到多執行緒環境,ConcurrentLinkedQueue 中的tail節點不一定是最後一個節點,他可能是倒數第二個。所以ConcurrentLinkedQueue判斷隊尾條件是節點的next為null。
public boolean offer(E e) {
checkNotNull(e); //為空判斷,e為null是拋異常
final Node<E> newNode = new Node<E>(e); //將e包裝成newNode
for (Node<E> t = tail, p = t;;) { //迴圈cas,直至加入成功
//t = p = tail
Node<E> q = p.next;
if (q == null) { //判斷p是否為尾節點
//如果是,p.next = newNode
if (p.casNext(null, newNode)) {
//首次新增時,p 等於t,不進行尾節點更新,所以所尾節點存在滯後性
//併發環境,可能存新增/刪除,tail就更難保證正確指向最後節點。
if (p != t)
//更新尾節點為最新元素
casTail(t, newNode);
return true;
}
}
else if (p == q)
//當tail不執行最後節點時,如果執行出列操作,很有可能將tail也給移除了
//此時需要對tail節點進行復位,復位到head節點
p = (t != (t = tail)) ? t : head;
else
//推動tail尾節點往隊尾移動
p = (p != t && t != (t = tail)) ? t : q;
}
}
分析
從圖上看tail不一定執行最後一個節點,但可以確定最後節點的next節點為null。到這可能朋友問他,併發環境什麼情況都有可能,ConcurrentLinkedQueue是怎麼保證執行緒安全的? 我們觀察offer方法的設計, 1:是一個死迴圈,就是不停使用cas判斷直到新增元素入隊成功。
for (Node<E> t = tail, p = t;;)
2:2個cas判斷方法 p.casNext(null, newNode) 確保佇列在入列時是原子操作
private boolean casTail(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, tailOffset, cmp, val);
}
casTail(t, newNode); 確保tail隊尾在移動改變時是原子操作
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
而在併發環境,ConcurrentLinkedQueue入列執行緒安全考慮具體可分2類: 1>執行緒1執行緒2同時入列 這個好理解, 執行緒1,執行緒2不管在offer哪個位置開始併發,他們最終的目的都是入列,也即都需要執行casNext方法, 我們只需要確保所有執行緒都有機會執行casNext方法,並且保證casNext方法是原子操作即可。casNext失敗的執行緒,可以進入下一輪迴圈,人品好的話就可以入列,衰的話繼續迴圈
2>執行緒1遍歷,執行緒2入列 ConcurrentLinkedQueue 遍歷是執行緒不安全的, 執行緒1遍歷,執行緒2很有可能進行入列出列操作, 所以ConcurrentLinkedQueue 的size是變化。換句話說,要想安全遍歷ConcurrentLinkedQueue 佇列,必須額外加鎖。
但換一個角度想, ConcurrentLinkedQueue 的設計初衷非阻塞佇列,我們更多關注入列與出列執行緒安全,這2點能保證就可以啦。
出列
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
//入列折騰的tail,那出列折騰的就是head
E item = p.item;
//出列判斷依據是節點的item=null
//item != null, 並且能將操作節點的item設定null, 表示出列成功
if (item != null && p.casItem(item, null)) {
if (p != h)
//一旦出列成功需要對head進行移動
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
//第一輪操作失敗,下一輪繼續,調回到迴圈前
continue restartFromHead;
else
//推動head節點移動
p = q;
}
}
}
看圖, 被移動的節點(item為null的節點)會被jvm回收。但是有個問題, tail也被回收, 那ConcurrentLinkedQueue就沒有tail節點了。如果此時再新增一個D元素時,會出現什麼情況?
好問的朋友,又想了,ConcurrentLinkedQueue怎麼保證出列執行緒安全?道理跟之前入列一樣,cas保證原子操作即可。
總結
到這ConcurrentLinkedQueue介紹就完成了。總結下ConcurrentLinkedQueue貼點: 1>入列出列執行緒安全,遍歷不安全 2>不允許新增null元素 3>底層使用列表與cas演算法包裝入列出列安全