背景
因為在工作中經常會用到阻塞佇列,有的時候還要根據業務場景獲取重寫阻塞佇列中的方法,所以學習一下阻塞佇列的實現原理還是很有必要的。(PS:不深入瞭解的話,很容易使用出錯,造成沒有技術深度的樣子)
阻塞佇列是什麼?
要想了解阻塞佇列,先了解一下佇列是啥,簡單的說佇列就是一種先進先出的資料結構。(具體的內容去資料結構裡學習一下)所以阻塞佇列就是一種可阻塞的佇列。和普通的佇列的不同就體現在 ”阻塞“兩個字上。阻塞是啥意思?
百度看一下
在軟體工程裡阻塞一般指的是阻塞呼叫,即呼叫結果返回之前,當前執行緒會被掛起。函式只有在得到結果之後才會返回。
阻塞佇列其實就是普通的佇列根據需要將某些方法改為阻塞呼叫。所以阻塞隊裡和普通隊裡的不同主要體現在兩個方面
- 當佇列是空的時,從佇列中獲取元素的操作將會被阻塞 。直到其他的執行緒往空的佇列插入新的元素
- 當佇列是滿時,往佇列裡新增元素的操作會被阻塞,直到其他的執行緒使佇列重新變得空閒起來,如從佇列中移除一個或者多個元素,或者完全清空佇列
為什麼要使用阻塞佇列?
那麼為什麼要使用阻塞佇列?阻塞佇列又能完成什麼特殊的任務嗎?
阻塞佇列的經典使用 場景就是“生產者”和“消費者”模型,生產者生產資料,放入佇列,然後消費從佇列中獲取資料,這個在一般情況下自然沒有問題,但如果生產者和消費者在某個時間段內,萬一發生資料處理速度不匹配的情況呢?
在出現消費者速度遠大於生產者速度,消費者在資料消費至一定程度的情況下,暫停等待一下(阻塞消費者)來等待生產者,以保證生產者能夠生產出新的資料;反之亦然。
阻塞佇列在java中的一種典型使用場景是執行緒池,線上程池中,當提交的任務不能被立即得到執行的時候,執行緒池就會將提交的任務放到一個阻塞的任務佇列中來(執行緒池的具體使用參見之前寫的一篇文章《java併發之執行緒池的淺析》)
然而,在阻塞佇列釋出以前,在多執行緒環境下,我們每個程式設計師都必須去自己控制這些細節,尤其還要兼顧效率和執行緒安全,而這會給我們的程式帶來不小的複雜度。在這裡要感謝一下concurrent包,減輕了我們很多工作
阻塞佇列的成員有哪些
下面分別簡單介紹一下:
-
ArrayBlockingQueue:是一個用陣列實現的有界阻塞佇列,此佇列按照先進先出(FIFO)的原則對元素進行排序。構造時必須傳入的引數是陣列大小此外還可以指定是否公平性。【注:每一個執行緒在獲取鎖的時候可能都會排隊等待,如果在等待時間上,先獲取鎖的執行緒的請求一定先被滿足,那麼這個鎖就是公平的。反之,這個鎖就是不公平的。公平的獲取鎖,也就是當前等待時間最長的執行緒先獲取鎖】;在插入或刪除元素時不會產生或銷燬任何額外的物件例項
- LinkedBlockingQueue:一個由連結串列結構組成的有界佇列,照先進先出的順序進行排序 ,未指定長度的話,預設 此佇列的長度為Integer.MAX_VALUE。。【PS:如果生產者的速度遠遠大於消費者的速度,也許還沒有等到佇列滿阻塞產生,系統記憶體就有可能已經被消耗殆盡了。】PriorityBlockingQueue: 一個支援執行緒優先順序排序的無界佇列,預設自然序進行排序,也可以自定義實現compareTo()方法來指定元素排序規則,不能保證同優先順序元素的順序。
- LinkedBlockingQueue之所以能夠高效的處理併發資料,是因為take()方法和put(E param)方法使用了不同的可重入鎖,分別為private final ReentrantLock putLock和private final ReentrantLock takeLock,這也意味著在高併發的情況下生產者和消費者可以並行地操作佇列中的資料,以此來提高整個佇列的併發效能
- LinkedBlockingQueue在插入元素是會建立一個額外的Node物件,所以它這在長時間內需要高效併發地處理大批量資料的系統中,對於GC的還是存在一定的影響。
- DelayQueue: 一個實現PriorityBlockingQueue實現延遲獲取的無界佇列,在建立元素時,可以指定多久才能從佇列中獲取當前元素。只有延時期滿後才能從佇列中獲取元素。(DelayQueue可以運用在以下應用場景:1.快取系統的設計:可以用DelayQueue儲存快取元素的有效期,使用一個執行緒迴圈查詢DelayQueue,一旦能從DelayQueue中獲取元素時,表示快取有效期到了。2.定時任務排程。使用DelayQueue儲存當天將會執行的任務和執行時間,一旦從DelayQueue中獲取到任務就開始執行,從比如TimerQueue就是使用DelayQueue實現的。)
- SynchronousQueue: 一個不儲存元素的阻塞佇列,每一個put操作必須等待take操作,否則不能新增元素。支援公平鎖和非公平鎖。SynchronousQueue的一個使用場景是線上程池裡。Executors.newCachedThreadPool()就使用了SynchronousQueue,這個執行緒池根據需要(新任務到來時)建立新的執行緒,如果有空閒執行緒則會重複使用,執行緒空閒了60秒後會被回收。
- LinkedTransferQueue: 一個由連結串列結構組成的無界阻塞佇列,相當於其它佇列,LinkedTransferQueue佇列多了transfer和tryTransfer方法。
-
LinkedBlockingDeque: 一個由連結串列結構組成的雙向阻塞佇列。佇列頭部和尾部都可以新增和移除元素,多執行緒併發時,可以將鎖的競爭最多降到一半。
阻塞佇列的核心方法
阻塞對佇列的核心方法主要是插入操作操作和取出操作,如下
- Throws Exception 型別的插入和取出在不能立即被執行的時候就會丟擲異常。
- Special Value 型別的插入和取出在不能被立即執行的情況下會返回一個特殊的值(true 或者 false 或者null)
- Blocked 型別的插入和取出操作在不能被立即執行的時候會阻塞執行緒直到可以操作的時候會被其他執行緒喚醒
- Timed out 型別的插入和取出操作在不能立即執行的時候會被阻塞一定的時候,如果在指定的時間內沒有被執行,那麼會返回一個特殊值
插入操作
- boolean offer(E e):將指定元素插入此佇列中(如果立即可行且不會違反容量限制),成功時返回 true,如果當前沒有可用的空間,則返回 false。(本方法不阻塞當前執行方法的執行緒)。
- boolean offer(E o, long timeout, TimeUnit unit):可以設定等待的時間,如果在設定的指定的時間內,還不能往佇列中加入BlockingQueue,則返回false。
- void put(E paramE) throws InterruptedException:將指定元素插入到此佇列中裡,如果佇列沒有空間,則呼叫此方法的執行緒被阻斷直到佇列裡裡面有空間再繼續執行插入操作。
- public boolean add(E e): 將指定元素插入此佇列中(如果立即可行且不會違反容量限制),成功時返回 true,如果當前沒有可用的空間,則丟擲 IllegalStateException(其實就是呼叫了offer方法)。
public boolean add(E e) { if (offer(e)) return true; else throw new IllegalStateException("Queue full"); }
獲取操作
- poll():取走BlockingQueue裡排在首位的物件,,取不到時返回null;
- poll(long timeout, TimeUnit unit):在指定時間內從BlockingQueue取出一個隊首的物件,佇列一旦有資料可取,則立即返回佇列中的資料。否則直到時間超時還沒有資料可取,返回null。
- take():取走BlockingQueue裡排在首位的物件,若BlockingQueue為空,阻斷進入等待狀態直到BlockingQueue有新的資料被加入;
- drainTo(Collection<? super E> c, int maxElements):一次性從BlockingQueue獲取所有可用的資料物件,將資料物件加入傳遞的集合中(還可以通過maxElements指定獲取資料的個數),通過該方法,可以提升獲取資料效率;不需要多次分批加鎖或釋放鎖
阻塞佇列的實現原理
前面介紹了非阻塞佇列和阻塞佇列中常用的方法,下面來探討阻塞佇列的實現原理,本文以比較常用的ArrayBlockingQueue為例,其他阻塞佇列實現原理根據特性會和ArrayBlockingQueue有一些差別,但是大體思路應該類似,有興趣的朋友可自行檢視其他阻塞佇列的實現原始碼。
首先看一下ArrayBlockingQueue的幾個關鍵成員變數
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable { /** The queued items */ final Object[] items; /** items index for next take, poll, peek or remove */ int takeIndex; /** items index for next put, offer, or add */ int putIndex; /** Number of elements in the queue */ int count; /* * Concurrency control uses the classic two-condition algorithm * found in any textbook. */ /** Main lock guarding all access */ final ReentrantLock lock; /** Condition for waiting takes */ private final Condition notEmpty; /** Condition for waiting puts */ private final Condition notFull; }
從上邊可以明顯的看出ArrayBlockingQueue用一個陣列來儲存資料,takeIndex和putIndex分別表示隊首元素和隊尾元素的下標,count表示佇列中元素的個數。 lock是一個可重入鎖,notEmpty和notFull是等待條件。
然後看它的一個關鍵方法的實現:put()
public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); }
}
- 首選檢查元素是否為空,為空則丟擲異常
- 接著例項化可重入鎖
- 然後localReentrantLock.lockInterruptibly();這裡特別強調一下 (lockInterruptibly()允許在等待時由其他執行緒的Thread.interrupt()方法來中斷等待執行緒而直接返回,這時是不用獲取鎖的,而會丟擲一個InterruptException。 而ReentrantLock.lock()方法則不允許Thread.interrupt()中斷,即使檢測到了Thread.interruptted一樣會繼續嘗試獲取鎖,失敗則繼續休眠。只是在最後獲取鎖成功之後在把當前執行緒置為中斷狀態)
- 判斷當前元素個數是否等於陣列的長度,如果相等,則呼叫notFull.await()進行等待,即當佇列滿的時候,將會等待
- 將元素插入到佇列中
- 解鎖(這裡一定要在finally中解鎖啊!!!)
enqueue(E x)將元素插入到陣列啊item中
/** * Inserts element at current put position, advances, and signals. * Call only when holding lock. */ private void enqueue(E x) { // assert lock.getHoldCount() == 1; // assert items[putIndex] == null; final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); }
該方法內部通過putIndex索引直接將元素新增到陣列items中
這裡思考一個問題 為什麼當putIndex索引大小等於陣列長度時,需要將putIndex重新設定為0?
這是因為當佇列是先進先出的 所以獲取元素總是從佇列頭部獲取,而新增元素從中從佇列尾部獲取。所以當佇列索引(從0開始)與陣列長度相等時,所以下次我們就需要從陣列頭部開始新增了;
最後當插入成功後,通過notEmpty喚醒正在等待取元素的執行緒
阻塞佇列中和put對應的就是take了
下邊是take方法的實現
public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return dequeue(); finally { lock.unlock(); } }
take方法其實很簡單,佇列中有資料就刪除沒有就阻塞,注意這個阻塞是可以中斷的,如果佇列沒有資料那麼就加入notEmpty條件佇列等待(有資料就直接取走,方法結束),如果有新的put執行緒新增了資料,那麼put操作將會喚醒take執行緒;
可以看到take的實現跟put方法實現很類似,只不過put方法等待的是notFull訊號,而take方法等待的是notEmpty訊號。(等的就是上文的put中的訊號)當陣列的數量為空時,也就是無任何資料可以被取出來的時候,notEmpty這個Condition就會進行阻塞,直到被notEmpty喚醒
dequeue的實現如下
private E dequeue() { final Object[] items = this.items; E x = (E) items[takeIndex]; items[takeIndex] = null; if (++takeIndex == items.length) takeIndex = 0; count--; if (itrs != null) itrs.elementDequeued(); notFull.signal(); return x; }
take方法主要是從佇列頭部取元素,可以看到takeIndex是取元素的時候的偏移值,而put中是putIndex控制新增元素的偏移量,由此可見,put和take操作的偏移量分別是由putIndex和takeIndex控制的。其實仔細觀察put和take的實現思路是有很多相似之處。
- offer(E o, long timeout, TimeUnit unit)的實現方式其實和put的思想是差不多的區別是 offer在阻塞的時候呼叫的不是await()方法而是awaitNanos(long nanosTimeout) 帶超時響應的等待(PS:具體區別可以參考我之前寫的關於鎖的部落格《JAVA併發之鎖的使用淺析》)
- poll(long timeout, TimeUnit unit)的實現也是這樣在take的基礎上加了超時響應。感興趣的朋友可以自行去看一下
案例分析
模擬食堂的經歷,食堂視窗端出一道菜放在臺面,然後等待顧客消費。寫到程式碼裡就是食堂視窗就是一個生產者執行緒,顧客就是消費者執行緒,檯面就是阻塞佇列。
public class TestBlockingQueue { /** * 生產和消費業務操作 * * @author tang * */ protected class WorkDesk { BlockingQueue<String> desk = new LinkedBlockingQueue<String>(8); public void work() throws InterruptedException { Thread.sleep(1000); desk.put("端出一道菜"); } public String eat() throws InterruptedException { Thread.sleep(4000); return desk.take(); } } /** * 生產者類 * * @author tang * */ class Producer implements Runnable { private String producerName; private WorkDesk workDesk; public Producer(String producerName, WorkDesk workDesk) { this.producerName = producerName; this.workDesk = workDesk; } @Override public void run() { try { for (;;) { workDesk.work(); System.out.println(producerName + "端出一道菜" +",Data:"+System.currentTimeMillis()); } } catch (Exception e) { e.printStackTrace(); } } } /** * 消費者類 * * */ class Consumer implements Runnable { private String consumerName; private WorkDesk workDesk; public Consumer(String consumerName, WorkDesk workDesk) { this.consumerName = consumerName; this.workDesk = workDesk; } @Override public void run() { try { for (;;) { workDesk.eat(); System.out.println(consumerName + "端走了一個菜"+",Data:"+System.currentTimeMillis()); } } catch (Exception e) { e.printStackTrace(); } } } public static void main(String args[]) throws InterruptedException { TestBlockingQueue testQueue = new TestBlockingQueue(); WorkDesk workDesk = testQueue.new WorkDesk(); ExecutorService service = Executors.newFixedThreadPool(6); //四個生產者執行緒 for (int i=1;i<=4;++i) { service.submit(testQueue.new Producer("食堂視窗-"+ i+"-", workDesk)); } //兩個消費者執行緒 Consumer consumer1 = testQueue.new Consumer("顧客-1-", workDesk); Consumer consumer2 = testQueue.new Consumer("顧客-2-", workDesk); service.submit(consumer1); service.submit(consumer2); service.shutdown(); } }
結果部分如下
可以看到當生產者產生的資料達到阻塞佇列的容量時,生成者執行緒會阻塞,等待消費者執行緒進行消費,上述案例中最大容量為8個盤子,所以當食堂做好了8個菜後了8會等待顧客進行消費,消費後繼續生產。上述案例使用阻塞佇列,看起來程式碼要簡單得多,不需要再單獨考慮同步和執行緒間通訊的問題。
在併發程式設計中,一般推薦使用阻塞佇列,這樣實現可以儘量地避免程式出現意外的錯誤。
阻塞佇列使用最經典的場景就是socket客戶端資料的讀取和解析,讀取資料的執行緒不斷將資料放入佇列,然後解析執行緒不斷從佇列取資料解析。還有其他類似的場景,如執行緒池中就使用了阻塞佇列,其實只要符合生產者-消費者模型的都可以使用阻塞佇列。
參考資料:
《Java程式設計思想》
https://www.cnblogs.com/dolphin0520/p/3932906.html
https://www.cnblogs.com/superfj/p/7757876.html