前言
目錄如下:
在面試過程中聊到併發相關的內容時,不少面試官都喜歡問這類問題:
當 N 個執行緒同時完成某項任務時,如何知道他們都已經執行完畢了。
這也是本次討論的話題之一,所以本篇為『併發包入坑指北』的第二篇;來聊聊常見的併發工具。
自己實現
其實這類問題的核心論點都是:如何在一個執行緒中得知其他執行緒是否執行完畢。
假設現在有 3 個執行緒在執行,需要在主執行緒中得知他們的執行結果;可以分為以下幾步:
- 定義一個計數器為 3。
- 每個執行緒完成任務後計數減一。
- 一旦計數器減為 0 則通知等待的執行緒。
所以也很容易想到可以利用等待通知機制來實現,和上文的『併發包入坑指北』之阻塞佇列的類似。
按照這個思路自定義了一個 MultipleThreadCountDownKit
工具,建構函式如下:
考慮到併發的前提,這個計數器自然需要保證執行緒安全,所以採用了 AtomicInteger
。
所以在初始化時需要根據執行緒數量來構建物件。
計數器減一
當其中一個業務執行緒完成後需要將這個計數器減一,直到減為0為止。
/**
* 執行緒完成後計數 -1
*/
public void countDown(){
if (counter.get() <= 0){
return;
}
int count = this.counter.decrementAndGet();
if (count < 0){
throw new RuntimeException("concurrent error") ;
}
if (count == 0){
synchronized (notify){
notify.notify();
}
}
}
複製程式碼
利用 counter.decrementAndGet()
來保證多執行緒的原子性,當減為 0 時則利用等待通知機制來 notify
其他執行緒。
等待所有執行緒完成
而需要知道業務執行緒執行完畢的其他執行緒則需要在未完成之前一直處於等待狀態,直到上文提到的在計數器變為 0 時得到通知。
/**
* 等待所有的執行緒完成
* @throws InterruptedException
*/
public void await() throws InterruptedException {
synchronized (notify){
while (counter.get() > 0){
notify.wait();
}
if (notifyListen != null){
notifyListen.notifyListen();
}
}
}
複製程式碼
原理也很簡單,一旦計數器還存在時則會利用 notify
物件進行等待,直到被業務執行緒喚醒。
同時這裡新增了一個通知介面可以自定義實現喚醒後的一些業務邏輯,後文會做演示。
併發測試
主要就是這兩個函式,下面來做一個演示。
- 初始化了三個計數器的併發工具
MultipleThreadCountDownKit
- 建立了三個執行緒分別執行業務邏輯,完畢後執行
countDown()
。 - 執行緒 3 休眠了 2s 用於模擬業務耗時。
- 主執行緒執行
await()
等待他們三個執行緒執行完畢。
通過執行結果可以看出主執行緒會等待最後一個執行緒完成後才會退出;從而達到了主執行緒等待其餘執行緒的效果。
MultipleThreadCountDownKit multipleThreadKit = new MultipleThreadCountDownKit(3);
multipleThreadKit.setNotify(() -> LOGGER.info("三個執行緒完成了任務"));
複製程式碼
也可以在初始化的時候指定一個回撥介面,用於接收業務執行緒執行完畢後的通知。
當然和在主執行緒中執行這段邏輯效果是一樣的(和執行 await()
方法處於同一個執行緒)。
CountDownLatch
當然我們自己實現的程式碼沒有經過大量生產環境的驗證,所以主要的目的還是嘗試窺探官方的實現原理。
所以我們現在來看看 juc
下的 CountDownLatch
是如何實現的。
通過建構函式會發現有一個 內部類 Sync
,他是繼承於 AbstractQueuedSynchronizer
;這是 Java 併發包中的基礎框架,都可以單獨拿來講了,所以這次重點不是它,今後我們再著重介紹。
這裡就可以把他簡單理解為提供了和上文類似的一個計數器及執行緒通知工具就行了。
countDown
其實他的核心邏輯和我們自己實現的區別不大。
public void countDown() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
複製程式碼
利用這個內部類的 releaseShared
方法,我們可以理解為他想要將計數器減一。
看到這裡有沒有似曾相識的感覺。
沒錯,在 JDK1.7
中的 AtomicInteger
自減就是這樣實現的(利用 CAS 保證了執行緒安全)。
只是一旦計數器減為 0 時則會執行 doReleaseShared
喚醒其他的執行緒。
這裡我們只需要關心紅框部分(其他的暫時不用關心,這裡涉及到了 AQS 中的佇列相關),最終會呼叫 LockSupport.unpark
來喚醒執行緒;就相當於上文呼叫 object.notify()
。
所以其實本質上還是相同的。
await
其中的 await()
也是借用 Sync
物件的方法實現的。
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//判斷計數器是否還未完成
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
複製程式碼
一旦還存在未完成的執行緒時,則會呼叫 doAcquireSharedInterruptibly
進入阻塞狀態。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
複製程式碼
同樣的由於這也是 AQS
中的方法,我們只需要關心紅框部分;其實最終就是呼叫了 LockSupport.park
方法,也就相當於執行了 object.wait()
。
- 所有的業務執行緒執行完畢後會在計數器減為 0 時呼叫
LockSupport.unpark
來喚醒執行緒。 - 等待執行緒一旦計數器 > 0 時則會利用
LockSupport.park
來等待喚醒。
這樣整個流程也就串起來了,它的使用方法也和上文的類似。
就不做過多介紹了。
實際案例
同樣的來看一個實際案例。
在上一篇《一次分表踩坑實踐的探討》提到了對於全表掃描的情況下,需要利用多執行緒來提高查詢效率。
比如我們這裡分為了 64 張表,計劃利用 8 個執行緒來分別處理這些表的資料,虛擬碼如下:
CountDownLatch count = new CountDownLatch(64);
ConcurrentHashMap total = new ConcurrentHashMap();
for(Integer i=0;i<=63;i++){
executor.execute(new Runnable(){
@Override
public void run(){
List value = queryTable(i);
total.put(value,NULL);
count.countDown();
}
}) ;
}
count.await();
System.out.println("查詢完畢");
複製程式碼
這樣就可以實現所有資料都查詢完畢後再做統一彙總;程式碼挺簡單,也好理解(當然也可以使用執行緒池的 API)。
總結
CountDownLatch
算是 juc
中一個高頻使用的工具,學會和理解他的使用會幫助我們更容易編寫併發應用。
文中涉及到的原始碼:
你的點贊與分享是對我最大的支援