面試官:呦,小夥子來的挺早啊!
Hydra:那是,不能讓您等太久了啊(別廢話了快開始吧,還趕著去下一場呢)。
面試官:前面兩輪表現還不錯,那我們今天繼續說說佇列中的SynchronousQueue
吧。
Hydra:好的,SynchronousQueue
和之前介紹過的佇列相比,稍微有一些特別,必須等到佇列中的元素被消費後,才能繼續向其中新增新的元素,因此它也被稱為無緩衝的等待佇列。
我還是先寫一個例子吧,建立兩個執行緒,生產者執行緒putThread
向SynchronousQueue
中放入元素,消費者執行緒takeThread
從中取走元素:
SynchronousQueue<Integer> queue=new SynchronousQueue<>(true);
Thread putThread=new Thread(()->{
for (int i = 0; i <= 2; i++) {
try {
System.out.println("put thread put:"+i);
queue.put(i);
System.out.println("put thread put:"+i+" awake");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread takeThread=new Thread(()->{
int j=0;
while(j<2){
try {
j=queue.take();
System.out.println("take from putThread:"+j);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
putThread.start();
Thread.sleep(1000);
takeThread.start();
執行上面的程式碼,檢視結果:
put thread put:0
take from putThread:0
put thread put:0 awake
put thread put:1
take from putThread:1
put thread put:1 awake
put thread put:2
take from putThread:2
put thread put:2 awake
可以看到,生產者執行緒在執行put
方法後就被阻塞,直到消費者執行緒執行take
方法對佇列中的元素進行了消費,生產者執行緒才被喚醒,繼續向下執行。簡單來說執行流程是這樣的:
面試官:就這?應用誰不會啊,不講講底層原理就想矇混過關?
Hydra:別急啊,我們先從它的建構函式說起,根據引數不同,SynchronousQueue
分為公平模式和非公平模式,預設情況下為非公平模式
public SynchronousQueue(boolean fair) {
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
我們先來看看公平模式吧,該模式下底層使用的是TransferQueue
佇列,內部節點由QNode
構成,定義如下:
volatile QNode next; // next node in queue
volatile Object item; // CAS'ed to or from null
volatile Thread waiter; // to control park/unpark
final boolean isData;
QNode(Object item, boolean isData) {
this.item = item;
this.isData = isData;
}
item
用來儲存資料,isData
用來區分節點是什麼型別的執行緒產生的,true
表示是生產者,false
表示是消費者,是後面用來進行節點匹配(complementary
)的關鍵。在SynchronousQueue
中匹配是一個非常重要的概念,例如一個執行緒先執行put
產生了一個節點放入佇列,另一個執行緒再執行take
產生了一個節點,這兩個不同型別的節點就可以匹配成功。
面試官:可是我看很多資料裡說SynchronousQueue
是一個不儲存元素的阻塞佇列,這點你是怎麼理解的?
Hydra:通過上面節點中封裝的屬性,可以看出SynchronousQueue
的佇列中封裝的節點更多針對的不是資料,而是要執行的操作,個人猜測這個說法的出發點就是佇列中儲存的節點更多偏向於操作這一屬性。
面試官:好吧,接著往下說佇列的結構吧。
Hydra:TransferQueue
中主要定義的屬性有下面這些:
transient volatile QNode head;
transient volatile QNode tail;
transient volatile QNode cleanMe;
TransferQueue() {
QNode h = new QNode(null, false); // initialize to dummy node.
head = h;
tail = h;
}
比較重要的有頭節點head
、尾節點tail
、以及用於標記下一個要刪除的節點的cleanMe
節點。在建構函式初始化中建立了一個節點,註釋中將它稱為dummy node
,也就是偽造的節點,它的作用類似於AQS
中的頭節點的作用,實際操作的節點是它的下一個節點。
要說SynchronousQueue
,真是一個神奇的佇列,不管你呼叫的是put
和offer
,還是take
和poll
,它都一概交給核心的transfer
方法去處理,只不過引數不同。今天我們拋棄原始碼,通過畫圖對它進行分析,首先看一下方法的定義:
E transfer(E e, boolean timed, long nanos);
面試官:呦呵,就一個方法?我倒要看看它是怎麼區分實現的入隊和出隊操作…
Hydra:在方法的引數中,timed
和nanos
用於標識呼叫transfer
的方法是否是能夠超時退出的,而e
是否為空則可以說明是生產者還是消費者呼叫的此方法。如果e
不為null
,是生產者呼叫,如果e
為null
則是消費者呼叫。方法的整體邏輯可以分為下面幾步:
1、若佇列為空,或佇列中的尾節點型別和自己的型別相同,那麼準備封裝一個新的QNode
新增到佇列中。在新增新節點到隊尾的過程中,並沒有使用synchronized
或ReentrantLock
,而是通過CAS
來保證執行緒之間的同步。
在新增新的QNode
到隊尾前,會首先判斷之前取到的尾節點是否發生過改變,如果有改變的話那麼放棄修改,進行自旋,在下一次迴圈中再次判斷。當檢查隊尾節點沒有發生改變後,構建新的節點QNode
,並將它新增到隊尾。
2、當新節點被新增到隊尾後,會呼叫awaitFulfill
方法,會根據傳遞的引數讓執行緒進行自旋或直接掛起。方法的定義如下,引數中的timed
為true
時,表示這是一個有等待超時的方法。
Object awaitFulfill(QNode s, E e, boolean timed, long nanos);
在awaitFulfill
方法中會進行判斷,如果新節點是head
節點的下一個節點,考慮到可能很快它就會完成匹配後出隊,先不將它掛起,進行一定次數的自旋,超過自旋次數的上限後再進行掛起。如果不是head
節點的下一個節點,避免自旋造成的資源浪費,則直接呼叫park
或parkNanos
掛起執行緒。
3、當掛起的執行緒被中斷或到達超時時間,那麼需要將節點從佇列中進行移除,這時會執行clean()
方法。如果要被刪除的節點不是連結串列中的尾節點,那麼比較簡單,直接使用CAS
替換前一個節點的next
指標。
如果要刪除的節點是連結串列中的尾節點,就會有點複雜了,因為多執行緒環境下可能正好有其他執行緒正在向尾節點後新增新的節點,這時如果直接刪除尾節點的話,會造成後面節點的丟失。
這時候就會用到TransferQueue
中定義的cleanMe
標記節點了,cleanMe
的作用就是當要被移除的節點是隊尾節點時,用它來標記隊尾節點的前驅節點。具體在執行過程中,又會分為兩種情況:
cleanMe
節點為null
,說明佇列在之前沒有標記需要刪除的節點。這時會使用cleanMe
來標識該節點的前驅節點,標記完成後退出clean
方法,當下一次執行clean
方法時才會刪除cleanMe
的下一個節點。
cleanMe
節點不為null
,那麼說明之前已經標記過需要刪除的節點。這時刪除cleanMe
的下一個節點,並清除當前cleanMe
標記,並再將當前節點未修改前的前驅節點標記為cleanMe
。注意,當前要被刪除的節點的前驅節點不會發生改變,即使這個前驅節點已經在邏輯上從佇列中刪除掉了。
執行完成clean
方法後,transfer
方法會直接返回null
,說明入隊操作失敗。
面試官:講了這麼多,入隊的還都是一個型別的節點吧?
Hydra:是的,TransferQueue
佇列中,只會存在一個型別的節點,如果有另一個型別的節點過來,那麼就會執行出隊的操作了。
面試官:好吧,那你接著再說說出隊方法吧。
Hydra:相對入隊來說,出隊的邏輯就比較簡單了。因為現在使用的是公平模式,所以當佇列不為空,且佇列的head
節點的下一個節點與當前節點匹配成功時,進行出隊操作,喚醒head
節點的下一個節點,進行資料的傳遞。
根據佇列中節點型別的不同,可以分為兩種情況進行分析:
1、如果head
節點的下一個節點是put
型別,當前新節點是take
型別。take
執行緒取出put
節點的item
的值,並將其item
變為null
,然後推進頭節點,喚醒被掛起的put
執行緒,take
執行緒返回item
的值,完成資料的傳遞過程。
head
節點的下一個節點被喚醒後,會推進head
節點,雖然前面說過佇列的head
節點是一個dummy
節點,並不儲存資料,理論上應該將第二個節點直接移出佇列,但是原始碼中還是將head
節點出隊,將原來的第二個節點變成了新的head
節點。
2、同理,如果head
節點的下一個節點是take
型別,當前新節點是put
型別。put
執行緒會將take
節點的item
設為自己的資料值,然後推進頭節點,並喚醒掛起的take
執行緒,喚醒的take
執行緒最終返回從put
執行緒獲得的item
的值。
此外,在take
執行緒喚醒後,會將自己QNode
的item
指標指向自己,並將waiter
中儲存的執行緒置為null
,方便之後被gc
回收。
面試官:也就是說,在程式碼中不一定非要生產者先去生產產品,也可以由消費者先到達後進行阻塞等待?
Hydra:是的,兩種執行緒都可以先進入佇列。
面試官:好了,公平模式下我是明白了,我去喝口水,給你十分鐘時間,回來我們聊聊非公平模式的實現吧。
Hydra:……