Java多執行緒15:Queue、BlockingQueue以及利用BlockingQueue實現生產者/消費者模型

五月的倉頡發表於2015-10-05

Queue是什麼

佇列,是一種資料結構。除了優先順序佇列和LIFO佇列外,佇列都是以FIFO(先進先出)的方式對各個元素進行排序的。無論使用哪種排序方式,佇列的頭都是呼叫remove()或poll()移除元素的。在FIFO佇列中,所有新元素都插入佇列的末尾。

 

Queue中的方法

Queue中的方法不難理解,6個,每2對是一個也就是總共3對。看一下JDK API就知道了:

注意一點就好,Queue通常不允許插入Null,儘管某些實現(比如LinkedList)是允許的,但是也不建議。

 

BlockingQueue

1、BlockingQueue概述

只講BlockingQueue,因為BlockingQueue是Queue中的一個重點,並且通過BlockingQueue我們再次加深對於生產者/消費者模型的理解。其他的Queue都不難,通過檢視JDK API和簡單閱讀原始碼完全可以理解他們的作用。

BlockingQueue,顧名思義,阻塞佇列。BlockingQueue是在java.util.concurrent下的,因此不難理解,BlockingQueue是為了解決多執行緒中資料高效安全傳輸而提出的。

多執行緒中,很多場景都可以使用佇列實現,比如經典的生產者/消費者模型,通過佇列可以便利地實現兩者之間資料的共享,定義一個生產者執行緒,定義一個消費者執行緒,通過佇列共享資料就可以了。

當然現實不可能都是理想的,比如消費者消費速度比生產者生產的速度要快,那麼消費者消費到 一定程度上的時候,必須要暫停等待一下了(使消費者執行緒處於WAITING狀態)。BlockingQueue的提出,就是為了解決這個問題的,他不用程式設計師去控制這些細節,同時還要兼顧效率和執行緒安全。

阻塞佇列所謂的"阻塞",指的是某些情況下執行緒會掛起(即阻塞),一旦條件滿足,被掛起的執行緒又會自動喚醒。使用BlockingQueue,不需要關心什麼時候需要阻塞執行緒,什麼時候需要喚醒執行緒,這些內容BlockingQueue都已經做好了

2、BlockingQueue中的方法

BlockingQueue既然是Queue的子介面,必然有Queue中的方法,上面已經列了。看一下BlockingQueue中特有的方法:

(1)void put(E e) throws InterruptedException

把e新增進BlockingQueue中,如果BlockingQueue中沒有空間,則呼叫執行緒被阻塞,進入等待狀態,直到BlockingQueue中有空間再繼續

(2)void take() throws InterruptedException

取走BlockingQueue裡面排在首位的物件,如果BlockingQueue為空,則呼叫執行緒被阻塞,進入等待狀態,直到BlockingQueue有新的資料被加入

(3)int drainTo(Collection<? super E> c, int maxElements)

一次性取走BlockingQueue中的資料到c中,可以指定取的個數。通過該方法可以提升獲取資料效率,不需要多次分批加鎖或釋放鎖

3、ArrayBlockingQueue

基於陣列的阻塞佇列,必須指定佇列大小。比較簡單。ArrayBlockingQueue中只有一個ReentrantLock物件,這意味著生產者和消費者無法並行執行(見下面的程式碼)。另外,建立ArrayBlockingQueue時,可以指定ReentrantLock是否為公平鎖,預設採用非公平鎖。

/** Main lock guarding all access */
private final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;

4、LinkedBlockingQueue

基於連結串列的阻塞佇列,和ArrayBlockingQueue差不多。不過LinkedBlockingQueue如果不指定佇列容量大小,會預設一個類似無限大小的容量,之所以說是類似是因為這個無限大小是Integer.MAX_VALUE,這麼說就好理解ArrayBlockingQueue為什麼必須要制定大小了,如果ArrayBlockingQueue不指定大小的話就用Integer.MAX_VALUE,那將造成大量的空間浪費,但是基於連結串列實現就不一樣的,一個一個節點連起來而已。另外,LinkedBlockingQueue生產者和消費者都有自己的鎖(見下面的程式碼),這意味著生產者和消費者可以"同時"執行。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

