源碼|并發一枝花之BlockingQueue

今天來介紹Java并發編程中最受歡迎的同步類——堪稱并發一枝花之BlockingQueue。

JDK版本:oracle java 1.8.0_102

繼續閱讀之前,需確保你對鎖和條件隊列的使用方法爛熟于心,特別是條件隊列,否則你可能無法理解以下源碼的精妙之處,甚至基本的正確性。本篇暫不涉及此部分內容,需讀者自行準備。

接口定義

BlockingQueue繼承自Queue,增加了阻塞的入隊、出隊等特性:

public interface BlockingQueue<E> extends Queue<E> {
  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的核心思想:在隊頭和隊尾各持有一把鎖,入隊和出隊之間不存在競爭

前面在Java實現生產者-消費者模型中循序漸進的引出了BlockingQueue#put()和BlockingQueue#take()的實現,可以先去復習一下,了解為什么LinkedBlockingQueue要如此設計。以下是更細致的講解。

阻塞的入隊操作put()

在隊尾入隊。putLock和notFull配合完成同步。

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node<E> node = new Node<E>(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 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
}

現在觸發一個入隊操作,分情況討論。

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 <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}
...
private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    last = last.next = node;
}
...
private E dequeue() {
    // assert takeLock.isHeldByCurrentThread();
    // assert head.item == null;
    Node<E> h = head;
    Node<E> 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,則事件發生順序:

  1. T1線程:count 自增
  2. T2線程:while 檢查 count > 0,無需等待條件 notEmpty
  3. T2線程:dequeue 執行
  4. T1線程:enqueue 執行

很明顯,在事件1發生后事件4發生前,雖然count>0,但隊列中實際是沒有元素的。因此,事件3 dequeue會執行失敗(預計拋出NullPointerException)。事件4也就不會發生了。

如果先enqueue再count自增

如果先enqueue再count自增,就不會存在該問題。

仍假設目前隊列長度0,則事件發生順序:

  1. T1線程:enqueue 執行
  2. T2線程:while 檢查 count == 0,等待條件 notEmpty
  3. T1線程:count 自增
  4. T1線程:通知條件notFull滿足
  5. T1線程:通知條件notEmpty滿足
  6. T2線程:收到條件notEmpty
  7. T2線程:while 檢查 count > 0,無需等待條件 notEmpty
  8. 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;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        if (count.get() < capacity) {
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        }
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
    return c >= 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 <= 0)
                return false;
            nanos = notFull.awaitNanos(nanos);
        }
        enqueue(new Node<E>(e));
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
    return true;
}

該方法同put()很像,12-13行判斷nanos超時的情況(吞掉了timeout參數非法的異常情況),所以區別只有14行:將阻塞的notFull.await()換成非阻塞的超時版notFull.awaitNanos(nanos)

awaitNanos()的實現有點意思,這里不表。其實現類中的Javadoc描述非常干練:“Block until signalled, interrupted, or timed out.”,返回值為剩余時間。剩余時間小于等于參數nanos,表示:

  1. 條件notFull滿足(剩余時間大于0)
  2. 等待的總時長已超過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。

可能不全,歡迎評論,隨時增改。

總結

沒有。


本文鏈接:源碼|并發一枝花之BlockingQueue
作者:猴子007
出處:https://monkeysayhi.github.io
本文基于 知識共享署名-相同方式共享 4.0 國際許可協議發布,歡迎轉載,演繹或用于商業目的,但是必須保留本文的署名及鏈接。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372