前言
較長一段時間以來我都發現不少開發者對 jdk 中的 J.U.C
(java.util.concurrent)也就是 Java 併發包的使用甚少,更別談對它的理解了;但這卻也是我們進階的必備關卡。
之前或多或少也分享過相關內容,但都不成體系;於是便想整理一套與併發包相關的系列文章。
其中的內容主要包含以下幾個部分:
- 根據定義自己實現一個併發工具。
- JDK 的標準實現。
- 實踐案例。
基於這三點我相信大家對這部分內容不至於一問三不知。
既然開了一個新坑,就不想做的太差;所以我打算將這個列表下的大部分類都講到。
所以本次重點討論 ArrayBlockingQueue
。
自己實現
在自己實現之前先搞清楚阻塞佇列的幾個特點:
- 基本佇列特性:先進先出。
- 寫入佇列空間不可用時會阻塞。
- 獲取佇列資料時當佇列為空時將阻塞。
實現佇列的方式多種,總的來說就是陣列和連結串列;其實我們只需要搞清楚其中一個即可,不同的特性主要表現為陣列和連結串列的區別。
這裡的 ArrayBlockingQueue
看名字很明顯是由陣列實現。
我們先根據它這三個特性嘗試自己實現試試。
初始化佇列
我這裡自定義了一個類:ArrayQueue
,它的建構函式如下:
public ArrayQueue(int size) {
items = new Object[size];
}
複製程式碼
很明顯這裡的 items
就是存放資料的陣列;在初始化時需要根據大小建立陣列。
寫入佇列
寫入佇列比較簡單,只需要依次把資料存放到這個陣列中即可,如下圖:
但還是有幾個需要注意的點:
- 佇列滿的時候,寫入的執行緒需要被阻塞。
- 寫入過佇列的數量大於佇列大小時需要從第一個下標開始寫。
先看第一個佇列滿的時候,寫入的執行緒需要被阻塞
,先來考慮下如何才能使一個執行緒被阻塞,看起來的表象執行緒卡住啥事也做不了。
有幾種方案可以實現這個效果:
Thread.sleep(timeout)
執行緒休眠。object.wait()
讓執行緒進入waiting
狀態。
當然還有一些
join、LockSupport.part
等不在本次的討論範圍。
阻塞佇列還有一個非常重要的特性是:當佇列空間可用時(取出佇列),寫入執行緒需要被喚醒讓資料可以寫入進去。
所以很明顯Thread.sleep(timeout)
不合適,它在到達超時時間之後便會繼續執行;達不到空間可用時才喚醒繼續執行這個特點。
其實這樣的一個特點很容易讓我們想到 Java 的等待通知機制來實現執行緒間通訊;更多執行緒見通訊的方案可以參考這裡:深入理解執行緒通訊
所以我這裡的做法是,一旦佇列滿時就將寫入執行緒呼叫 object.wait()
進入 waiting
狀態,直到空間可用時再進行喚醒。
/**
* 佇列滿時的阻塞鎖
*/
private Object full = new Object();
/**
* 佇列空時的阻塞鎖
*/
private Object empty = new Object();
複製程式碼
所以這裡宣告瞭兩個物件用於佇列滿、空情況下的互相通知作用。
在寫入資料成功後需要使用 empty.notify()
,這樣的目的是當獲取佇列為空時,一旦寫入資料成功就可以把消費佇列的執行緒喚醒。
這裡的 wait 和 notify 操作都需要對各自的物件使用
synchronized
方法塊,這是因為 wait 和 notify 都需要獲取到各自的鎖。
消費佇列
上文也提到了:當佇列為空時,獲取佇列的執行緒需要被阻塞,直到佇列中有資料時才被喚醒。
程式碼和寫入的非常類似,也很好理解;只是這裡的等待、喚醒恰好是相反的,通過下面這張圖可以很好理解:
總的來說就是:
- 寫入佇列滿時會阻塞直到獲取執行緒消費了佇列資料後喚醒寫入執行緒。
- 消費佇列空時會阻塞直到寫入執行緒寫入了佇列資料後喚醒消費執行緒。
測試
先來一個基本的測試:單執行緒的寫入和消費。
3
123
1234
12345
複製程式碼
通過結果來看沒什麼問題。
當寫入的資料超過佇列的大小時,就只能消費之後才能接著寫入。
2019-04-09 16:24:41.040 [Thread-0] INFO c.c.concurrent.ArrayQueueTest - [Thread-0]123
2019-04-09 16:24:41.040 [main] INFO c.c.concurrent.ArrayQueueTest - size=3
2019-04-09 16:24:41.047 [main] INFO c.c.concurrent.ArrayQueueTest - 1234
2019-04-09 16:24:41.048 [main] INFO c.c.concurrent.ArrayQueueTest - 12345
2019-04-09 16:24:41.048 [main] INFO c.c.concurrent.ArrayQueueTest - 123456
複製程式碼
從執行結果也能看出只有當消費資料後才能接著往佇列裡寫入資料。
而當沒有消費時,再往佇列裡寫資料則會導致寫入執行緒被阻塞。
併發測試
三個執行緒併發寫入300條資料,其中一個執行緒消費一條。
=====0
299
複製程式碼
最終的佇列大小為 299,可見執行緒也是安全的。
由於不管是寫入還是獲取方法裡的操作都需要獲取鎖才能操作,所以整個佇列是執行緒安全的。
ArrayBlockingQueue
下面來看看 JDK 標準的 ArrayBlockingQueue
的實現,有了上面的基礎會更好理解。
初始化佇列
看似要複雜些,但其實逐步拆分後也很好理解:
第一步其實和我們自己寫的一樣,初始化一個佇列大小的陣列。
第二步初始化了一個重入鎖,這裡其實就和我們之前使用的 synchronized
作用一致的;
只是這裡在初始化重入鎖的時候預設是非公平鎖
,當然也可以指定為 true
使用公平鎖;這樣就會按照佇列的順序進行寫入和消費。
更多關於
ReentrantLock
的使用和原理請參考這裡:ReentrantLock 實現原理
三四兩步則是建立了 notEmpty notFull
這兩個條件,他的作用於用法和之前使用的 object.wait/notify
類似。
這就是整個初始化的內容,其實和我們自己實現的非常類似。
寫入佇列
其實會發現阻塞寫入的原理都是差不多的,只是這裡使用的是 Lock 來顯式獲取和釋放鎖。
同時其中的 notFull.await();notEmpty.signal();
和我們之前使用的 object.wait/notify
的用法和作用也是一樣的。
當然它還是實現了超時阻塞的 API
。
也是比較簡單,使用了一個具有超時時間的等待方法。
消費佇列
再看消費佇列:
也是差不多的,一看就懂。
而其中的超時 API 也是使用了 notEmpty.awaitNanos(nanos)
來實現超時返回的,就不具體說了。
實際案例
說了這麼多,來看一個佇列的實際案例吧。
背景是這樣的:
有一個定時任務會按照一定的間隔時間從資料庫中讀取一批資料,需要對這些資料做校驗同時呼叫一個遠端介面。
簡單的做法就是由這個定時任務的執行緒去完成讀取資料、訊息校驗、呼叫介面等整個全流程;但這樣會有一個問題:
假設呼叫外部介面出現了異常、網路不穩導致耗時增加就會造成整個任務的效率降低,因為他都是序列會互相影響。
所以我們改進了方案:
其實就是一個典型的生產者消費者模型:
- 生產執行緒從資料庫中讀取訊息丟到佇列裡。
- 消費執行緒從佇列裡獲取資料做業務邏輯。
這樣兩個執行緒就可以通過這個佇列來進行解耦,互相不影響,同時這個佇列也能起到緩衝的作用。
但在使用過程中也有一些小細節值得注意。
因為這個外部介面是支援批量執行的,所以在消費執行緒取出資料後會在記憶體中做一個累加,一旦達到閾值或者是累計了一個時間段便將這批累計的資料處理掉。
但由於開發者的大意,在消費的時候使用的是 queue.take()
這個阻塞的 API;正常執行沒啥問題。
可一旦原始的資料來源,也就是 DB 中沒資料了,導致佇列裡的資料也被消費完後這個消費執行緒便會被阻塞。
這樣上一輪積累在記憶體中的資料便一直沒機會使用,直到資料來源又有資料了,一旦中間間隔較長時便可能會導致嚴重的業務異常。
所以我們最好是使用 queue.poll(timeout)
這樣帶超時時間的 api,除非業務上有明確的要求需要阻塞。
這個習慣同樣適用於其他場景,比如呼叫 http、rpc 介面等都需要設定合理的超時時間。
總結
關於 ArrayBlockingQueue
的相關分享便到此結束,接著會繼續更新其他併發容器及併發工具。
對本文有任何相關問題都可以留言討論。
本文涉及到的所有原始碼:
你的點贊與分享是對我最大的支援