5、SynchronousQueue

比較特殊,一種沒有緩衝的等待佇列。什麼叫做沒有緩衝區,ArrayBlocking中有:

/** The queued items  */
private final E[] items;

陣列用以儲存佇列。LinkedBlockingQueue中有:

/**
 * Linked list node class
 */
static class Node<E> {
    /** The item, volatile to ensure barrier separating write and read */
    volatile E item;
    Node<E> next;
    Node(E x) { item = x; }
}

將佇列以連結串列形式連線。

生產者/消費者運算元據實際上都是通過這兩個"中介"來運算元據的,但是SynchronousQueue則是生產者直接把資料給消費者(消費者直接從生產者這裡拿資料),好像又回到了沒有生產者/消費者模型的老辦法了。換句話說,每一個插入操作必須等待一個執行緒對應的移除操作。SynchronousQueue又有兩種模式:

1、公平模式

採用公平鎖,並配合一個FIFO佇列(Queue)來管理多餘的生產者和消費者

2、非公平模式

採用非公平鎖,並配合一個LIFO棧(Stack)來管理多餘的生產者和消費者,這也是SynchronousQueue預設的模式

 

利用BlockingQueue實現生產者消費者模型

上一篇我們寫的生產者消費者模型有侷限,侷限體現在:

  • 緩衝區內只能存放一個資料,實際生產者/消費者模型中的緩衝區內可以存放大量生產者生產出來的資料
  • 生產者和消費者處理資料的速度幾乎一樣

OK,我們就用BlockingQueue來簡單寫一個例子,並且讓生產者、消費者處理資料速度不同。子類選擇的是ArrayBlockingQueue,大小定為10:

public static void main(String[] args)
{
    final BlockingQueue<String> bq = new ArrayBlockingQueue<String>(10);
    Runnable producerRunnable = new Runnable()
    {
        int i = 0;
        public void run()
        {
            while (true)
            {
                try
                {
                    System.out.println("我生產了一個" + i++);
                    bq.put(i + "");
                    Thread.sleep(1000);
                } 
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
            }
        }
    };
    Runnable customerRunnable = new Runnable()
    {
        public void run()
        {
            while (true)
            {
                try
                {
                    System.out.println("我消費了一個" + bq.take());
                    Thread.sleep(3000);
                } 
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
            }
        }
    };
    Thread producerThread = new Thread(producerRunnable);
    Thread customerThread = new Thread(customerRunnable);
    producerThread.start();
    customerThread.start();
}

程式碼的做法是讓生產者生產速度快於消費者消費速度的,看一下執行結果:

 1 我生產了一個0
 2 我消費了一個1
 3 我生產了一個1
 4 我生產了一個2
 5 我消費了一個2
 6 我生產了一個3
 7 我生產了一個4
 8 我生產了一個5
 9 我消費了一個3
10 我生產了一個6
11 我生產了一個7
12 我生產了一個8
13 我消費了一個4
14 我生產了一個9
15 我生產了一個10
16 我生產了一個11
17 我消費了一個5
18 我生產了一個12
19 我生產了一個13
20 我生產了一個14
21 我消費了一個6
22 我生產了一個15
23 我生產了一個16
24 我消費了一個7
25 我生產了一個17
26 我消費了一個8
27 我生產了一個18

分兩部分來看輸出結果:

1、第1行~第23行。這塊BlockingQueue未滿,所以生產者隨便生產,消費者隨便消費,基本上都是生產3個消費1個,消費者消費速度慢

2、第24行~第27行,從前面我們可以看出,生產到16,消費到6,說明到了ArrayBlockingQueue的極限10了,這時候沒辦法,生產者生產一個ArrayBlockingQueue就滿了,所以不能繼續生產了,只有等到消費者消費完才可以繼續生產。所以之後的列印內容一定是一個生產者、一個消費者

這就是前面一章開頭說的"通過平衡生產者和消費者的處理能力來提高整體處理資料的速度",這給例子應該體現得很明顯。另外,也不要擔心非單一生產者/消費者場景下的系統假死問題,緩衝區空、緩衝區滿的場景BlockingQueue都是定義了不同的Condition,所以不會喚醒自己的同類。

相關文章