前言
SynchronousQueue
是一個普通使用者不怎麼常用的佇列,通常在建立無界執行緒池(Executors.newCachedThreadPool()
)的時候使用,也就是那個非常危險的執行緒池 ^_^
。
它是一個非常特殊的阻塞佇列,他的模式是:在 offer
的時候,如果沒有另一個執行緒在 take 或者 poll
的話,就會失敗,反之,如果在 take
或者 poll
的時候,沒有執行緒在offer
,則也會失敗,而這種特性,則非常適合用來做高響應並且執行緒不固定的執行緒池的Queue
。所以,在很多高效能伺服器中,如果併發很高,這時候,普通的 LinkedQueue
就會成為瓶頸,效能就會出現毛刺,當換上 SynchronousQueue
後,效能就會好很多。
今天就看看這個特殊的 Queue 是怎麼實現的。友情提示:程式碼有些小複雜。。。請做好心理準備。
原始碼實現
SynchronousQueue 內部分為公平(佇列)和非公平(棧),佇列的效能相對而言會好點。構造方法中,就看出來了。預設是非公平的,通常非公平(棧 FIFO)的效能會高那麼一點點。
構造方法:
public SynchronousQueue(boolean fair) {
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
複製程式碼
offer 方法
該方法我們通常建議使用帶有超時機制的 offer
方法。
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
if (e == null) throw new NullPointerException();
if (transferer.transfer(e, true, unit.toNanos(timeout)) != null)
return true;
if (!Thread.interrupted())
return false;
throw new InterruptedException();
}
複製程式碼
從上面的程式碼中,可以看到核心方法就是 transfer
方法。如果該方法返回 true
,表示,插入成功,如果失敗,就返回 false
。
poll 方法
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
E e = transferer.transfer(null, true, unit.toNanos(timeout));
if (e != null || !Thread.interrupted())
return e;
throw new InterruptedException();
}
複製程式碼
同樣的該方法也是呼叫了 transfer
方法。結果返回得到的值或者null
。區別在於,offer
方法的 e
引數是實體的。而 poll
方法 e
引數是 null
,我們猜測,方法內部肯定根據這個做了判斷。所以,重點在於transfer
方法的實現。
而 transferer 有 2 種,佇列和棧,我們就研究一種,知曉其原理,另一種有時間在看。
TransferQueue 原始碼實現
構造方法:
TransferQueue() {
QNode h = new QNode(null, false); // initialize to dummy node.
head = h;
tail = h;
}
複製程式碼
構造一個 Node 節點,註釋說這是一個加的 node。並賦值給 head 和 tail 節點。形成一個初始化的連結串列。
看看這個 node:
/** Node class for TransferQueue. */
static final class 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;
}
複製程式碼
node 持有佇列中下一個 node,node 對應的值 value,持有該 node 的執行緒,擁有 park 或者 unpark,這裡用的是 JUC 的工具類 LockSupport,還有一個布林型別,isData,這個非常重要,需要好好理解,到後面我們會好好講解。
我們更關注的是這個類的 transfer 方法,該方法是 SynchronousQueue 的核心。
該方法介面定義如下:
/**
* Performs a put or take. put 或者 take
*
* @param e if non-null, the item to be handed to a consumer;
* if null, requests that transfer return an item
* offered by producer.
* @param timed if this operation should timeout
* @param nanos the timeout, in nanoseconds
* @return if non-null, the item provided or received; if null,
* the operation failed due to timeout or interrupt --
* the caller can distinguish which of these occurred
* by checking Thread.interrupted.
*/
abstract E transfer(E e, boolean timed, long nanos);
複製程式碼
註釋說道 e 引數的作用:
如果 e 不是 null(說明是生產者呼叫) ,將 item 交給消費者,並返回 e;反之,如果是 null(說明是消費者呼叫),將生產者提供的 item 返回給消費者。
看看 TransferQueue 類的 transfer 方法實現,樓主寫了很多的註釋嘗試解讀:
QNode s = null; // constructed/reused as needed
boolean isData = (e != null);// 當輸入的是資料時,isData 就是 ture,表明這個操作是一個輸入資料的操作;同理,當呼叫者輸入的是 null,則是在消費資料。
for (;;) {
QNode t = tail;
QNode h = head;
if (t == null || h == null) // 如果併發導致未"來得及"初始化
continue; // 自旋重來
// 以下分成兩個部分進行
// 1. 如果當前操作和 tail 節點的操作是一樣的;或者頭尾相同(表明佇列中啥都沒有)。
if (h == t || t.isData == isData) {
QNode tn = t.next;
if (t != tail) // 如果 t 和 tail 不一樣,說明,tail 被其他的執行緒改了,重來
continue;
if (tn != null) { // 如果 tail 的 next 不是空。就需要將 next 追加到 tail 後面了。
advanceTail(t, tn); // 使用 CAS 將 tail.next 變成 tail,
continue;
}
if (timed && nanos <= 0) // 時間到了,不等待,返回 null,插入失敗,獲取也是失敗的。
return null;
if (s == null) // 如果能走到這裡,說明 tail 的 next 是 null,這裡的判斷是避免重複建立 Qnode 物件。
s = new QNode(e, isData);// 建立一個新的節點。
if (!t.casNext(null, s)) // 嘗試 CAS 將這個剛剛建立的節點追加到 tail 的 next 節點上.
continue;// 如果失敗,則重來
advanceTail(t, s); // 當新的節點成功追加到 tail 節點的 next 上了, 就嘗試將 tail.next 節點覆蓋 tail 節點,稱之為推進。
// s == 新節點,“可能”是新的 tail;e 是實際資料。
Object x = awaitFulfill(s, e, timed, nanos);// 該方法作用就是,讓當前執行緒等待。排除意外情況和超時的話,就是等待其他執行緒拿走資料並替換成 isData 不同的資料。
if (x == s) { // x == s 是什麼意思呢? 表明在 awaitFulfill 方法中,這個資料被取消了,tryCancel 方法就是將 item 覆蓋了 QNode。說明這次操作失敗了。
clean(t, s);// 操作失敗則需要清理資料,並返回 null。
return null;
}
// 如果一切順利,確實被其他執行緒喚醒了,其他執行緒也交換了資料。
// 這個判斷:next != this,說明了什麼?當這個 tail 節點的 next 不再指向自己,說明了
if (!s.isOffList()) { // not already unlinked
// 這一步是將 S 節點設定為 Head,並且將新 Head 的 next 指向自己,讓 Head 和之前的 next 斷開。
advanceHead(t, s); // unlink if head
// 當 x 不是 null,表明對方執行緒是存放資料的。
if (x != null) // and forget fields
// 這一步操作將自己的 item 設定成自己。
s.item = s;
// 將 S 節點的持有執行緒變成 null。
s.waiter = null;
}
// x 不是 null 表明,對方執行緒是生產者,返回他生產的資料;如果是 null,說明對方執行緒是消費者,那他自己就是生產者,返回自己的資料,表示成功。
return (x != null) ? (E)x : e;
}
// 2. 如果當前的操作型別和 tail 的操作不一樣。稱之為互補。
else { // complementary-mode
QNode m = h.next; // node to fulfill
// 如果下方這些判斷沒過,說明併發修改了,自旋重來。
if (t != tail || m == null || h != head)
continue; // inconsistent read
Object x = m.item;
// 如果 head 節點的 isData 和當前操作相同,
// 如果 操作不同,但 head 的 item 就是自身,也就是發生了取消操作,tryCancel 方法會做這件事情。
// 如果上面2個都不滿足,嘗試使用 CAS 將 e 覆蓋 item。
if (isData == (x != null) || // m already fulfilled
x == m || // m cancelled
!m.casItem(x, e)) { // lost CAS
// CAS 失敗了,Head 的操作型別和當前型別相同,item 被取消了,都會走這裡。
// 將 h.next 覆蓋 head。重來。
advanceHead(h, m); // dequeue and retry
continue;
}
// 這裡也是將 h.next 覆蓋 head。能夠走到這裡,說明,上面的 CAS 操作成功了,當前執行緒已經將 e 覆蓋了 next 的 item 。
advanceHead(h, m); // successfully fulfilled
// 喚醒 next 的 執行緒。提醒他可以取出資料,或者“我”已經拿到資料了。
LockSupport.unpark(m.waiter);
// 如果 x 不是 null,表明這是一次消費資料的操作,反之,這是一次生產資料的操作。
return (x != null) ? (E)x : e;
}
}
複製程式碼
說實話,程式碼還是比較複雜的。JDK 中註釋是這麼說的:
基本演算法是死迴圈採取 2 種方式中的其中一種。 1 如果佇列是空的,或者持有相同的模式節點(
isData
相同),就嘗試新增節點到佇列中,並讓當前執行緒等待。 2 如果佇列中有執行緒在等待,那麼就使用一種互補
的方式,使用 CAS 和等待者交換資料。並返回。
什麼意思呢?
首先明確一點,佇列中,資料有 2 種情況(但同時只存在一種),要麼QNode
中有實際資料(offer
的時候,是有資料的,但沒有“人”來取),要麼沒有實際資料(poll
的時候,佇列中沒有資料,執行緒只好等待)。佇列在哪一種狀態取決於他為空後,第一個插入的是什麼型別的資料
。
樓主畫了點圖來表示:
- 佇列初始化的時候,只有一個空的
Node
。
- 此時,一個執行緒嘗試
offer
或者poll
資料,都會插入一個Node
插入到節點中。
- 假設剛剛發生的是 offer 操作,這個時候,另一個執行緒也來 offer,這時就會有 2 個節點。
- 這個時候,佇列中有 2 個有真實資料(offer 操作)的節點了,注意,這個時候,那 2 個執行緒都是
wait
的,因為沒有人接受他們的資料。此時,又來一個執行緒,做 poll 操作。
從上圖可以看出,poll
執行緒從head
開始取資料,因為它的 isData
和 tail
節點的 isData 不同,那麼就會從 head 開始找節點,並嘗試將自己的 null 值和節點中的真實資料進行交換。並喚醒等待中的執行緒。
這 4 幅圖就是 SynchronousQueue
的精華。
既然叫做同步佇列,一定是 A 執行緒生產資料的時候,有 B 執行緒在消費,否則 A 執行緒就需要等待,反之,如果 A 執行緒準備消費資料,但佇列中沒有資料,執行緒也會等待,直到有 B 執行緒存放資料。
而 JDK 的實現原理則是:使用一個佇列,佇列中的用一個 isData
來區分生產還是消費,所有新操作都根據 tail 節點的模式來決定到底是追加到 tail
節點還是和 tail
節點(從 head
開始)交換資料。
而所謂的交換是從head
開始,取出節點的實際資料,然後使用 CAS
和匹配到的節點進行交換。從而完成兩個執行緒直接交換資料的操作。
為什麼他在某些情況下,比LinkedBlockingQueue
效能高呢?其中有個原因就是沒有使用鎖,減少了執行緒上下文切換。第二則是執行緒之間交換資料的方式更加的高效。
好,重點部分講完了,再看看其中執行緒是如何等待的。邏輯在 awaitFulfill
方法中:
// 自旋或者等待,直到填充完畢
// 這裡的策略是什麼呢?如果自旋次數不夠了,通常是 16 次,但還有超過 1 秒的時間,就阻塞等待被喚醒。
// 如果時間到了,就取消這次的入隊行為。
// 返回的是 Node 本身
// s.item 就是 e
Object awaitFulfill(QNode s, E e, boolean timed, long nanos) {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
Thread w = Thread.currentThread();
int spins = ((head.next == s) ?// 如果成功將 tail.next 覆蓋了 tail,如果有超時機制,則自旋 32 次,如果沒有超時機制,則自旋 32 *16 = 512次
(timed ? maxTimedSpins : maxUntimedSpins) : 0);
for (;;) {
if (w.isInterrupted())// 當前執行緒被中斷
s.tryCancel(e);// 嘗試取消這個 item
Object x = s.item;// 獲取到這個 tail 的 item
if (x != e) // 如果不相等,說明 node 中的 item 取消了,返回這個 item。
// 這裡是唯一停止迴圈的地方。當 s.item 已經不是當初的哪個 e 了,說明要麼是時間到了被取消了,要麼是執行緒中斷被取消了。
// 當然,不僅僅只有這2種 “意外” 情況,還有一種情況是:當另一個執行緒拿走了這個資料,並修改了 item,也會通過這個判斷,返回被“修改”過的 item。
return x;
if (timed) {// 如果有時間限制
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {// 如果時間到了
s.tryCancel(e);// 嘗試取消 item,供上面的 x != e 判斷
continue;// 重來
}
}
if (spins > 0)// 如果還有自旋次數
--spins;// 減一
else if (s.waiter == null)// 如果自旋不夠,且 tail 的等待執行緒還沒有賦值
s.waiter = w;// 當前執行緒賦值給 tail 的等待執行緒
else if (!timed)// 如果自旋不夠,且如果執行緒賦值過了,且沒有限制時間,則 wait,(危險操作)
LockSupport.park(this);
else if (nanos > spinForTimeoutThreshold)// 如果自旋不夠,且如果限制了時間,且時間還剩餘超過 1 秒,則 wait 剩餘時間。
// 主要目的就是等待,等待其他執行緒喚醒這個節點所在的執行緒。
LockSupport.parkNanos(this, nanos);
}
}
複製程式碼
該方法邏輯如下:
- 預設自旋 32 次,如果沒有超時機制,則 512 次。
- 如果時間到了,或者執行緒被中斷,則取消這次的操作,將
item
設定成自己。供後面判斷。 - 如果自旋結束,且剩餘時間還超過 1 秒,則阻塞等待至剩餘時間。
- 當執行緒被其他的執行緒喚醒,說明資料被交換了。則
return
,返回的是交換後的資料。
總結
好了,關於 SynchronousQueue
的核心原始碼分析就到這裡了,樓主沒有分析這個類的所有原始碼,只研究了核心部分程式碼,這足夠我們理解這個 Queue
的內部實現了。
總結下來就是:
JDK 使用了佇列或者棧來實現公平或非公平模型。其中,isData
屬性極為重要,標識這這個執行緒的這次操作,決定了他到底應該是追加到佇列中,還是從佇列中交換資料。
每個執行緒在沒有遇到自己的另一半時,要麼快速失敗,要麼進行阻塞,阻塞等待自己的另一半來,至於對方是給資料還是取資料,取決於她自己,如果她是消費者,那麼他就是生產者。
good luck!!!!