面試侃集合 | SynchronousQueue公平模式篇

碼農參上發表於2021-06-03

面試官:呦,小夥子來的挺早啊!

Hydra:那是,不能讓您等太久了啊(別廢話了快開始吧,還趕著去下一場呢)。

面試官:前面兩輪表現還不錯,那我們今天繼續說說佇列中的SynchronousQueue吧。

Hydra:好的,SynchronousQueue和之前介紹過的佇列相比,稍微有一些特別,必須等到佇列中的元素被消費後,才能繼續向其中新增新的元素,因此它也被稱為無緩衝的等待佇列。

我還是先寫一個例子吧,建立兩個執行緒,生產者執行緒putThreadSynchronousQueue中放入元素,消費者執行緒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,真是一個神奇的佇列,不管你呼叫的是putoffer,還是takepoll,它都一概交給核心的transfer方法去處理,只不過引數不同。今天我們拋棄原始碼,通過畫圖對它進行分析,首先看一下方法的定義:

E transfer(E e, boolean timed, long nanos);

面試官:呦呵,就一個方法?我倒要看看它是怎麼區分實現的入隊和出隊操作…

Hydra:在方法的引數中,timednanos用於標識呼叫transfer的方法是否是能夠超時退出的,而e是否為空則可以說明是生產者還是消費者呼叫的此方法。如果e不為null,是生產者呼叫,如果enull則是消費者呼叫。方法的整體邏輯可以分為下面幾步:

1、若佇列為空,或佇列中的尾節點型別和自己的型別相同,那麼準備封裝一個新的QNode新增到佇列中。在新增新節點到隊尾的過程中,並沒有使用synchronizedReentrantLock,而是通過CAS來保證執行緒之間的同步。

在新增新的QNode到隊尾前,會首先判斷之前取到的尾節點是否發生過改變,如果有改變的話那麼放棄修改,進行自旋,在下一次迴圈中再次判斷。當檢查隊尾節點沒有發生改變後,構建新的節點QNode,並將它新增到隊尾。

2、當新節點被新增到隊尾後,會呼叫awaitFulfill方法,會根據傳遞的引數讓執行緒進行自旋或直接掛起。方法的定義如下,引數中的timedtrue時,表示這是一個有等待超時的方法。

Object awaitFulfill(QNode s, E e, boolean timed, long nanos);

awaitFulfill方法中會進行判斷,如果新節點是head節點的下一個節點,考慮到可能很快它就會完成匹配後出隊,先不將它掛起,進行一定次數的自旋,超過自旋次數的上限後再進行掛起。如果不是head節點的下一個節點,避免自旋造成的資源浪費,則直接呼叫parkparkNanos掛起執行緒。

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執行緒喚醒後,會將自己QNodeitem指標指向自己,並將waiter中儲存的執行緒置為null,方便之後被gc回收。

面試官:也就是說,在程式碼中不一定非要生產者先去生產產品,也可以由消費者先到達後進行阻塞等待?

Hydra:是的,兩種執行緒都可以先進入佇列。

面試官:好了,公平模式下我是明白了,我去喝口水,給你十分鐘時間,回來我們聊聊非公平模式的實現吧。

Hydra:……

相關文章