1. 阻塞佇列介紹
顧名思義,阻塞佇列是一個具備先進先出特性的佇列結構,從佇列末尾插入資料,從佇列頭部取出資料。而阻塞佇列與普通佇列的最大不同在於阻塞佇列提供了阻塞式的同步插入、取出資料的功能(阻塞入隊put/阻塞出隊take)。
使用put插入資料時,如果佇列空間已滿並不直接返回,而是令當前操作的執行緒陷入阻塞態(生產者執行緒),等待著阻塞佇列中的元素被其它執行緒(消費者執行緒)取走,令佇列重新變得不滿時被喚醒再次嘗試插入資料。使用take取出資料時,如果佇列空間為空並不直接返回,而是令當前操作的執行緒陷入阻塞態(消費者執行緒),等待其它執行緒(生產者執行緒)插入新元素,令佇列非空時被喚醒再次嘗試取出資料。
阻塞佇列主要用於解決併發場景下消費者執行緒與生產者執行緒處理速度不一致的問題。例如jdk的執行緒池實現中,執行緒池核心執行緒(消費者執行緒)處理速度一定的情況下,如果業務方執行緒提交的任務過多導致核心執行緒處理不過來時,將任務暫時放進阻塞佇列等待核心執行緒消費(阻塞佇列未滿);由於核心執行緒常駐的原因,當業務方執行緒提交的任務較少,核心執行緒消費速度高於業務方生產速度時,核心執行緒作為消費者會阻塞在阻塞佇列的take方法中,避免無謂的浪費cpu資源。
由於阻塞佇列在內部實現了協調生產者/消費者的機制而不需要外部使用者過多的考慮併發同步問題,極大的降低了生產者/消費者場景下程式的複雜度。
2. 自己實現阻塞佇列
下面我們自己動手一步步的實現幾個不同版本、效率由低到高的的阻塞佇列,來加深對阻塞佇列工作原理的理解。
阻塞佇列介面
為了降低複雜度,我們的阻塞佇列只提供最基礎的出隊、入隊和判空介面。
/** * 阻塞佇列 * 1. 首先是一個先進先出的佇列 * 2. 提供特別的api,在入隊時如果佇列已滿令當前操作執行緒阻塞;在出隊時如果佇列為空令當前操作執行緒阻塞 * 3. 單個元素的插入、刪除操作是執行緒安全的 */ public interface MyBlockingQueue<E> {
/** * 插入特定元素e,加入隊尾 * 佇列已滿時阻塞當前執行緒,直到佇列中元素被其它執行緒刪除並插入成功 * */ void put(E e) throws InterruptedException; /** * 佇列頭部的元素出隊(返回頭部元素,將其從佇列中刪除) * 佇列為空時阻塞當前執行緒,直到佇列被其它元素插入新元素並出隊成功 * */ E take() throws InterruptedException; /** * 佇列是否為空 * */ boolean isEmpty(); }
2.1 v1版本(最基本的佇列實現)
部落格中所實現的阻塞佇列底層是使用陣列承載資料的(ArrayBlockingQueue),內部提供了私有方法enqueue和dequeue來實現原始的內部入隊和出隊操作。
最初始的v1版本中,我們只實現最基本的FIFO佇列功能,其put和take方法只是簡單的呼叫了enqueue和dequeue,因此v1版本中其入隊、出隊不是阻塞的,也無法保障執行緒安全,十分簡陋。
後續的版本中,我們會以v1版本為基礎,實現阻塞呼叫以及執行緒安全的特性,並且對所實現的阻塞佇列效能進行不斷的優化。
/** * 陣列作為底層結構的阻塞佇列 v1版本 */ public class MyArrayBlockingQueueV1<E> implements MyBlockingQueue<E> { /** * 佇列預設的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 承載佇列元素的底層陣列 * */ private final Object[] elements; /** * 當前頭部元素的下標 * */ private int head; /** * 下一個元素插入時的下標 * */ private int tail; /** * 佇列中元素個數 * */ private int count; //=================================================構造方法====================================================== public MyArrayBlockingQueueV1() { // 設定陣列大小為預設 this.elements = new Object[DEFAULT_CAPACITY]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; } public MyArrayBlockingQueueV1(int initCapacity) { assert initCapacity > 0;
this.elements = new Object[initCapacity]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; } /** * 下標取模 * */ private int getMod(int logicIndex){ int innerArrayLength = this.elements.length; // 由於佇列下標邏輯上是迴圈的 if(logicIndex < 0){ // 當邏輯下標小於零時 // 真實下標 = 邏輯下標 + 加上當前陣列長度 return logicIndex + innerArrayLength; } else if(logicIndex >= innerArrayLength){ // 當邏輯下標大於陣列長度時 // 真實下標 = 邏輯下標 - 減去當前陣列長度 return logicIndex - innerArrayLength; } else { // 真實下標 = 邏輯下標 return logicIndex; } } /** * 入隊 * */ private void enqueue(E e){ // 存放新插入的元素 this.elements[this.tail] = e; // 尾部插入新元素後 tail下標後移一位 this.tail = getMod(this.tail + 1); this.count++; } /** * 出隊 * */ private E dequeue(){ // 暫存需要被刪除的資料 E dataNeedRemove = (E)this.elements[this.head]; // 將當前頭部元素引用釋放 this.elements[this.head] = null; // 頭部下標 後移一位 this.head = getMod(this.head + 1); this.count--; return dataNeedRemove; } @Override public void put(E e){ enqueue(e); } @Override public E take() { return dequeue(); } @Override public boolean isEmpty() { return this.count == 0; } }
2.2 v2版本(實現同步阻塞和執行緒安全的特性)
前面提到阻塞呼叫的出隊、入隊的功能是阻塞佇列區別於普通佇列的關鍵特性。阻塞呼叫實現的方式有很多,其中最容易理解的一種方式便是無限迴圈的輪詢,直到出隊/入隊成功(雖然cpu效率很低)。
v2版本在v1的基礎上,使用無限迴圈加定時休眠的方式簡單的實現了同步呼叫時阻塞的特性。並且在put/take內增加了synchronized塊將入隊/出隊程式碼包裹起來,阻止多個執行緒併發的操作佇列而產生執行緒安全問題。
v2版本入隊方法實現:
@Override public void put(E e) throws InterruptedException { while (true) { synchronized (this) { // 佇列未滿時執行入隊操作 if (count != elements.length) { // 入隊,並返回 enqueue(e); return; } } // 佇列已滿,休眠一段時間後重試 Thread.sleep(100L); } }
v2版本出隊方法實現:
@Override public E take() throws InterruptedException { while (true) { synchronized (this) { // 佇列非空時執行出隊操作 if (count != 0) { // 出隊並立即返回 return dequeue(); } } // 佇列為空的情況下,休眠一段時間後重試 Thread.sleep(100L); } }
v2版本完整程式碼:
/** * 陣列作為底層結構的阻塞佇列 v2版本 */ public class MyArrayBlockingQueueV2<E> implements MyBlockingQueue<E> { /** * 佇列預設的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 承載佇列元素的底層陣列 * */ private final Object[] elements; /** * 當前頭部元素的下標 * */ private int head; /** * 下一個元素插入時的下標 * */ private int tail; /** * 佇列中元素個數 * */ private int count; //=================================================構造方法====================================================== /** * 預設構造方法 * */ public MyArrayBlockingQueueV2() { // 設定陣列大小為預設 this.elements = new Object[DEFAULT_CAPACITY]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; } /** * 預設構造方法 * */ public MyArrayBlockingQueueV2(int initCapacity) { assert initCapacity > 0; // 設定陣列大小為預設 this.elements = new Object[initCapacity]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; } /** * 下標取模 * */ private int getMod(int logicIndex){ int innerArrayLength = this.elements.length; // 由於佇列下標邏輯上是迴圈的 if(logicIndex < 0){ // 當邏輯下標小於零時 // 真實下標 = 邏輯下標 + 加上當前陣列長度 return logicIndex + innerArrayLength; } else if(logicIndex >= innerArrayLength){ // 當邏輯下標大於陣列長度時 // 真實下標 = 邏輯下標 - 減去當前陣列長度 return logicIndex - innerArrayLength; } else { // 真實下標 = 邏輯下標 return logicIndex; } } /** * 入隊 * */ private void enqueue(E e){ // 存放新插入的元素 this.elements[this.tail] = e; // 尾部插入新元素後 tail下標後移一位 this.tail = getMod(this.tail + 1); this.count++; } /** * 出隊 * */ private E dequeue(){ // 暫存需要被刪除的資料 E dataNeedRemove = (E)this.elements[this.head]; // 將當前頭部元素引用釋放 this.elements[this.head] = null; // 頭部下標 後移一位 this.head = getMod(this.head + 1); this.count--; return dataNeedRemove; } @Override public void put(E e) throws InterruptedException { while (true) { synchronized (this) { // 佇列未滿時執行入隊操作 if (count != elements.length) { // 入隊,並返回 enqueue(e); return; } } // 佇列已滿,休眠一段時間後重試 Thread.sleep(100L); } } @Override public E take() throws InterruptedException { while (true) { synchronized (this) { // 佇列非空時執行出隊操作 if (count != 0) { // 出隊並立即返回 return dequeue(); } } // 佇列為空的情況下,休眠一段時間後重試 Thread.sleep(100L); } } @Override public boolean isEmpty() { return this.count == 0; } }
2.3 v3版本(引入條件變數優化無限迴圈輪詢)
在有大量執行緒競爭的情況下,v2版本無限迴圈加休眠的阻塞方式存在兩個嚴重的問題。
無限迴圈輪詢的缺陷
1. 執行緒週期性的休眠/喚醒會造成頻繁的發生執行緒上下文切換,非常浪費cpu資源
2. 執行緒在嘗試操作失敗被阻塞時(嘗試入隊時佇列已滿、嘗試出隊時佇列為空),如果休眠時間設定的太短,則休眠/喚醒的次數會非常多,cpu效能低下;但如果休眠的時間設定的較長,則會導致被阻塞執行緒在佇列狀態發生變化時無法及時的響應
舉個例子:某一生產者執行緒在入隊時發現佇列已滿,當前執行緒休眠1s,在0.1s之後一個消費者執行緒取走了一個元素,而此時休眠的生產者執行緒還需要白白等待0.9s後才被喚醒並感知到佇列未滿而接著執行入隊操作。綜上所述,無限迴圈加休眠的v2版本阻塞佇列其效能極差,需要進一步的優化。
使用條件變數進行優化
為了解決上述迴圈休眠浪費cpu和佇列狀態發生變化時(已滿到未滿,已空到未空)被阻塞執行緒無法及時響應的問題,v3版本引入條件變數對其進行優化。
條件變數由底層的作業系統核心實現的、用於執行緒間同步的利器。(條件變數的實現原理可以參考我之前的部落格:https://www.cnblogs.com/xiaoxiongcanguan/p/14152830.html)
java將不同作業系統核心提供的條件變數機制抽象封裝後,作為可重入鎖ReentrantLock的附屬給程式設計師使用。且為了避免lost wakeup問題,在條件變數的實現中增加了校驗,要求呼叫條件變數的signal和await方法時當前執行緒必須先獲得條件變數所附屬的鎖才行,更具體的解析可以參考這篇文章:https://mp.weixin.qq.com/s/ohcr6T1aB7-lVFJIfyJZjA。
引入條件變數後,可以令未滿足某種條件的執行緒暫時進入阻塞態,等待在一個條件變數上;當對應條件滿足時由其它的執行緒將等待在條件變數上的執行緒喚醒,將其從阻塞態再切換回就緒態。
舉個例子:當某一生產者執行緒想要插入新元素但阻塞佇列已滿時,可以令當前生產者執行緒等待並阻塞在對應的條件變數中;當後續某一消費者執行緒執行出隊操作使得佇列非空後,將等待在條件變數上的生產者執行緒喚醒,被喚醒的生產者執行緒便能及時的再次嘗試進行入隊操作。
v3和v2版本相比,等待在條件變數進入阻塞態的執行緒不再週期性的被喚醒而佔用過多的cpu資源,且在特定條件滿足時也能被及時喚醒。
引入條件變數後的v3版本阻塞佇列效率比v2高出許多。
v3版本完整程式碼:
/** * 陣列作為底層結構的阻塞佇列 v3版本 */ public class MyArrayBlockingQueueV3<E> implements MyBlockingQueue<E> { /** * 佇列預設的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 承載佇列元素的底層陣列 * */ private final Object[] elements; /** * 當前頭部元素的下標 * */ private int head; /** * 下一個元素插入時的下標 * */ private int tail; /** * 佇列中元素個數 * */ private int count; private final ReentrantLock reentrantLock; private final Condition condition; //=================================================構造方法====================================================== /** * 預設構造方法 * */ public MyArrayBlockingQueueV3() { this(DEFAULT_CAPACITY); } /** * 預設構造方法 * */ public MyArrayBlockingQueueV3(int initCapacity) { assert initCapacity > 0; // 設定陣列大小為預設 this.elements = new Object[initCapacity]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; this.reentrantLock = new ReentrantLock(); this.condition = this.reentrantLock.newCondition(); } /** * 下標取模 * */ private int getMod(int logicIndex){ int innerArrayLength = this.elements.length; // 由於佇列下標邏輯上是迴圈的 if(logicIndex < 0){ // 當邏輯下標小於零時 // 真實下標 = 邏輯下標 + 加上當前陣列長度 return logicIndex + innerArrayLength; } else if(logicIndex >= innerArrayLength){ // 當邏輯下標大於陣列長度時 // 真實下標 = 邏輯下標 - 減去當前陣列長度 return logicIndex - innerArrayLength; } else { // 真實下標 = 邏輯下標 return logicIndex; } } /** * 入隊 * */ private void enqueue(E e){ // 存放新插入的元素 this.elements[this.tail] = e; // 尾部插入新元素後 tail下標後移一位 this.tail = getMod(this.tail + 1); this.count++; } /** * 出隊 * */ private E dequeue(){ // 暫存需要被刪除的資料 E dataNeedRemove = (E)this.elements[this.head]; // 將當前頭部元素引用釋放 this.elements[this.head] = null; // 頭部下標 後移一位 this.head = getMod(this.head + 1); this.count--; return dataNeedRemove; } @Override public void put(E e) throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 reentrantLock.lockInterruptibly(); try { // 因為被消費者喚醒後可能會被其它的生產者再度填滿佇列,需要迴圈的判斷 while (this.count == elements.length) { // put操作時,如果佇列已滿則進入條件變數的等待佇列,並釋放條件變數對應的鎖 condition.await(); } // 走到這裡,說明當前佇列不滿,可以執行入隊操作 enqueue(e); // 喚醒可能等待著的消費者執行緒 // 由於共用了一個condition,所以不能用signal,否則一旦喚醒的也是生產者執行緒就會陷入上面的while死迴圈) condition.signalAll(); } finally { // 入隊完畢,釋放鎖 reentrantLock.unlock(); } } @Override public E take() throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 reentrantLock.lockInterruptibly(); try { // 因為被生產者喚醒後可能會被其它的消費者消費而使得佇列再次為空,需要迴圈的判斷 while(this.count == 0){ condition.await(); } E headElement = dequeue(); // 喚醒可能等待著的生產者執行緒 // 由於共用了一個condition,所以不能用signal,否則一旦喚醒的也是消費者執行緒就會陷入上面的while死迴圈) condition.signalAll(); return headElement; } finally { // 出隊完畢,釋放鎖 reentrantLock.unlock(); } } @Override public boolean isEmpty() { return this.count == 0; } }
2.4 v4版本(引入雙條件變數,優化喚醒效率)
v3版本通過引入條件變數解決了v2版本中迴圈休眠、喚醒效率低下的問題,但v3版本還是存在一定的效能問題。
v3版本中signalAll的效率問題
jdk的Condition條件變數提供了signal和signalAll這兩個方法用於喚醒等待在條件變數中的執行緒,其中signalAll會喚醒等待在條件變數上的所有執行緒,而signal則只會喚醒其中一個。
舉個例子,v3版本中消費者執行緒在佇列已滿時進行出隊操作後,通過signalAll會喚醒所有等待入隊的多個生產者執行緒,但最終只會有一個執行緒成功競爭到互斥鎖併成功執行入隊操作,其它的生產者執行緒在被喚醒後發現佇列依然是滿的,而繼續等待。v3版本中的signalAll喚醒操作造成了驚群效應,無意義的喚醒了過多的等待中的執行緒。
但僅僅將v3版本中的signalAll改成signal是不行的,因為生產者和消費者執行緒是等待在同一個條件變數中的,如果消費者在出隊後通過signal喚醒的不是與之對應的生產者執行緒,而是另一個消費者執行緒,則本該被喚醒的生產者執行緒可能遲遲無法被喚醒,甚至在一些場景下會永遠被阻塞,無法再喚醒。
仔細思索後可以發現,對於生產者執行緒其在佇列已滿時阻塞等待,等待的是佇列不滿的條件(notFull);而對於消費者執行緒其在佇列為空時阻塞等待,等待的是佇列不空的條件(notEmpty)。佇列不滿和佇列不空實質上是兩個互不相關的條件。
因此v4版本中將生產者執行緒和消費者執行緒關注的條件變數拆分成兩個:生產者執行緒在佇列已滿時阻塞等待在notFull條件變數上,消費者執行緒出隊後通過notFull.signal嘗試著喚醒一個等待的生產者執行緒;與之相對的,消費者執行緒在佇列為空時阻塞等待在notEmpty條件變數上,生產者執行緒入隊後通過notEmpty.signal嘗試著喚醒一個等待的消費者執行緒。
通過拆分出兩個互相獨立的條件變數,v4版本避免了v3版本中signalAll操作帶來的驚群效應,避免了signalAll操作無效喚醒帶來的額外開銷。
v4版本完整程式碼:
/** * 陣列作為底層結構的阻塞佇列 v4版本 */ public class MyArrayBlockingQueueV4<E> implements MyBlockingQueue<E> { /** * 佇列預設的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 承載佇列元素的底層陣列 * */ private final Object[] elements; /** * 當前頭部元素的下標 * */ private int head; /** * 下一個元素插入時的下標 * */ private int tail; /** * 佇列中元素個數 * */ private int count; private final ReentrantLock reentrantLock; private final Condition notEmpty; private final Condition notFull; //=================================================構造方法====================================================== /** * 預設構造方法 * */ public MyArrayBlockingQueueV4() { this(DEFAULT_CAPACITY); } /** * 預設構造方法 * */ public MyArrayBlockingQueueV4(int initCapacity) { assert initCapacity > 0; // 設定陣列大小為預設 this.elements = new Object[initCapacity]; // 初始化佇列 頭部,尾部下標 this.head = 0; this.tail = 0; this.reentrantLock = new ReentrantLock(); this.notEmpty = this.reentrantLock.newCondition(); this.notFull = this.reentrantLock.newCondition(); } /** * 下標取模 * */ private int getMod(int logicIndex){ int innerArrayLength = this.elements.length; // 由於佇列下標邏輯上是迴圈的 if(logicIndex < 0){ // 當邏輯下標小於零時 // 真實下標 = 邏輯下標 + 加上當前陣列長度 return logicIndex + innerArrayLength; } else if(logicIndex >= innerArrayLength){ // 當邏輯下標大於陣列長度時 // 真實下標 = 邏輯下標 - 減去當前陣列長度 return logicIndex - innerArrayLength; } else { // 真實下標 = 邏輯下標 return logicIndex; } } /** * 入隊 * */ private void enqueue(E e){ // 存放新插入的元素 this.elements[this.tail] = e; // 尾部插入新元素後 tail下標後移一位 this.tail = getMod(this.tail + 1); this.count++; } /** * 出隊 * */ private E dequeue(){ // 暫存需要被刪除的資料 E dataNeedRemove = (E)this.elements[this.head]; // 將當前頭部元素引用釋放 this.elements[this.head] = null; // 頭部下標 後移一位 this.head = getMod(this.head + 1); this.count--; return dataNeedRemove; } @Override public void put(E e) throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 reentrantLock.lockInterruptibly(); try { // 因為被消費者喚醒後可能會被其它的生產者再度填滿佇列,需要迴圈的判斷 while (this.count == elements.length) { // put操作時,如果佇列已滿則進入notFull條件變數的等待佇列,並釋放條件變數對應的互斥鎖 notFull.await(); // 消費者進行出隊操作時 } // 走到這裡,說明當前佇列不滿,可以執行入隊操作 enqueue(e); // 喚醒可能等待在notEmpty中的一個消費者執行緒 notEmpty.signal(); } finally { // 入隊完畢,釋放鎖 reentrantLock.unlock(); } } @Override public E take() throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 reentrantLock.lockInterruptibly(); try { // 因為被生產者喚醒後可能會被其它的消費者消費而使得佇列再次為空,需要迴圈的判斷 while(this.count == 0){ notEmpty.await(); } E headElement = dequeue(); // 喚醒可能等待在notFull中的一個生產者執行緒 notFull.signal(); return headElement; } finally { // 出隊完畢,釋放鎖 reentrantLock.unlock(); } } @Override public boolean isEmpty() { return this.count == 0; } }
2.5 v5版本(引入雙鎖令生產者和消費者能併發操作阻塞佇列)
v4版本的阻塞佇列採用雙條件變數之後,其效能已經不錯了,但仍存在進一步優化的空間。
v4版本單鎖的效能問題
v4版本中阻塞佇列的出隊、入隊操作是使用同一個互斥鎖進行併發同步的,這意味著生產者執行緒和消費者執行緒無法併發工作,消費者執行緒必須等待生產者執行緒操作完成退出臨界區之後才能繼續執行,反之亦然。單鎖的設計在生產者和消費者都很活躍的高併發場景下會一定程度限制阻塞佇列的吞吐量。
因此v5版本在v4版本的基礎上,將出隊和入隊操作使用兩把鎖分別管理,使得生產者執行緒和消費者執行緒可以併發的操作阻塞佇列,達到進一步提高吞吐量的目的。
使用兩把鎖分別控制出隊、入隊後,還需要一些調整來解決生產者/消費者併發操作佇列所帶來的問題。
存在併發問題的雙鎖版本出隊、入隊實現第一版(v4基礎上的微調):
/** this.takeLock = new ReentrantLock(); this.notEmpty = this.takeLock.newCondition(); this.putLock = new ReentrantLock(); this.notFull = this.putLock.newCondition(); */ @Override public void put(E e) throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 putLock.lockInterruptibly(); try { // 因為被消費者喚醒後可能會被其它的生產者再度填滿佇列,需要迴圈的判斷 while (this.count == elements.length) { // put操作時,如果佇列已滿則進入notFull條件變數的等待佇列,並釋放條件變數對應的互斥鎖 notFull.await(); } // 走到這裡,說明當前佇列不滿,可以執行入隊操作 enqueue(e); // 喚醒可能等待在notEmpty中的一個消費者執行緒 notEmpty.signal(); } finally { // 入隊完畢,釋放鎖 putLock.unlock(); } } @Override public E take() throws InterruptedException { // 先嚐試獲得互斥鎖,以進入臨界區 takeLock.lockInterruptibly(); try { // 因為被生產者喚醒後可能會被其它的消費者消費而使得佇列再次為空,需要迴圈的判斷 while(this.count == 0){ notEmpty.await(); } E headElement = dequeue(); // 喚醒可能等待在notFull中的一個生產者執行緒 notFull.signal(); return headElement; } finally { // 出隊完畢,釋放鎖 takeLock.unlock(); } }
上面基於v4版本微調的雙鎖實現雖然容易理解,但由於允許消費者和生產者執行緒併發的訪問佇列而存在幾個嚴重問題。
1. count屬性執行緒不安全
佇列長度count欄位是一個用於判斷佇列是否已滿,佇列是否為空的重要屬性。在v5之前的版本count屬性一直被唯一的同步鎖保護著,任意時刻至多隻有一個執行緒可以進入臨界區修改count的值。而引入雙鎖令消費者執行緒/生產者執行緒能併發訪問後,count變數的自增/自減操作會出現執行緒不安全的問題。
解決方案:將int型別的count修改為AtomicInteger來解決生產者/消費者同時訪問、修改count時導致的併發問題。
2. 生產者/消費者執行緒死鎖問題
在上述的程式碼示例中,生產者執行緒首先獲得生產者鎖去執行入隊操作,然後喚醒可能阻塞在notEmpty上的消費者執行緒。由於使用條件變數前首先需要獲得其所屬的互斥鎖,如果生產者執行緒不先釋放生產者鎖就去獲取消費者的互斥鎖,那麼就存在出現死鎖的風險。消費者執行緒和生產者執行緒可以併發的先分別獲得消費者鎖和生產者鎖,並且也同時嘗試著獲取另一把鎖,這樣雙方都在等待著對方釋放鎖,互相阻塞出現死鎖現象。
解決方案:先釋放已獲得的鎖之後再去獲得另一個鎖執行喚醒操作
存在併發問題的雙鎖版本出隊、入隊實現第二版(在上述第一版基礎上進行微調):
/** private final AtomicInteger count = new AtomicInteger(); this.takeLock = new ReentrantLock(); this.notEmpty = this.takeLock.newCondition(); this.putLock = new ReentrantLock(); this.notFull = this.putLock.newCondition(); */ @Override public void put(E e) throws InterruptedException { int currentCount; // 先嚐試獲得互斥鎖,以進入臨界區 putLock.lockInterruptibly(); try { // 因為被消費者喚醒後可能會被其它的生產者再度填滿佇列,需要迴圈的判斷 while (count.get() == elements.length) { // put操作時,如果佇列已滿則進入notFull條件變數的等待佇列,並釋放條件變數對應的互斥鎖 notFull.await(); // 消費者進行出隊操作時 } // 走到這裡,說明當前佇列不滿,可以執行入隊操作 enqueue(e); currentCount = count.getAndIncrement(); } finally { // 入隊完畢,釋放鎖 putLock.unlock(); } // 如果插入之前佇列為空,才喚醒等待彈出元素的執行緒 if (currentCount == 0) { signalNotEmpty(); } } @Override public E take() throws InterruptedException { E headElement; int currentCount; // 先嚐試獲得互斥鎖,以進入臨界區 takeLock.lockInterruptibly(); try { // 因為被生產者喚醒後可能會被其它的消費者消費而使得佇列再次為空,需要迴圈的判斷 while(this.count.get() == 0){ notEmpty.await(); } headElement = dequeue(); currentCount = this.count.getAndDecrement(); } finally { // 出隊完畢,釋放鎖 takeLock.unlock(); } // 只有在彈出之前佇列已滿的情況下才喚醒等待插入元素的執行緒 if (currentCount == elements.length) { signalNotFull(); } return headElement; } /** * 喚醒等待佇列非空條件的執行緒 */ private void signalNotEmpty() { // 為了喚醒等待佇列非空條件的執行緒,需要先獲取對應的takeLock takeLock.lock(); try { // 喚醒一個等待非空條件的執行緒 notEmpty.signal(); } finally { takeLock.unlock(); } } /** * 喚醒等待佇列未滿條件的執行緒 */ private void signalNotFull() { // 為了喚醒等待佇列未滿條件的執行緒,需要先獲取對應的putLock putLock.lock(); try { // 喚醒一個等待佇列未滿條件的執行緒 notFull.signal(); } finally { putLock.unlock(); } }
3. lost wakeup問題
在上述待改進的雙鎖實現第二版中,阻塞在notFull中的生產者執行緒完全依賴相對應的消費者執行緒來將其喚醒(阻塞在notEmpty中的消費者執行緒也同樣依賴對應的生產者執行緒將其喚醒),這在生產者執行緒和消費者執行緒併發時會出現lost wakeup的問題。
下面構造一個簡單而不失一般性的例子來說明,為什麼上述第二版的實現中會出現問題。
時序圖(假設阻塞佇列的長度為5(element.length=5),且一開始時佇列已滿)
生產者執行緒P1 | 生產者執行緒P2 | 消費者執行緒C | |
1 |
執行put操作,此時佇列已滿。 執行while迴圈中的notfull.await陷入阻塞狀態 (await會釋放putLock) |
||
2 |
執行take操作,佇列未滿,成功執行完dequeue。 此時currentCount=5,this.count=4, 執行takeLock.unLock釋放takeLock鎖 |
||
3 |
執行put操作,拿到putLock鎖,由於消費者C已經執行完出隊操作, 成功執行enqueue。 此時currentCount=4,this.count=5, 執行putLock.unLock釋放putLock鎖 |
||
4 |
判斷currentCount == elements.length為真, 執行signalNotFull,併成功拿到putLock。 notFull.signal喚醒等待在其上的生產者執行緒P1。 take方法執行完畢,return返回 |
||
5 |
被消費者C喚醒,但此時count=5,無法跳出while迴圈, 繼續await阻塞在notFull條件變數中 |
||
6 |
判斷currentCount == 0為假,進行處理。 put方法執行完畢 ,return返回 |
可以看到,雖然生產者執行緒P1由於佇列已滿而先被阻塞,而消費者執行緒C在出隊後也確實通知喚醒了生產者執行緒P1。但是由於生產者執行緒P2和消費者執行緒C的併發執行,導致了生產者執行緒P1在被喚醒後依然無法成功執行入隊操作,只能繼續的阻塞下去。在一些情況下,P1生產者執行緒可能再也不會被喚醒而永久的阻塞在條件變數notFull上。
為了解決這一問題,雙鎖版本的阻塞佇列其生產者執行緒不能僅僅依靠消費者執行緒來將其喚醒,而是需要在其它生產者執行緒在入隊操作完成後,發現佇列未滿時也嘗試著喚醒由於上述併發場景發生lost wakeup問題的生產者執行緒(消費者執行緒在出隊時的優化亦是如此)。
最終優化的V5版本的出隊、入隊實現:
/** private final AtomicInteger count = new AtomicInteger(); this.takeLock = new ReentrantLock(); this.notEmpty = this.takeLock.newCondition(); this.putLock = new ReentrantLock(); this.notFull = this.putLock.newCondition(); */ @Override public void put(E e) throws InterruptedException { int currentCount; // 先嚐試獲得互斥鎖,以進入臨界區 putLock.lockInterruptibly(); try { // 因為被消費者喚醒後可能會被其它的生產者再度填滿佇列,需要迴圈的判斷 while (count.get() == elements.length) { // put操作時,如果佇列已滿則進入notFull條件變數的等待佇列,並釋放條件變數對應的互斥鎖 notFull.await(); // 消費者進行出隊操作時 } // 走到這裡,說明當前佇列不滿,可以執行入隊操作 enqueue(e); currentCount = count.getAndIncrement(); // 如果在插入後佇列仍然沒滿,則喚醒其他等待插入的執行緒 if (currentCount + 1 < elements.length) { notFull.signal(); } } finally { // 入隊完畢,釋放鎖 putLock.unlock(); } // 如果插入之前佇列為空,才喚醒等待彈出元素的執行緒 // 為了防止死鎖,不能在釋放putLock之前獲取takeLock if (currentCount == 0) { signalNotEmpty(); } } @Override public E take() throws InterruptedException { E headElement; int currentCount; // 先嚐試獲得互斥鎖,以進入臨界區 takeLock.lockInterruptibly(); try { // 因為被生產者喚醒後可能會被其它的消費者消費而使得佇列再次為空,需要迴圈的判斷 while(this.count.get() == 0){ notEmpty.await(); } headElement = dequeue(); currentCount = this.count.getAndDecrement(); // 如果佇列在彈出一個元素後仍然非空,則喚醒其他等待佇列非空的執行緒 if (currentCount - 1 > 0) { notEmpty.signal(); } } finally { // 出隊完畢,釋放鎖 takeLock.unlock(); } // 只有在彈出之前佇列已滿的情況下才喚醒等待插入元素的執行緒 // 為了防止死鎖,不能在釋放takeLock之前獲取putLock if (currentCount == elements.length) { signalNotFull(); } return headElement; } /** * 喚醒等待佇列非空條件的執行緒 */ private void signalNotEmpty() { // 為了喚醒等待佇列非空條件的執行緒,需要先獲取對應的takeLock takeLock.lock(); try { // 喚醒一個等待非空條件的執行緒 notEmpty.signal(); } finally { takeLock.unlock(); } } /** * 喚醒等待佇列未滿條件的執行緒 */ private void signalNotFull() { // 為了喚醒等待佇列未滿條件的執行緒,需要先獲取對應的putLock putLock.lock(); try { // 喚醒一個等待佇列未滿條件的執行緒 notFull.signal(); } finally { putLock.unlock(); } }
3. 不同版本阻塞佇列的效能測試
前面從v2版本開始,對所實現的阻塞佇列進行了一系列的優化,一直到最終的V5版本實現了一個基於雙鎖,雙條件變數的高效能版本。
下面對v3-v5版本進行一輪基礎的效能測試(v2無限輪詢效能太差),看看其實際效能是否真的如部落格第二章中所說的那般,高版本的效能是更優秀的。同時令jdk中的ArrayBlockingQueue和LinkedBlockingQueue也實現MyBlockingQueue,也加入測試。
測試工具類BlockingQueueTestUtil:
1 public class BlockingQueueTestUtil { 2 public static long statisticBlockingQueueRuntime( 3 MyBlockingQueue<Integer> blockingQueue, int workerNum, int perWorkerProcessNum, int repeatTime) throws InterruptedException { 4 ExecutorService executorService = Executors.newFixedThreadPool(workerNum * 2); 5 // 第一次執行時存在一定的初始化開銷,不進行統計 6 oneTurnExecute(executorService,blockingQueue,workerNum,perWorkerProcessNum); 7 8 long totalTime = 0; 9 for(int i=0; i<repeatTime; i++){ 10 long oneTurnTime = oneTurnExecute(executorService,blockingQueue,workerNum,perWorkerProcessNum); 11 totalTime += oneTurnTime; 12 } 13 14 executorService.shutdown(); 15 16 assert blockingQueue.isEmpty(); 17 18 return totalTime/repeatTime; 19 } 20 21 private static long oneTurnExecute(ExecutorService executorService, MyBlockingQueue<Integer> blockingQueue, 22 int workerNum, int perWorkerProcessNum) throws InterruptedException { 23 long startTime = System.currentTimeMillis(); 24 CountDownLatch countDownLatch = new CountDownLatch(workerNum * 2); 25 26 // 建立workerNum個生產者/消費者 27 for(int i=0; i<workerNum; i++){ 28 executorService.execute(()->{ 29 produce(blockingQueue,perWorkerProcessNum); 30 countDownLatch.countDown(); 31 }); 32 33 executorService.execute(()->{ 34 consume(blockingQueue,perWorkerProcessNum); 35 countDownLatch.countDown(); 36 }); 37 } 38 countDownLatch.await(); 39 long endTime = System.currentTimeMillis(); 40 41 return endTime - startTime; 42 } 43 44 private static void produce(MyBlockingQueue<Integer> blockingQueue,int perWorkerProcessNum){ 45 try { 46 // 每個生產者生產perWorkerProcessNum個元素 47 for(int j=0; j<perWorkerProcessNum; j++){ 48 blockingQueue.put(j); 49 } 50 } catch (InterruptedException e) { 51 throw new RuntimeException(e); 52 } 53 } 54 55 private static void consume(MyBlockingQueue<Integer> blockingQueue,int perWorkerProcessNum){ 56 try { 57 // 每個消費者消費perWorkerProcessNum個元素 58 for(int j=0; j<perWorkerProcessNum; j++){ 59 blockingQueue.take(); 60 } 61 } catch (InterruptedException e) { 62 throw new RuntimeException(e); 63 } 64 } 65 }
jdk的ArrayBlockingQueue簡單包裝(JDKArrayBlockingQueue):
public class JDKArrayBlockingQueue<E> implements MyBlockingQueue<E> { private final BlockingQueue<E> jdkBlockingQueue; /** * 指定佇列大小的構造器 * * @param capacity 佇列大小 */ public JDKArrayBlockingQueue(int capacity) { if (capacity <= 0) throw new IllegalArgumentException(); jdkBlockingQueue = new ArrayBlockingQueue<>(capacity); } @Override public void put(E e) throws InterruptedException { jdkBlockingQueue.put(e); } @Override public E take() throws InterruptedException { return jdkBlockingQueue.take(); } @Override public boolean isEmpty() { return jdkBlockingQueue.isEmpty(); } @Override public String toString() { return "JDKArrayBlockingQueue{" + "jdkBlockingQueue=" + jdkBlockingQueue + '}'; } }
jdk的LinkedBlockingQueue簡單包裝(JDKLinkedBlockingQueue):
public class JDKLinkedBlockingQueue<E> implements MyBlockingQueue<E> { private final BlockingQueue<E> jdkBlockingQueue; /** * 指定佇列大小的構造器 * * @param capacity 佇列大小 */ public JDKLinkedBlockingQueue(int capacity) { if (capacity <= 0) throw new IllegalArgumentException(); jdkBlockingQueue = new LinkedBlockingQueue<>(capacity); } @Override public void put(E e) throws InterruptedException { jdkBlockingQueue.put(e); } @Override public E take() throws InterruptedException { return jdkBlockingQueue.take(); } @Override public boolean isEmpty() { return jdkBlockingQueue.isEmpty(); } @Override public String toString() { return "JDKLinkedBlockingQueue{" + "jdkBlockingQueue=" + jdkBlockingQueue + '}'; } }
測試主體程式碼:
public class BlockingQueuePerformanceTest { /** * 佇列容量 * */ private static final int QUEUE_CAPACITY = 3; /** * 併發執行緒數(消費者 + 生產者 = 2 * WORKER_NUM) * */ private static final int WORKER_NUM = 30; /** * 單次測試中每個執行緒訪問佇列的次數 * */ private static final int PER_WORKER_PROCESS_NUM = 3000; /** * 重複執行的次數 * */ private static final int REPEAT_TIME = 5; public static void main(String[] args) throws InterruptedException { { MyBlockingQueue<Integer> myArrayBlockingQueueV3 = new MyArrayBlockingQueueV3<>(QUEUE_CAPACITY); long avgCostTimeV3 = BlockingQueueTestUtil.statisticBlockingQueueRuntime(myArrayBlockingQueueV3, WORKER_NUM, PER_WORKER_PROCESS_NUM, REPEAT_TIME); System.out.println(costTimeLog(MyArrayBlockingQueueV3.class, avgCostTimeV3)); } { MyBlockingQueue<Integer> myArrayBlockingQueueV4 = new MyArrayBlockingQueueV4<>(QUEUE_CAPACITY); long avgCostTimeV4 = BlockingQueueTestUtil.statisticBlockingQueueRuntime(myArrayBlockingQueueV4, WORKER_NUM, PER_WORKER_PROCESS_NUM, REPEAT_TIME); System.out.println(costTimeLog(MyArrayBlockingQueueV4.class, avgCostTimeV4)); } { MyBlockingQueue<Integer> myArrayBlockingQueueV5 = new MyArrayBlockingQueueV5<>(QUEUE_CAPACITY); long avgCostTimeV5 = BlockingQueueTestUtil.statisticBlockingQueueRuntime(myArrayBlockingQueueV5, WORKER_NUM, PER_WORKER_PROCESS_NUM, REPEAT_TIME); System.out.println(costTimeLog(MyArrayBlockingQueueV5.class, avgCostTimeV5)); } { MyBlockingQueue<Integer> jdkArrayBlockingQueue = new JDKArrayBlockingQueue<>(QUEUE_CAPACITY); long avgCostTimeJDK = BlockingQueueTestUtil.statisticBlockingQueueRuntime(jdkArrayBlockingQueue, WORKER_NUM, PER_WORKER_PROCESS_NUM, REPEAT_TIME); System.out.println(costTimeLog(JDKArrayBlockingQueue.class, avgCostTimeJDK)); } { MyBlockingQueue<Integer> jdkLinkedBlockingQueue = new JDKLinkedBlockingQueue<>(QUEUE_CAPACITY); long avgCostTimeJDK = BlockingQueueTestUtil.statisticBlockingQueueRuntime(jdkLinkedBlockingQueue, WORKER_NUM, PER_WORKER_PROCESS_NUM, REPEAT_TIME); System.out.println(costTimeLog(JDKLinkedBlockingQueue.class, avgCostTimeJDK)); } } private static String costTimeLog(Class blockQueueCLass,long costTime){ return blockQueueCLass.getSimpleName() + " avgCostTime=" + costTime + "ms"; } }
上述程式碼指定的引數為基於最大容量為3的阻塞佇列,生產者、消費者執行緒各30個,每個執行緒執行3000次出隊或入隊操作,重複執行5次用於統計平均時間。
我的機器上的執行結果如下:
MyArrayBlockingQueueV3 avgCostTime=843ms MyArrayBlockingQueueV4 avgCostTime=530ms MyArrayBlockingQueueV5 avgCostTime=165ms JDKArrayBlockingQueue avgCostTime=506ms JDKLinkedBlockingQueue avgCostTime=163ms
執行時長v3 > v4 > JDKArrayBlockingQueue > MyArrayBlockingQueueV5 > JDKLinkedBlockingQueue,且v4耗時大致等於JDKArrayBlockingQueue、v5耗時大致等於JDKLinkedBlockingQueue。
究其原因是因為jdk的ArrayBlockingQueue實現和V4版本一樣,是基於單鎖,雙條件變數的;而jdk的LinkedBlockingQueue實現和V5版本一樣,是基於雙鎖,雙條件變數的(V4、V5版本的實現就是參考的jdk原始碼)。
雖然測試的用例不是很全面,但測試結果和理論大致是吻合的,希望大家通過測試結果來加深對不同版本間效能差異的背後原理的理解。
4. 為什麼jdk中的ArrayBlockingQueue不基於效能更好的雙鎖實現 ?
看到這裡,不知你是否和我一樣對為什麼jdk的ArrayBlockingQueue使用單鎖而不使用效能更好的雙鎖實現而感到疑惑。所幸網上也有不少小夥伴有類似的疑問,這裡將相關內容簡單梳理一下。
1. 基於陣列實現的阻塞佇列(ABQ)是可以採用雙鎖實現更加高效率的出隊、入隊的。但由於jdk中阻塞佇列是屬於集合Collection的一個子類,雙鎖版本的ABQ其迭代器會比單鎖的複雜很多很多,但在效能上的改善並不那麼的可觀。ABQ的實現在複雜度和效能上做了一個折中,選擇了容易實現但效能稍低的單鎖實現。
http://jsr166-concurrency.10961.n7.nabble.com/ArrayBlockingQueue-concurrent-put-and-take-tc1306.html
2. 如果對效能有更加苛刻要求的話,可以考慮使用jdk中基於雙鎖實現的LinkedBlockingQueue(LBQ)。需要注意的是,在高吞吐量的出隊、入隊的場景下,LBQ鏈式的結構在垃圾回收時效能會略低於基於陣列的,緊湊結構的ABQ。
3. jdk提供了一個龐大而全面的集合框架,每個具體的資料結構都需要儘可能多的實現高層的介面和抽象方法。這樣的設計對於使用者來說確實很友好,但也令實現者背上了沉重的負擔,必須為實現一些可能極少使用的介面而花費巨大的精力,甚至反過來影響到特定資料結構的本身的實現。ABQ受制於雙鎖版本迭代器實現的複雜度,而被迫改為效率更低的單鎖實現就是一個典型的例子。
5. 總結
前段時間迷上了MIT6.824的資料庫課程,在理解了課程所提供的實驗後(共6個lab)收穫很大,因此想著自己再動手實現一個更加全面的版本(併發的B+樹,MVCC多版本控制、行級鎖以及sql直譯器、網路協議等等)。但一段時間後發現上述的功能難度很大且實現起來細節很多,這將耗費我過多的時間而被迫放棄了(膨脹了Orz)。在被打擊後,清醒的意識到對於現階段的我來說還是應該穩紮穩打,著眼於更小的知識點,通過自己動手造輪子的方式加深對知識點的理解,至於擼一個完善的關係型資料庫這種巨集大的目標受制於我目前的水平還是暫時先放放吧。
本篇部落格的完整程式碼在我的github上:https://github.com/1399852153/Reinventing-the-wheel-for-learning(blocking queue模組)。後續應該會陸續更新關於自己動手實現執行緒池、抽象同步佇列AQS等的部落格。
還存在很多不足之處,請多多指教。
主要參考文章:
https://zhuanlan.zhihu.com/p/64156753 從0到1實現自己的阻塞佇列(上)
https://zhuanlan.zhihu.com/p/64156910 從0到1實現自己的阻塞佇列(下)
https://blog.csdn.net/liubenlong007/article/details/102823081 為什麼ArrayBlockingQueue單鎖實現,LinkedBlockingQueue雙鎖實現?