一、等待多線程完成的CountDownLatch
1、案例介紹
public class CountDownLatchDemo {
private static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("child threadOne over");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
executorService.execute(() -> {
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("child threadTwo over");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
System.out.println("wait all child thread over");
countDownLatch.await();
System.out.println("all child thread over");
executorService.shutdown();
}
}
創建了一個CountDownLatch實例,因爲有兩個子線程所以構造函數的傳參爲2。主線程調用countDownLatch.await()方法後會被阻塞。子線程執行完畢後調用countDownLatch.countDown()方法讓countDownLatch內部的計數器減1,所有子線程執行完畢並調用countDown()方法後計數器會變爲0,這時候主線程的await()方法纔會返回
2、CountDownLatch源碼分析
CountDownLatch是使用AQS實現的,通過下面的構造函數把計數器的值賦給了AQS的狀態變量state,也就是使用AQS的狀態值來表示計數器值
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
Sync(int count) {
setState(count);
}
1)、await()方法
當線程調用CountDownLatch對象的await()方法後,當前線程會被阻塞,直到下面的情況之一發生纔會返回:當所有線程都調用了CountDownLatch對象的countDown()方法後,也就是計數器的值爲0時;其他線程調用了當前線程的interrupt()方法中斷了當前線程,當前線程就會拋出InterruptedException異常,然後返回
public void await() throws InterruptedException {
//調用AQS中的模板方法acquireSharedInterruptibly
sync.acquireSharedInterruptibly(1);
}
AQS中的acquireSharedInterruptibly方法:
//AQS獲取共享資源時可被中斷的方法
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
//如果線程被中斷則拋出異常
if (Thread.interrupted())
throw new InterruptedException();
//調用CountDownLatch中sync重寫的tryAcquireShared方法,查看當前計數器值是否爲0,爲0則直接返回,否則進入AQS的隊列等待
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
sync類實現的AQS的接口:
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
2)、countDown()方法
public void countDown() {
//調用AQS中的模板方法releaseShared
sync.releaseShared(1);
}
AQS中的releaseShared方法:
public final boolean releaseShared(int arg) {
//調用CountDownLatch中sync重寫的tryReleaseShared方法
if (tryReleaseShared(arg)) {
//AQS的釋放資源方法
doReleaseShared();
return true;
}
return false;
}
sync類實現的AQS的接口:
protected boolean tryReleaseShared(int releases) {
//循環進行CAS,直到當前線程成功完成CAS使計數器值(狀態值state)減1並更新到state
for (;;) {
int c = getState();
//如果當前狀態值爲0則直接返回(爲了防止當計數器值爲0後,其他線程又調用了countDown方法,防止狀態值變爲負數)
if (c == 0)
return false;
//使用CAS讓計數器值減1
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
二、同步屏障CyclicBarrier
1、案例介紹
public class CyclicBarrierDemo {
private static CyclicBarrier cyclicBarrier = new CyclicBarrier(2, new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "執行回調方法");
}
});
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
try {
System.out.println(Thread.currentThread().getName() + " step1");
cyclicBarrier.await();
System.out.println(Thread.currentThread().getName() + " step2");
cyclicBarrier.await();
System.out.println(Thread.currentThread().getName() + " step3");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
});
executorService.execute(() -> {
try {
System.out.println(Thread.currentThread().getName() + " step1");
cyclicBarrier.await();
System.out.println(Thread.currentThread().getName() + " step2");
cyclicBarrier.await();
System.out.println(Thread.currentThread().getName() + " step3");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
});
executorService.shutdown();
}
}
運行結果:
pool-1-thread-1 step1
pool-1-thread-2 step1
pool-1-thread-2執行回調方法
pool-1-thread-1 step2
pool-1-thread-2 step2
pool-1-thread-2執行回調方法
pool-1-thread-1 step3
pool-1-thread-2 step3
多個線程之間是相互等待的,假如計數器值爲N,那麼隨後調用await()方法的N-1個線程都會因爲到達屏障點而被阻塞,當第N個線程調用await()後,計數器值爲0了,這時候第N個線程纔會發出通知喚醒前面的N-1個線程。也就是當全部線程都到達屏障點時才能一塊繼續向下執行
此外從上面的案例中還可以得知,CyclicBarrier的計數器具備自動重置的功能,可以循環利用,回調任務是由最後一個到達屏障的線程執行的
2、CyclicBarrier源碼分析
CyclicBarrier基於獨佔鎖實現,本質底層還是基於AQS的。parties用來記錄線程個數,這裏表示多少線程調用await()後,所有線程纔會衝破屏障繼續往下運行。而count—開始等於parties,每當有線程調用await()方法就遞減1,當count爲0時就表示所有線程都到了屏障點。而parties始終用來記錄總的線程個數,當count計數器值變爲0後,會將parties的值賦給count,從而進行復用
public CyclicBarrier(int parties, Runnable barrierAction) {
if (parties <= 0) throw new IllegalArgumentException();
this.parties = parties;
this.count = parties;
this.barrierCommand = barrierAction;
}
還有一個變量barrierCommand也通過構造函數傳遞,這是一個任務,這個任務的執行時機是當所有線程都到達屏障點後。使用lock首先保證了更新計數器count的原子性,另外使用lock的條件變量trip支持線程間使用await()和signal()操作進行同步
在變量generation內部有一個變量broken,其用來記錄當前屏障是否被打破。這裏的broken並沒有被聲明爲volatile的,因爲是在鎖內使用變量,所以不需要聲明
private static class Generation {
boolean broken = false;
}
await()方法:
當前線程調用CyclicBarrier的該方法時會被阻塞,直到滿足下麪條件之一纔會返回:parties個線程都調用了await()方法,也就是線程都到了屏障點;其他線程調用了當前線程的interrupt()方法中斷了當前線程,則當前線程會拋出InterruptedException異常而返回;與當前屏障點關聯的Generation對象的broken標誌被設置爲true時,會拋出BrokenBarrierException異常,然後返回
public int await() throws InterruptedException, BrokenBarrierException {
try {
//調用內部的dowait()方法,第一個參數爲flase表示不設置超時時間
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}
dowait()方法:
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
final Generation g = generation;
if (g.broken)
throw new BrokenBarrierException();
if (Thread.interrupted()) {
breakBarrier();
throw new InterruptedException();
}
//如果index==0則說明所有線程都到了屏障點,此時執行初始化時傳遞的任務
int index = --count;
if (index == 0) { // tripped
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
//(1)執行任務
if (command != null)
command.run();
ranAction = true;
//(2)激活其他因調用await方法而被阻塞的線程,並重置CyclicBarrier
nextGeneration();
//返回
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}
//(3)index!=0
for (;;) {
try {
//沒有設置超時時間
if (!timed)
trip.await();
//設置了超時時間
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) {
breakBarrier();
throw ie;
} else {
Thread.currentThread().interrupt();
}
}
if (g.broken)
throw new BrokenBarrierException();
if (g != generation)
return index;
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
lock.unlock();
}
}
nextGeneration()方法:
private void nextGeneration() {
//喚醒條件隊列裏面阻塞的線程
trip.signalAll();
//重置CyclicBarrier
count = parties;
generation = new Generation();
}
當一個線程調用了dowait方法後,首先會獲取獨佔鎖lock,如果創建CycleBarrier時傳遞的參數爲10,那麼後面9個調用線程會被阻塞。然後當前獲取到鎖的線程會對計數器count進行遞減操作,遞減後count=index=9,因爲index!=0所以當前線程會執行代碼(3)。如果當前線程調用的是無參數的await()方法,則這裏timed=false,所以當前線程會被放入條件變量trip的條件阻塞隊列,當前線程會被掛起並釋放獲取的lock鎖。如果調用的是有參數的await方法則timed=true,然後當前線程也會被放入條件變量的條件隊列並釋放鎖資源,不同的是當前線程會在指定時間超時後自動被激活
當第一個獲取鎖的線程由於被阻塞釋放鎖後,被阻塞的9個線程中有一個會競爭到lock鎖,然後執行與第一個線程同樣的操作,直到最後一個線程獲取到lock鎖,此時已經有9個線程被放入了條件變量trip的條件隊列裏面。最後count=index等於0,所以執行代碼(1),如果創建CyclicBarrier時傳遞了任務,則在其他線程被喚醒前先執行任務,任務執行完畢後再執行代碼(2),喚醒其他9個線程,並重置CyclicBarrier,然後這10個線程就可以繼續向下運行了
三、使用CountDownLatchDown和CyclicBarrier優化對賬系統
在學習極客時間的《Java併發編程實戰》這門課時,關於CountDownLatchDown和CyclicBarrier的文章《CountDownLatch和CyclicBarrier:如何讓多線程步調一致?》中,王寶令老師通過一個優化對賬系統的案例更好的詮釋了CountDownLatchDown和CyclicBarrier的使用,同時作者解決問題的思路也值得我們借鑑,下面是我對文章進行的總結和梳理,同時也強烈推薦學習一下這門課程,對併發編程的講解還是很不錯的
對賬系統業務:用戶通過在線商城下單,會生成電子訂單,保存在訂單庫;之後物流會生成派送單給用戶發貨,派送單保存在派送單庫。爲了防止漏派送或者重複派送,對賬系統每天還會校驗是否存在異常訂單
對賬系統的核心代碼如下,就是在一個單線程裏面循環查詢訂單、派送單,然後執行對賬,最後將寫入差異庫
while (存在未對賬訂單) {
//查詢未對賬訂單
pos = getPOrders();
//查詢派送單
dos = getDOrders();
//執行對賬操作
diff = check(pos, dos);
//差異寫入差異庫
save(diff);
}
首先要優化性能,就要找到這個對賬系統的瓶頸所在:目前的對賬系統,由於訂單量和派送單量巨大,所以查詢未對賬訂單getPOrders()和查詢派送單getDOrders()相對較慢,目前對賬系統是單線程執行的,圖形化後是下圖這個樣子
查詢未對賬訂單getPOrders()和查詢派送單getDOrders()這兩個操作並沒有先後順序的依賴可以並行處理,執行過程如下圖所示。對比一下單線程的執行示意圖,在同等時間內,並行執行的吞吐量近乎單線程的2倍
1、用CountDownLatch實現線程等待
//創建2個線程的線程池
Executor executor = Executors.newFixedThreadPool(2);
while (存在未對賬訂單) {
//計數器初始化爲2
CountDownLatch latch = new CountDownLatch(2);
//查詢未對賬訂單
executor.execute(() -> {
pos = getPOrders();
latch.countDown();
});
//查詢派送單
executor.execute(() -> {
dos = getDOrders();
latch.countDown();
});
//等待兩個查詢操作結束
latch.await();
//執行對賬操作
diff = check(pos, dos);
//差異寫入差異庫
save(diff);
}
在while循環裏面,創建了一個CountDownLatch,計數器的初始值等於2,之後在pos = getPOrders();和dos = getDOrders();兩條語句的後面對計數器執行減1操作,這個對計數器減1的操作是通過調用latch.countDown();來實現的。在主線程中,通過調用latch.await()來實現對計數器等於0的等待
2、進一步優化性能
getPOrders()和getDOrders()這兩個查詢操作和對賬操作check()、save()之間也是可以並行的,也就是說,在執行對賬操作的時候,可以同時去執行下一輪的查詢操作,如下圖所示
針對對賬這個項目,設計了兩個隊列,並且兩個隊列的元素之間還有對應關係。具體如下圖所示,訂單查詢操作將訂單查詢結果插入訂單隊列,派送單查詢操作將派送單插入派送單隊列,這兩個隊列的元素之間是有一一對應的關係的。兩個隊列的好處是,對賬操作可以每次從訂單隊列出一個元素,從派送單隊列出一個元素,然後對這兩個元素執行對賬操作,這樣數據一定不會亂掉
線程T1和線程T2只有都生產完1條數據的時候,才能一起向下執行,也就是說,線程T1和線程T2要互相等待,步調要一致;同時當線程T1和T2都生產完一條數據的時候,還要能夠通知線程T3執行對賬操作
3、用CyclicBarrier實現線程同步
//訂單隊列
Vector<P> pos;
//派送單隊列
Vector<D> dos;
//執行回調的線程池
Executor executor = Executors.newFixedThreadPool(1);
final CyclicBarrier barrier = new CyclicBarrier(2, () -> {
executor.execute(() -> check());
});
void check() {
P p = pos.remove(0);
D d = dos.remove(0);
//執行對賬操作
diff = check(p, d);
//差異寫入差異庫
save(diff);
}
void checkAll() {
//循環查詢訂單庫
Thread T1 = new Thread(() -> {
while (存在未對賬訂單) {
//查詢訂單庫
pos.add(getPOrders());
//等待
barrier.await();
}
});
T1.start();
//循環查詢運單庫
Thread T2 = new Thread(() -> {
while (存在未對賬訂單) {
//查詢運單庫
dos.add(getDOrders());
//等待
barrier.await();
}
});
T2.start();
}
首先創建了一個計數器初始值爲2的CyclicBarrier,還傳入了一個回調函數,當計數器減到0的時候,會調用這個回調函數
線程T1負責查詢訂單,當查出一條時,調用barrier.await()來將計數器減1,同時等待計數器變爲0;線程T2負責查詢派送單,當查出一條時,也調用barrier.await()來將計數器減1,同時等待計數器變爲0;當T1和T2都調用barrier.await()的時候,計數器會減到0,此時T1和T2就可以執行下一條語句了,同時會調用barrier的回調函數來執行對賬操作
CyclicBarrier的計數器有自動重置的功能,當減到0的時候,會自動重置設置的初始值
回調函數中使用了一個固定大小爲1的線程池。首先使用線程池是爲了異步操作,否則回調函數是同步調用的,也就是本次對賬操作執行完才能進行下一輪的檢查;其次線程數量固定爲1,防止了多線程併發導致的數據不一致,因爲訂單和派送單是兩個隊列,只有單線程去兩個隊列中取消息纔不會出現消息不匹配的問題
4、總結
CountDownLatch主要用來解決一個線程等待多個線程的場景,而CyclicBarrier是一組線程之間互相等待,而且CyclicBarrier的計數器具備自動重置的功能,可以循環利用,CyclicBarrier還可以設置回調函數
四、信號量Semaphore
1、信號量模型
信號量模型包括一個計數器,一個等待隊列,三個方法。在信號量模型裏,計數器和等待隊列對外是透明的,所以只能通過信號量模型提供的三個方法來訪問它們,這三個方法分別是:init()、down()和up()
- init():設置計數器的初始值
- down():計數器的值減1;如果此時計數器的值小於0,則當前線程將被阻塞,否則當前線程可以繼續執行
- up():計數器的值加1;如果此時計數器的值小於或者等於0,則喚醒等待隊列中的一個線程,並將其從等待隊列中移除
init()、down()和up()三個方法都是原子性的。在Java SDK裏面,信號量模型是由java.util.concurrent.Semaphore
實現的,Semaphore這個類能夠保證這三個方法都是原子操作
信號量模型裏面down()、up()這兩個操作歷史上最早稱爲P操作和V操作,所以心好累模型也被稱爲PV原語。在Semaphore中,down()和up()對應的則是acquire()和release()
2、Semaphore使用
1)、使用Semaphore實現累加器(互斥)
static int count;
//初始化信號量
static final Semaphore semaphore = new Semaphore(1);
//用信號量保證互斥
static void addOne() {
try {
semaphore.acquire();
count += 1;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}
假設兩個線程T1和T2同時訪問addOne()方法,當它們同時調用acquire()的時候,由於acquire()是一個原子操作,所以只能由一個線程(假設T1)把信號量裏的計數器減爲0,另外一個線程(T2)則是將計數器減爲-1。對於線程T1,信號量裏面的計數器的值是0,大於等於0,所以線程T1會繼續執行;對於線程T2,信號量裏面的計數器的值是-1,小於0,按照信號量模型裏的對down()操作的描述,線程T2將被阻塞。所以此時只有線程T1會進入臨界區執行count += 1
當線程T1執行release()操作,也就是up()操作的時候,信號量裏計數器的值是-1,加1之後的值是0,小於等於0,按照信號量模型裏對up()操作的描述,此時等待隊列中的T2將會被喚醒。於是T2在T1執行完臨界區代碼之後才獲得了進入臨界區執行的機會,從而保證了互斥性
2)、使用Semaphore控制同時訪問特定資源的線程數量
public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(8);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + "開始執行");
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}).start();
}
}
}
運行結果:第一次有8個線程執行了打印,等待5秒後,後兩個線程執行了打印
3)、使用Semaphore實現一個對象池
public class ObjPool<T, R> {
final List<T> pool;
//用信號量實現限流器
final Semaphore sem;
//構造函數
ObjPool(int size, T t) {
pool = new Vector<T>();
for (int i = 0; i < size; i++) {
pool.add(t);
}
sem = new Semaphore(size);
}
//利用對象池的對象,調用func
R exec(Function<T, R> func) throws InterruptedException {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
public static void main(String[] args) throws InterruptedException {
//創建對象池
ObjPool<Long, String> pool = new ObjPool<Long, String>(10, 2L);
//通過對象池獲取t,之後執行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
}
}
Semaphore可以允許多個線程訪問一個臨界區,用Vector保存對象實例,Vector是線程安全的,用Semaphore 實現限流器。關鍵的代碼是ObjPool裏面的exec()方法,這個方法裏面實現了限流的功能。在這個方法裏面,我們首先調用acquire()方法(與之匹配的是在finally裏面調用release()方法),假設對象池的大小是10,信號量的計數器初始化爲10,那麼前10個線程調用acquire()方法,都能繼續執行,而其他線程則會阻塞在acquire()方法上。對於通過信號燈的線程,我們爲每個線程分配了一個對象t(這個分配工作是通過pool.remove(0)實現的),分配完之後會執行一個回調函數func,而函數的參數正是前面分配的對象t;執行完回調函數之後,它們就會釋放對象(這個釋放工作是通過pool.add(t)實現的),同時調用release()方法來更新信號量的計數器。如果此時信號量裏計數器的值小於等於0,那麼說明有線程在等待,此時會自動喚醒等待的線程
3、Semaphore源碼分析
Semaphore還是使用AQS實現的。Sync只是對AQS的一個修飾,並且Sync有兩個實現類,用來指定獲取信號量時是否採用公平策略
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
Sync(int permits) {
setState(permits);
}
Semaphore默認釆用非公平策略,如果需要使用公平策略則可以使用帶兩個參數的構造函數來構造Semaphore對象
如CountDownLatch構造函數傳遞 的初始化信號量個數permits被賦給了AQS的state狀態變量一樣,這裏AQS的state值表示當前持有的信號量個數
1)、acquire()方法
public void acquire() throws InterruptedException {
//調用AQS中的模板方法acquireSharedInterruptibly
sync.acquireSharedInterruptibly(1);
}
AQS中的acquireSharedInterruptibly方法:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//調用Semaphore中sync重寫的tryAcquireShared方法,根據構造函數確定使用的公平策略
if (tryAcquireShared(arg) < 0)
//如果獲取失敗則放入阻塞隊列。然後再次嘗試,如果失敗則調用park方法掛起當前線程
doAcquireSharedInterruptibly(arg);
}
非公平策略:
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
//獲取當前信號量值
int available = getState();
//計算當前剩餘值
int remaining = available - acquires;
//如果當前剩餘值小於0或者CAS設置成功則返回
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
如果線程A先調用了aquire()方法獲取信號量,但是當前信號量個數爲0,那麼線程A會被放入AQS的阻塞隊列。過一段時間後線程C調用了release()方法釋放了一個信號量,如果當前沒有其他線程獲取信號量,那麼線程A就會被激活,然後獲取該信號量,但是假如線程C釋放信號量後,線程C調用了aquire()方法,那麼線程C就會和線程A去競爭這個信號量資源。如果採用非公平策略,線程C完全可以在線程A被激活前,或者激活後先於線程A獲取到該信號量,也就是在這種模式下阻塞線程和當前請求的線程是競爭關係,而不遵循先來先得的策略
公平策略:
protected int tryAcquireShared(int acquires) {
for (;;) {
//公平策略是看當前線程節點的前驅節點是否也在等待獲取該資源,如果是則自己放棄獲取的權限, 然後當前線程會被放入AQS阻塞隊列,否則就去獲取
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
}
2)、release()方法
public void release() {
//調用AQS中的模板方法releaseShared
sync.releaseShared(1);
}
AQS中的releaseShared方法:
public final boolean releaseShared(int arg) {
//嘗試釋放資源
if (tryReleaseShared(arg)) {
//釋放資源成功則調用park方法喚醒AQS隊列裏面最先掛起的線程
doReleaseShared();
return true;
}
return false;
}
Sync中重寫的tryReleaseShared方法:
protected final boolean tryReleaseShared(int releases) {
for (;;) {
//獲取當前信號量值
int current = getState();
//將當前信號量值增加releases
int next = current + releases;
if (next < current)
throw new Error("Maximum permit count exceeded");
//使用CAS保證更新信號量值的原子性
if (compareAndSetState(current, next))
return true;
}
}