死磕阻塞佇列

后羿飛箭發表於2020-07-31
J.U.C 中的阻塞佇列
     
阻塞佇列的操作方法
在阻塞佇列中,提供了四種處理方式
1. 插入操作
add(e) :新增元素到佇列中,如果佇列滿了,繼續插入元素會報錯,IllegalStateException。
offer(e) : 新增元素到佇列,同時會返回元素是否插入成功的狀態,如果成功則返回 trueput(e) :當阻塞佇列滿了以後,生產者繼續通過 put新增元素,佇列會一直阻塞生產者執行緒,知道佇列可用
offer(e,time,unit) :當阻塞佇列滿了以後繼續新增元素,生產者執行緒會被阻塞指定時間,如果超時,則執行緒直接退出
2. 移除操作
remove():當佇列為空時,呼叫 remove 會返回 false,如果元素移除成功,則返回 true
poll(): 當佇列中存在元素,則從佇列中取出一個元素,如果佇列為空,則直接返回 null
take():基於阻塞的方式獲取佇列中的元素,如果佇列為空,則 take 方法會一直阻塞,直到佇列中有新的資料可以消費
poll(time,unit):帶超時機制的獲取資料,如果佇列為空,則會等待指定的時間再去獲取元素返回
 
ArrayBlockingQueue 原理分析
     
構造方法
ArrayBlockingQueue 提供了三個構造方法,分別如下。
capacity: 表示陣列的長度,也就是佇列的長度fair:表示是否為公平的阻塞佇列,預設情況下構造的是非公平的阻塞佇列。
其中第三個構造方法就不解釋了,它提供了接收一個幾個作為資料初始化的方法
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public
ArrayBlockingQueue(int capacity,boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair); //重入鎖,出隊和入隊持有這一把鎖
notEmpty = lock.newCondition(); //初始化非空等待佇列
notFull = lock.newCondition(); //初始化非滿等待佇列
}

 

關於鎖的用途,大家在沒有看接下來的原始碼之前,可以先思考一下他的作用。items 構造以後,大概是一個這樣的陣列結構
Add 方法
    
以 add 方法作為入口,在 add 方法中會呼叫父類的 add 方法,也就是 AbstractQueue.如果看原始碼看得比較多的話,
一般這種寫法都是呼叫父類的模版方法來解決通用性問題
    
