在并發編程中,我們可能經常需要用到線程安全的隊列,java為此提供了兩種模式的隊列:阻塞隊列和非阻塞隊列。
注:阻塞隊列和非阻塞隊列如何實現線程安全?
- 阻塞隊列可以用一個鎖(入隊和出隊共享一把鎖)或者兩個鎖(入隊使用一把鎖,出隊使用一把鎖)來實現線程安全,JDK中典型的實現是
BlockingQueue
;- 非阻塞隊列可以用循環CAS的方式來保證數據的一致性,來達到線程安全的目的。
接下來我們就來看看JDK是如何使用非阻塞的方式來實現線程安全隊列ConcurrentLinkedQueue的。
ConcurrentLinkedQueue源碼分析
ConcurrentLinkedQueue是一個基于鏈接節點的無界線程安全隊列,遵循隊列的FIFO原則,隊尾入隊,隊首出隊。我們先來看下隊列的基礎數據結構以及初始化相關源碼實現。
隊列基礎數據結構Node及初始化
-
Node實現
Node實現
從源碼可以看出,Node有兩個私有屬性item和指向下一個節點的next,為了降低開銷,item和next都被聲明成volatile類型,同時,它們使用CAS來保證更新的線程安全。 -
ConcurrentLinkedQueue數據結構
ConcurrentLinkedQueue私有屬性
ConcurrentLinkedQueue由head和tail節點組成,節點與節點之間通過next連接,從而來組成一個鏈表結構的隊列。 -
隊列初始化
ConcurrentLinkedQueue有兩個私有屬性,head和tail:
ConcurrentLinkedQueue私有屬性
接下來我們來看看ConcurrentLinkedQueue是如何初始化的。
ConcurrentLinkedQueue初始化
從源碼可以看出,當初始化一個ConcurrentLinkedQueue對象時,會創建一個item和next都為null的節點,并讓head和tail都指向該節點,初始化后的隊列結構如下:
初始化后結構圖
看完數據結構及初始化后,接下里我們就該來看看隊列的兩個重要實現:入隊和出隊。
ConcurrentLinkedQueue入隊
入隊的實質就是在隊尾做節點插入,具體的執行流程如下:
調用checkNotNull方法判斷待入隊元素是否為null,如果為null則拋出空指針異常;
創建一個待入隊節點;
-
循環執行隊尾插入:
-
情況1:如果tail節點的下一個節點q為null,通過p.casNext(null, newNode)將p節點的next節點設置為待入隊節點:
- CAS設置成功:比較p和t,如果p不等t,將tail節點設置為待入隊節點,入隊成功,返回true,如果p等于t,直接返回true;
- CAS設置不成功,表明有其他的線程對tail節點有所更改,那么,繼續執行for循環,直到入隊成功。
-
情況3:變換一下順序,先說最后一種情況,改寫一下代碼,就比較容易理解了:
更改后代碼
可以看出來,如果p和t相等,則將p指向q,否則,判斷tail節點是否發生變化,如果沒有發生變化,將p指向q,如果發生變化,設置p為尾節點;
情況2:通過情況3的操作,p和q相等的情況就可能會出現了,此時,若tail節點沒有發生變化,那么應該就是head節點發生了變化,設置p為head節點,從頭開始遍歷隊列,如果是tail節點發生變化,設置p為tail節點。
-
到這里為止,入隊的源碼分析差不多,說實在的,還是很懵,大家心里可能可能還在糾結,第一,入隊的過程到底是什么樣子的呀?第二,入隊直接CAS更新tail節點就可以了,為什么還要那么費勁的分情況處理?
針對第一個問題,給出一個圖,大家就能完全明白了:
添加節點1,此時tail節點的next節點為null,符合上述代碼中的情況1,更新隊列的tail節點的next節點為元素1,由于初始化是head節點等于tail節點,所以此時head和tail的next節點均指向節點1;
添加節點2,此時tail節點的next節點不為null,同時p也不等于q,符合上述代碼中的情況3,首先執行情況3將p指向tail節點的next節點,再執行情況1相關邏輯,設置節點1的next節點為元素2,此時p不等于t,更新tail節點,將tail節點指向元素2;
節點3和4入隊過程與1和2入隊一致,在這里我就不再做贅述。需要注意的是:tail節點并不一定是指向隊列的最后一個節點,它可能指向最后一個節點的前一個節點!!!
針對第二個問題,我們來嘗試換一種方式思考,假如我們每次就讓tail節點作為隊尾節點,每次的入隊所要做的事情其實就是將入隊節點設置成尾節點,代碼可以簡化成這樣:
上述代碼量確實非常少,而且邏輯非常清楚和易懂,但是這樣做有個缺點就是每次入隊都需要循環CAS更新tail節點。
如果能減少更新tail節點的次數,那么就能提高入隊的效率,所以,Doug Lea并沒有讓tail節點作為隊尾節點,只有tail節點與隊尾節點之間的距離等于1的時候才需要更新tail節點。但是,這樣就可能導致當隊列長度越長的時候每次入隊定位尾節點的時間就會越長,即便是這樣,它仍然可以提高入隊效率,因為從本質上來看,volatile變量的寫操作的開銷要遠遠大于讀操作的。
分析完入隊,接下來我們來看看ConcurrentLinkedQueue的出隊。
ConcurrentLinkedQueue出隊
出隊的實質就是情況表頭節點的引用并返回表頭節點的值,具體的之行邏輯如下:
獲取頭結點的元素;
-
如果表頭節點的元素不為null,并且調用p.casItem(item, null)設置表頭節點數據為null成功:
- 如果p不等于head節點,此時表頭發生了變化,調用updateHead方法更新表頭節點,然后返回刪除節點item;
- 否則,不更新表頭節點,直接返回刪除節點item。
如果步驟2條件不成立并且表頭節點的next節點q為null,那么此時隊列只有一個為null的節點,調用updateHead方法更新表頭節點為p,然后返回null;
如果步驟2和3的條件均不成立并且p等于q,跳轉到restartFromHead標記重新執行;
步驟2,3,4均不成立,將p指向q;
循環執行上述步驟;
源碼其實就這么多,為了方便理解出隊的過程,還是照樣給一個圖:
節點1出隊,此時head的item為null,執行上述代碼邏輯中的步驟5,p指向節點1,此時p的item域不為空,執行步驟2,將節點1的item設置為null,此時p不等于h,更新頭結點(如果p的next節點不為null,將頭結點指向q,否則指向p),返回節點1的item,head執行節點2;
節點2出隊,此時head的item不為null,執行上述代碼邏輯中步驟2,將節點2的item設置為null,此時p等于h,直接返回節點2的item,head仍然指向節點2;
節點3和4出隊過程與1和2出隊一致,在這里我就不再做贅述。需要注意的是head節點并不一定是指向隊列的第一個有效節點,它可能指向有效節點的前一個節點!!!
注:這里的有效節點是指從head節點向后遍歷可達的節點當中,item不為null的節點。
當然,為什么head節點不總是指向隊列的第一個有效節點,其原因跟入隊是一樣的,這么做的最主要也是減少CAS更新head節點的次數,從而提高出隊效率。