原始碼|併發一枝花之BlockingQueue
今天來介紹Java併發程式設計中最受歡迎的同步類——堪稱併發一枝花之BlockingQueue。
JDK版本:oracle java 1.8.0_102
繼續閱讀之前,需確保你對鎖和條件佇列的使用方法爛熟於心,特別是條件佇列,否則你可能無法理解以下原始碼的精妙之處,甚至基本的正確性。本篇暫不涉及此部分內容,需讀者自行準備。
介面定義
BlockingQueue繼承自Queue,增加了阻塞的入隊、出隊等特性:
public interface BlockingQueueextends Queue { boolean add(E e); void put(E e) throws InterruptedException; // can extends from Queue. i don't know why overriding here boolean offer(E e); boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; E take() throws InterruptedException; // extends from Queue // E poll(); E poll(long timeout, TimeUnit unit) throws InterruptedException; int remainingCapacity(); boolean remove(Object o); public boolean contains(Object o); int drainTo(Collection super E> c); int drainTo(Collection super E> c, int maxElements); }
為了方便講解,我調整了部分方法的順序,還增加了註釋輔助說明。
需要關注的是兩對方法:
阻塞方法BlockingQueue#put()和BlockingQueue#take():如果入隊(或出隊,下同)失敗(如希望入隊但佇列滿,下同),則等待,一直到滿足入隊條件,入隊成功。
非阻塞方法BlockingQueue#offer()和BlockingQueue#poll(),及它們的超時版本:非超時版本是瞬時動作,如果入隊當前入隊失敗,則立刻返回失敗;超時版本可在此基礎上阻塞一段時間,相當於限時的BlockingQueue#put()和BlockingQueue#take()。
實現類
BlockingQueue有很多實現類。根據github的code results排名,最常用的是LinkedBlockingQueue(253k)和ArrayBlockingQueue(95k)。LinkedBlockingQueue的效能在大部分情況下優於ArrayBlockingQueue,本文主要介紹LinkedBlockingQueue,文末會簡要提及二者的對比。
LinkedBlockingQueue
阻塞方法put()和take()
兩個阻塞方法相對簡單,有助於理解LinkedBlockingQueue的核心思想:在隊頭和隊尾各持有一把鎖,入隊和出隊之間不存在競爭。
前面在中循序漸進的引出了BlockingQueue#put()和BlockingQueue#take()的實現,可以先去複習一下,瞭解為什麼LinkedBlockingQueue要如此設計。以下是更細緻的講解。
阻塞的入隊操作put()
在隊尾入隊。putLock和notFull配合完成同步。
public void put(E e) throws InterruptedException { if (e == null) throw new NullPointerException(); int c = -1; Nodenode = new Node (e); final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); try { while (count.get() == capacity) { notFull.await(); } enqueue(node); c = count.getAndIncrement(); if (c + 1 現在觸發一個入隊操作,分情況討論。
case1:入隊前,佇列非空非滿(長度大於等於2)
入隊前需得到鎖putLock。檢查佇列非滿,無需等待條件notFull,直接入隊。入隊後,檢查佇列非滿(精確說是入隊前“將滿”,但不影響理解),隨機通知一個生產者條件notFull滿足。最後,檢查入隊前佇列非空,則無需通知條件notEmpty。
注意點:
入隊前佇列非空非滿(長度大於等於2),則head和tail指向的節點不同,入隊與出隊操作不會同時更新同一節點也就不存在競爭。因此,分別用兩個鎖同步入隊、出隊操作才能是執行緒安全的。進一步的,由於入隊已經由鎖putLock保護,則enqueue內部實現不需要加鎖。
條件notFull可以只隨機通知一個等待該條件的生產者執行緒(使用signal()而不是signalAll())。即
“單次通知”
,目的是減少無效競爭。但這不會產生“訊號劫持”的問題,因為只有生產者在等待該條件。條件通知方法singal()是近乎“冪等”的:如果有執行緒在等待該條件,則隨機選擇一個執行緒通知;如果沒有執行緒等待,則什麼都不做,不會造成什麼惡劣影響。
case2:入隊前,佇列滿
入隊前需得到鎖putLock。檢查佇列滿,則等待條件notFull。條件notFull可能由出隊成功觸發(必要的),也可能由入隊成功觸發(也是必要的,避免“訊號不足”的問題)。條件notFull滿足後,入隊。入隊後,假設檢查佇列滿(佇列非滿的情況同case1),則無需通知條件notFull。最後,檢查入隊前佇列非空,則無需通知條件notEmpty。
注意點:
“訊號不足”問題:假設佇列滿時,存在3個生產者P1-P3(多於一個就可以)同時阻塞在10行;如果此時5個消費者C1-C5(多於一個就可以)快速、連續的出隊,但最後只會有一個訊號發出(19-20行在take()中的對偶邏輯,只會在佇列之前消費前佇列滿的情況發出訊號);一個訊號只能喚醒一個生產者P1,但明顯此時佇列缺少了5個元素,該邏輯不足以喚醒P2、P3。因此,14-15行“入隊完成時的通知”是必要的,保證了只要佇列非滿,每次入隊後都能喚醒1個阻塞的生產者,來等待鎖釋放後競爭鎖。即,P1完成入隊後,如果檢查到佇列非滿,會隨機喚醒一個生產者P2,讓P2在P1釋放鎖putLock後競爭鎖,繼續入隊,P3同理。相比於signalAll()喚醒所有生產者,這種解決方案使得同一時間最多隻有一個生產者在清醒的競爭鎖,效能提升非常明顯。
補充signalNotEmpty()、signalNotFull()的實現:
private void signalNotEmpty() { final ReentrantLock takeLock = this.takeLock; takeLock.lock(); try { notEmpty.signal(); } finally { takeLock.unlock(); } }private void signalNotFull() { final ReentrantLock putLock = this.putLock; putLock.lock(); try { notFull.signal(); } finally { putLock.unlock(); } }case3:入隊前,佇列空
入隊前需得到鎖putLock。檢查佇列空,則無需等待條件notFull,直接入隊。入隊後,如果佇列非滿,則同case1;如果佇列滿,則同case2。最後,假設檢查入隊前佇列空(佇列非空的情況同case1),則隨機通知一個消費者條件notEmpty滿足。
注意點:
只有入隊前佇列空的情況下,才需要通知條件notEmpty滿足。即
“條件通知”
,是一種減少無效通知的措施。因為如果佇列非空,則出隊操作不會阻塞在條件notEmpty上。另一方面,雖然已經有生產者完成了入隊,但可能有消費者在生產者釋放鎖putLock後、通知條件notEmpty滿足前,使佇列變空;不過這沒有影響,take()方法的while迴圈能夠線上程競爭到鎖之後再次確認。透過入隊和出隊前檢查佇列長度(while+await),隱含保證了佇列空時只允許入隊操作,不存在競爭佇列。
case4:入隊前,佇列長度為1
case4是一個特殊情況,分析方法類似於case1,但可能入隊與出隊之間存在競爭,我們稍後分析。
阻塞的出隊操作take()
在隊頭入隊。takeLock和notEmpty配合完成同步。
public E take() throws InterruptedException { E x; int c = -1; final AtomicInteger count = this.count; final ReentrantLock takeLock = this.takeLock; takeLock.lockInterruptibly(); try { while (count.get() == 0) { notEmpty.await(); } x = dequeue(); c = count.getAndDecrement(); if (c > 1) notEmpty.signal(); } finally { takeLock.unlock(); } if (c == capacity) signalNotFull(); return x; }依舊是四種case,put()和take()是對偶的,很容易分析,不贅述。
“case4 佇列長度為1”時的特殊情況
佇列長度為1時,到底入隊和出隊之間存在競爭嗎?這取決於LinkedBlockingQueue的底層資料結構。
最簡單的是使用樸素連結串列,可以自己實現,也可以使用JDK提供的非執行緒安全集合類,如LinkedList等。但是,佇列長度為1時,樸素連結串列中的head、tail指向同一個節點,從而入隊、出隊更新同一個節點時存在競爭。
樸素連結串列:一個節點儲存一個元素,不加任何控制和trick。典型如LinkedList。
增加dummy node可解決該問題(或者叫哨兵節點什麼的)。定義Node(item, next),描述如下:
初始化連結串列時,建立dummy node:
dummy = new Node(null, null)
head = dummy.next // head 為 null 佇列空
tail = dummy // tail.item 為 null 佇列空
在隊尾入隊時,tail後移:
tail.next = new Node(newItem, null)
tail = tail.next
在隊頭出隊時,dummy後移,同步更新head:
oldItem = head.item
dummy = dummy.next
dummy.item = null
head = dummy.next
return oldItem
在新的資料結構中,更新操作發生在dummy和tail上,head僅僅作為示意存在,跟隨dummy節點更新。佇列長度為1時,雖然head、tail仍指向同一個節點,但dummy、tail指向不同的節點,從而更新dummy和tail時不存在競爭。
原始碼中的head即為
dummy
,first即為head
:...public LinkedBlockingQueue(int capacity) { if (capacity (null); } ...private void enqueue(Nodenode) { // assert putLock.isHeldByCurrentThread(); // assert last.next == null; last = last.next = node; } ...private E dequeue() { // assert takeLock.isHeldByCurrentThread(); // assert head.item == null; Node h = head; Node first = h.next; h.next = h; // help GC head = first; E x = first.item; first.item = null; return x; } ... enqueue和count自增的先後順序
以put()為例,count自增一定要晚於enqueue執行,否則take()方法的while迴圈檢查會失效。
用一個最簡單的場景來分析,只有一個生產者執行緒T1,一個消費者執行緒T2。
如果先count自增再enqueue
假設目前佇列長度0,則事件發生順序:
T1執行緒:count 自增
T2執行緒:while 檢查 count > 0,無需等待條件 notEmpty
T2執行緒:dequeue 執行
T1執行緒:enqueue 執行
很明顯,在事件1發生後事件4發生前,雖然count>0,但佇列中實際是沒有元素的。因此,事件3 dequeue會執行失敗(預計丟擲NullPointerException)。事件4也就不會發生了。
如果先enqueue再count自增
如果先enqueue再count自增,就不會存在該問題。
仍假設目前佇列長度0,則事件發生順序:
T1執行緒:enqueue 執行
T2執行緒:while 檢查 count == 0,等待條件 notEmpty
T1執行緒:count 自增
T1執行緒:通知條件notFull滿足
T1執行緒:通知條件notEmpty滿足
T2執行緒:收到條件notEmpty
T2執行緒:while 檢查 count > 0,無需等待條件 notEmpty
T2執行緒:dequeue 執行
換個方法,用狀態機來描述:
事件E1發生前,佇列處於
狀態S1
事件E1發生,執行緒T1 增加了一個佇列元素,導致佇列元素的數量大於count(1>0),佇列轉換到
狀態S2
事件E1發生後、直到事件E3發生前,佇列一直處於
狀態S2
事件E3發生,執行緒T1 使count自增,導致佇列元素的數量等於count(1=1),佇列轉換到
狀態S1
事件E3發生後、事件E8發生前,佇列一直處於
狀態S1
很多讀者可能第一次從狀態機的角度來理解併發程式設計,所以猴子選擇先寫出狀態遷移序列,如果能理解上述序列,我們再進行進一步的抽象。實際的狀態機定義比下面要嚴謹的多,不過這裡的描述已經足夠了。
現在補充定義如下,不考慮入隊和出隊的區別:
佇列元素的數量等於count的狀態定義為
狀態S1
佇列元素的數量大於count的狀態定義為
狀態S2
enqueue操作定義為狀態轉換S1->S2
count自增操作定義為狀態轉換S2->S1
LinkedBlockingQueue中的同步機制保證了不會有其他執行緒看到狀態S2,即,S1->S2->S1兩個狀態轉換隻能由執行緒T1連續完成,其他執行緒無法在中間插入狀態轉換。
在猴子的理解中,併發程式設計的本質是狀態機,即維護合法的狀態和狀態轉換。以上是一個極其簡單的場景,用狀態機舉例子就可以描述;然而,複雜場景需要用狀態機做數學證明,這使得用狀態機描述併發程式設計不太受歡迎(雖然口頭描述也不能算嚴格證明)。不過,理解實現中的各種程式碼順序、猛不丁蹦出的trick,這些只是“知其所以然”;透過簡單的例子來掌握其狀態機本質,才能讓我們瞭解其如何保證執行緒安全性,自己也能寫出類似的實現,做到“知其然而知其所以然”。後面會繼續用狀態機分析ConcurrentLinkedQueue的原始碼,敬請期待。
非阻塞方法offer()和poll()
分析了兩個阻塞方法put()、take()後,非阻塞方法就簡單了。
瞬時版
以offer為例,poll()同理。假設此時佇列非空。
public boolean offer(E e) { if (e == null) throw new NullPointerException(); final AtomicInteger count = this.count; if (count.get() == capacity) return false; int c = -1; Nodenode = new Node (e); final ReentrantLock putLock = this.putLock; putLock.lock(); try { if (count.get() = 0; } case1:入隊前,佇列非滿
入隊前需得到鎖putLock。檢查佇列非滿(隱含表明“無需等待條件notFull”),直接入隊。入隊後,檢查佇列非滿,隨機通知一個生產者(包括使用put()方法的生產者,下同)條件notFull滿足。最後,檢查入隊前佇列非空,則無需通知條件notEmpty。
可以看到,瞬時版offer()在佇列非滿時的行為與put()相同。
case2:入隊前,佇列滿
入隊前需得到鎖putLock。檢查佇列滿,直接退出try-block。後同case1。
佇列滿時,offer()與put()的區別就顯現出來了。put()透過while迴圈阻塞,一直等到條件notFull得到滿足;而offer()卻直接返回。
一個小point:
c在申請鎖putLock前被賦值為-1。接下來,如果入隊成功,會執行
c = count.getAndIncrement();
一句,則釋放鎖後,c的值將大於等於0。於是,這裡直接用c是否大於等於0來判斷是否入隊成功。這種實現犧牲了可讀性,只換來了無足輕重的效能或程式碼量的最佳化。自己在開發時,不要編寫這種程式碼。超時版
同上,以offer()為例。假設此時佇列非空。
public boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException { if (e == null) throw new NullPointerException(); long nanos = unit.toNanos(timeout); int c = -1; final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); try { while (count.get() == capacity) { if (nanos (e)); c = count.getAndIncrement(); if (c + 1該方法同put()很像,12-13行判斷nanos超時的情況(吞掉了timeout引數非法的異常情況),所以區別只有14行:將阻塞的
notFull.await()
換成非阻塞的超時版notFull.awaitNanos(nanos)
。awaitNanos()的實現有點意思,這裡不表。其實現類中的Javadoc描述非常幹練:“Block until signalled, interrupted, or timed out.”,返回值為剩餘時間。剩餘時間小於等於引數nanos,表示:
條件notFull滿足(剩餘時間大於0)
等待的總時長已超過timeout(剩餘時間小於等於0)
nanos首先被初始化為timeout;接下來,消費者執行緒可能阻塞、收到訊號多次,每次收到訊號被喚醒,返回的剩餘時間都大於0並小於等於引數nanos,再用剩餘時間作為下次等待的引數nanos,直到剩餘時間小於等於0。以此實現總時長不超過timeout的超時檢測。
其他同put()方法。
12-13行判斷nanos引數非法後,直接返回了false。實現有問題,有可能違反介面宣告。
根據Javadoc的返回值宣告,返回值true表示入隊成功,false表示入隊失敗。但如果傳進來的timeout是一個負數,那麼5行初始化的nanos也將是一個負數;進而一進入while迴圈,就在13行返回了false。然而,這是一種引數非法的情況,返回false讓人誤以為引數正常,只是入隊失敗。這違反了介面宣告,並且非常難以發現。
應該在函式頭部就將引數非法的情況檢查出來,相應丟擲IllegalArgumentException。
LinkedBlockingQueue與ArrayBlockingQueue的區別
github上LinkedBlockingQueue和ArrayBlockingQueue的使用頻率都很高。大部分情況下都可以也建議使用LinkedBlockingQueue,但清楚二者的異同點,方能對症下藥,在針對不同的最佳化場景選擇最合適的方案。
相同點:
支援有界
不同點
LinkedBlockingQueue底層用連結串列實現:ArrayBlockingQueue底層用陣列實現
LinkedBlockingQueue支援不指定容量的無界佇列(長度最大值Integer.MAX_VALUE);ArrayBlockingQueue必須指定容量,無法擴容
LinkedBlockingQueue支援懶載入:ArrayBlockingQueue不支援
ArrayBlockingQueue入隊時不生成額外物件:LinkedBlockingQueue需生成Node物件,消耗時間,且GC壓力大
LinkedBlockingQueue的入隊和出隊分別用兩把鎖保護,無競爭,二者不會互相影響;ArrayBlockingQueue的入隊和出隊共用一把鎖,入隊和出隊存在競爭,一方速度高時另一方速度會變低。不考慮分配物件、GC等因素的話,ArrayBlockingQueue併發效能要低於LinkedBlockingQueue
可以看到,LinkedBlockingQueue整體上是優於ArrayBlockingQueue的。所以,除非某些特殊原因,否則應優先使用LinkedBlockingQueue。
可能不全,歡迎評論,隨時增改。
總結
沒有。
作者:猴子007
連結:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/3705/viewspace-2802590/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 原始碼|併發一枝花之CopyOnWriteArrayList原始碼
- 原始碼|併發一枝花之ConcurrentLinkedQueue【偽】原始碼
- 原始碼|併發一枝花之ReentrantLock與AQS(3):Condition原始碼ReentrantLockAQS
- java併發-BlockingQueueJavaBloC
- Java併發包原始碼學習系列:阻塞佇列BlockingQueue及實現原理分析Java原始碼佇列BloC
- Java併發系列 — 阻塞佇列(BlockingQueue)Java佇列BloC
- 解讀 Java 併發佇列 BlockingQueueJava佇列BloC
- 乾貨|解讀Java併發佇列BlockingQueueJava佇列BloC
- java併發之hashmap原始碼JavaHashMap原始碼
- BlockingQueue介面及其實現類的原始碼分析BloC原始碼
- Java併發指南11:解讀 Java 阻塞佇列 BlockingQueueJava佇列BloC
- 併發類Condition原始碼分析原始碼
- 併發程式設計—— FutureTask 原始碼分析程式設計原始碼
- Java併發之AQS原始碼分析(二)JavaAQS原始碼
- Java併發之Semaphore原始碼解析(一)Java原始碼
- Java併發之Semaphore原始碼解析(二)Java原始碼
- 併發工具類:Semaphore原始碼解讀原始碼
- Java併發包原始碼學習系列:同步元件CountDownLatch原始碼解析Java原始碼元件CountDownLatch
- Java併發包原始碼學習系列:同步元件CyclicBarrier原始碼解析Java原始碼元件
- Java併發包原始碼學習系列:同步元件Semaphore原始碼解析Java原始碼元件
- Java併發包原始碼學習系列:基於CAS非阻塞併發佇列ConcurrentLinkedQueue原始碼解析Java原始碼佇列
- 併發系列(二)——FutureTask類原始碼簡析原始碼
- 併發程式設計之 Exchanger 原始碼分析程式設計原始碼
- Java併發之ReentrantLock原始碼解析(一)JavaReentrantLock原始碼
- Java併發之ReentrantLock原始碼解析(二)JavaReentrantLock原始碼
- Java併發之ReentrantLock原始碼解析(三)JavaReentrantLock原始碼
- Java併發之ReentrantLock原始碼解析(四)JavaReentrantLock原始碼
- 併發程式設計之:AQS原始碼解析程式設計AQS原始碼
- Java併發之ThreadPoolExecutor原始碼解析(二)Javathread原始碼
- Java併發之ThreadPoolExecutor原始碼解析(三)Javathread原始碼
- java 併發程式設計-AQS原始碼分析Java程式設計AQS原始碼
- 併發程式設計之 CountDown 原始碼分析程式設計原始碼
- 併發程式設計之 CyclicBarrier 原始碼分析程式設計原始碼
- [Java併發程式設計實戰] 阻塞佇列 BlockingQueue(含程式碼,生產者-消費者模型)Java程式設計佇列BloC模型
- Java 併發程式設計(十五) -- Semaphore原始碼分析Java程式設計原始碼
- Java 併發程式設計(十四) -- CyclicBarrier原始碼分析Java程式設計原始碼
- Java 併發程式設計(十三) -- CountDownLatch原始碼分析Java程式設計CountDownLatch原始碼
- Java 併發程式設計(七) -- AbstractQueuedSynchronizer 原始碼分析Java程式設計原始碼