public boolean add(E e) {
return super.add(e);
}
從父類的 add 方法可以看到,這裡做了一個佇列是否滿了的判斷,如果佇列滿了直接丟擲一個異常
public boolean add(E e) {
if (offer(e))
return true;
else
throw new IllegalStateException("Queue
full");
}
offer 方法
   
add 方法最終還是呼叫 offer 方法來新增資料,返回一個新增成功或者失敗的布林值反饋
這段程式碼做了幾個事情
1. 判斷新增的資料是否為空
2. 新增重入鎖
3. 判斷佇列長度,如果佇列長度等於陣列長度,表示滿了直接返回 false
4. 否則,直接呼叫 enqueue 將元素新增到佇列中
  
public boolean offer(E e) {
 checkNotNull(e); //對請求資料做判斷
 final ReentrantLock lock = this.lock;
 lock.lock();
 try {
 if (count == items.length)
 return false;
 else {
 enqueue(e);
 return true;
 }
 } finally {
 lock.unlock();
 } }
enqueue
   
這個是最核心的邏輯,方法內部通過 putIndex 索引直接將元素新增到陣列 items
private void enqueue(E x) {
 // assert lock.getHoldCount() == 1;
 // assert items[putIndex] == null;
 final Object[] items = this.items;
 items[putIndex] = x; //通過 putIndex 對資料賦if (++putIndex == items.length) //putIndex 等於陣列長度時,將 putIndex 重置為 0
 putIndex = 0;
 count++;//記錄佇列元素的個數
 notEmpty.signal();//喚醒處於等待狀態下的執行緒,表示當前佇列中的元素不為空,如果存在消費者執行緒阻塞,就可以開始取出元素
}
這裡大家肯定會有一個疑問,putIndex 為什麼會在等於陣列長度的時候重新設定為 0。因為 ArrayBlockingQueue 是一個 FIFO 的佇列,佇列新增元素時,是從隊尾獲取 putIndex 來儲存元素,當 putIndex等於陣列長度時,下次就需要從陣列頭部開始新增了。
 
下面這個圖模擬了新增到不同長度的元素時,putIndex 的變化,當 putIndex 等於陣列長度時,不可能讓 putIndex 繼續累加,否則會超出陣列初始化的容量大小。同時大家還需要思考兩個問題
1. 當元素滿了以後是無法繼續新增的,因為會報錯
2. 其次,佇列中的元素肯定會有一個消費者執行緒通過 take或者其他方法來獲取資料,而獲取資料的同時元素也會從佇列中移除
 
put 方法
    
put 方法和 add 方法功能一樣,差異是 put 方法如果佇列滿了,會阻塞。這個在最開始的時候說過。接下來看一下它的實現邏輯
public void put(E e) throws 
InterruptedException {
 checkNotNull(e);
 final ReentrantLock lock = this.lock;
 lock.lockInterruptibly(); //這個也是獲得鎖,但是和 lock 的區別是,這個方法優先允許在等待時由其他執行緒呼叫等待執行緒的 interrupt 方法來中斷等待直接返回。而 lock方法是嘗試獲得鎖成功後才響應中斷
 try {
 while (count == items.length)
 notFull.await();//佇列滿了的情況下,當前執行緒將會被 notFull 條件物件掛起加到等待佇列中
 enqueue(e);
 } finally {
 lock.unlock();
 } }

 

 

take 方法
 
  
take 方法是一種阻塞獲取佇列中元素的方法它的實現原理很簡單,有就刪除沒有就阻塞,注意這個阻塞是可以中斷的,如果佇列沒有資料那麼就加入 notEmpty條件佇列等待(有資料就直接取走,方法結束),如果有新的put 執行緒新增了資料,那麼 put 操作將會喚醒 take 執行緒,執行 take 操作
 
  
public E take() throws InterruptedException {
 final ReentrantLock lock = this.lock;
 lock.lockInterruptibly();
 try {
 while (count == 0)
 notEmpty.await(); //如果佇列為空的情況下,直接通過 await 方法阻塞
 return dequeue();
 } finally {
 lock.unlock();
 } }

 

如果佇列中新增了元素,那麼這個時候,會在 enqueue 中呼叫 notempty.signal 喚醒 take 執行緒來獲得元素
 
dequeue 方法
 
這個是出佇列的方法,主要是刪除佇列頭部的元素併發返回給客戶端takeIndex,是用來記錄拿資料的索引值
 
private E dequeue() {
 // assert lock.getHoldCount() == 1;
 // assert items[takeIndex] != null;
 final Object[] items = this.items;
 @SuppressWarnings("unchecked")
 E x = (E) items[takeIndex]; //預設獲取 0 位置的元素
 items[takeIndex] = null;//將該位置的元素設定為if (++takeIndex == items.length)//這裡的作用也是一樣,如果拿到陣列的最大值,那麼重置為 0,繼續從頭部位置開始獲取資料
 takeIndex = 0;
 count--;//記錄 元素個數遞減
 if (itrs != null)
 itrs.elementDequeued();//同時更新迭代器中的元素資料
 notFull.signal();//觸發 因為佇列滿了以後導致的被阻塞的執行緒
 return x;
}

itrs.elementDequeued();
 
ArrayBlockingQueue 中,實現了迭代器的功能,也就是可以通過迭代器來遍歷阻塞佇列中的元素
所以 itrs.elementDequeued() 是用來更新迭代器中的元素資料的takeIndex 的索引變化圖如下,同時隨著資料的移除,會喚醒處於 put 阻塞狀態下的執行緒來繼續新增資料

remove 方法

 

remove 方法是移除一個指定元素。看看它的實現程式碼
public boolean remove(Object o) {
 if (o == null) return false;
 final Object[] items = this.items; //獲取數組元素
 final ReentrantLock lock = this.lock;
 lock.lock(); //獲得鎖
 try {
 if (count > 0) { //如果佇列不為空
 final int putIndex = this.putIndex; //獲取下一個要新增元素時的索引
 int i = takeIndex;//獲取當前要被移除的元素的索引
 do {
 if (o.equals(items[i])) {//takeIndex 下標開始,找到要被刪除的元素
 removeAt(i);//移除指定元素
 return true;//返回執行結果
 }
//當前刪除索引執行加 1 後判斷是否與陣列長度相等
//若為 true,說明索引已到陣列盡頭,將 i 設定為 0
 if (++i == items.length)
 i = 0;
 } while (i != putIndex);//繼續查詢,直到找到最後一個元素
 }
 return false;
 } finally {
 lock.unlock();
 } }

 

原子操作類
    
原子性這個概念,在多執行緒程式設計裡是一個老生常談的問題。
所謂的原子性表示一個或者多個操作,要麼全部執行完,要麼一個也不執行。不能出現成功一部分失敗一部分的情況。
在多執行緒中,如果多個執行緒同時更新一個共享變數,可能會得到一個意料之外的值。比如 i=1 。A 執行緒更新 i+1 、B 執行緒也更新 i+1。
通過兩個執行緒並行操作之後可能 i 的值不等於 3。而可能等於 2。因為 A 和 B 在更新變數 i 的時候拿到的 i 可能都是 1
這就是一個典型的原子性問題
前面幾節課我們講過,多執行緒裡面,要實現原子性,有幾種方法,其中一種就是加 Synchronized 同步鎖。
而從 JDK1.5 開始,在 J.U.C 包中提供了 Atomic 包,提供了對於常用資料結構的原子操作。它提供了簡單、高效、以及執行緒安全的更新一個變數的方式
 
J.U.C 中的原子操作類
    
由於變數型別的關係,在 J.U.C 中提供了 12 個原子操作的類。這 12 個類可以分為四大類
  
1. 原子更新基本型別 
    
AtomicBoolean、AtomicInteger、AtomicLong
 
2. 原子更新陣列
   
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
3. 原子更新引用
     
AtomicReference 、 AtomicReferenceFieldUpdater 、AtomicMarkableReference(更新帶有標記位的引用型別) 
4. 原子更新欄位 
  
AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicStampedReference 
 
AtomicInteger 原理分析 
   
接下來,我們來剖析一下 AtomicInteger 的實現原理,仍然是基於我們剛剛在前面的案例中使用到的方法作為突破口 
 
getAndIncrement
getAndIncrement 實際上是呼叫 unsafe 這個類裡面提供的方法,
Unsafe 類我們前面在分析 AQS 的時候講過,這個類相當於是一個後門,使得 Java 可以像 C 語言的指標一樣直接操作記憶體空間。當然也會帶來一些弊端,就是指標的問題。
實際上這個類在很多方面都有使用,除了 J.U.C 這個包以外,還有 Netty、kafka 等等這個類提供了很多功能,包括多執行緒同步(monitorEnter)、CAS 操 作 (compareAndSwap) 、執行緒的掛起和恢復(park/unpark)、記憶體屏障(loadFence/storeFence)
記憶體管理(記憶體分配、釋放記憶體、獲取記憶體地址等.)
 
public final int getAndIncrement() {
 return unsafe.getAndAddInt(this, 
valueOffset, 1);
}
valueOffset,也比較熟了。通過 unsafe.objectFieldOffset()獲取當前 Value 這個變數在記憶體中的偏移量,後續會基於這個偏移量從記憶體中得到value的值來和當前的值做比較,實現樂觀鎖
private static final long valueOffset;
static {
 try {
 valueOffset = unsafe.objectFieldOffset
 
(AtomicInteger.class.getDeclaredField("value")
);
 } catch (Exception ex) { throw new 
Error(ex); }
}
getAndAddInt
通過 do/while 迴圈,基於 CAS 樂觀鎖來做原子遞增。實際上前面的 valueOffset 的作用就是從主記憶體中獲得當前value 的值和預期值做一個比較,如果相等,對 value 做遞增並結束迴圈
public final int getAndAddInt(Object var1, long 
var2, int var4) {
 int var5;
 do {
 var5 = this.getIntVolatile(var1, var2);
 } while(!this.compareAndSwapInt(var1, var2, 
var5, var5 + var4));
 return var5;
}
get 方法
    
get 方法只需要直接返回 value 的值就行,這裡的 value 是通過 Volatile 修飾的,用來保證可見性
 
public final int get() {
 return value; }
其他方法
AtomicInteger 的實現非常簡單,所以我們可以很快就分析完它的實現原理,當然除了剛剛分析的這兩個方法之外,
還有其他的一些比 如 它 提 供 了 compareAndSet , 允 許 客 戶 端 基 於AtomicInteger 來實現樂觀鎖的操作
 
public final boolean compareAndSet(int expect, 
int update) {
 return unsafe.compareAndSwapInt(this, 
valueOffset, expect, update);
}

 

相關文章