面試侃集合 | SynchronousQueue公平模式篇
面試官:呦,小夥子來的挺早啊!
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:……