目錄
通過前面的學習,我們一起回顧了線程、鎖等各種併發編程的基本元素,也逐步涉及了 Java 併發包中的部分內容,相信經過前面的熱身,我們能夠更快地理解 Java 併發包。
Java 併發包提供了哪些併發工具類?
典型回答
我們通常所說的併發包也就是 java.util.concurrent 及其子包,集中了 Java 併發的各種基礎工具類,具體主要包括幾個方面:
- 提供了比 synchronized 更加高級的各種同步結構,包括 CountDownLatch、CyclicBarrier、Semaphore 等,可以實現更加豐富的多線程操作,比如利用 Semaphore 作爲資源控制器,限制同時進行工作的線程數量。
- 各種線程安全的容器,比如最常見的 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通過類似快照機制,實現線程安全的動態數組 CopyOnWriteArrayList 等。
- 各種併發隊列實現,如各種 BlockedQueue 實現,比較典型的 ArrayBlockingQueue、 SynchorousQueue 或針對特定場景的 PriorityBlockingQueue 等。
- 強大的 Executor 框架,可以創建各種不同類型的線程池,調度任務運行等,絕大部分情況下,不再需要自己從頭實現線程池和任務調度器。
考點分析
這個題目主要考察你對併發包瞭解程度,以及是否有實際使用經驗。我們進行多線程編程,無非是達到幾個目的:
- 利用多線程提高程序的擴展能力,以達到業務對吞吐量的要求。
- 協調線程間調度、交互,以完成業務邏輯。
- 線程間傳遞數據和狀態,這同樣是實現業務邏輯的需要。
所以,這道題目只能算作簡單的開始,往往面試官還會進一步考察如何利用併發包實現某個特定的用例,分析實現的優缺點等。
如果你在這方面的基礎比較薄弱,我的建議是:
- 從總體上,把握住幾個主要組成部分(前面回答中已經簡要介紹)。
- 理解具體設計、實現和能力。
- 再深入掌握一些比較典型工具類的適用場景、用法甚至是原理,並熟練寫出典型的代碼用例。
掌握這些通常就夠用了,畢竟併發包提供了方方面面的工具,其實很少有機會能在應用中全面使用過,紮實地掌握核心功能就非常不錯了。真正特別深入的經驗,還是得靠在實際場景中踩坑來獲得。
知識擴展
首先,我們來看看併發包提供的豐富同步結構。前面幾講已經分析過各種不同的顯式鎖,今天我將專注於
- CountDownLatch,允許一個或多個線程等待某些操作完成。
- CyclicBarrier,一種輔助性的同步結構,允許多個線程等待到達某個屏障。
- Semaphore,Java 版本的信號量實現。
Semaphore
Java 提供了經典信號量(Semaphore))的實現,它通過控制一定數量的允許(permit)的方式,來達到限制通用資源訪問的目的。你可以想象一下這個場景,在車站、機場等出租車時,當很多空出租車就位時,爲防止過度擁擠,調度員指揮排隊等待坐車的隊伍一次進來量5 個人上車,等這 5 個人坐車出發,再放進去下一批,這和 Semaphore 的工作原理有些類似。
你可以試試使用 Semaphore 來模擬實現這個調度過程:
import java.util.concurrent.Semaphore;
public class UsualSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
System.out.println("Action...GO!");
Semaphore semaphore = new Semaphore(5);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new SemaphoreWorker(semaphore));
t.start();
}
}
}
class SemaphoreWorker implements Runnable {
private String name;
private Semaphore semaphore;
public SemaphoreWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
log("is waiting for a permit!");
semaphore.acquire();
log("acquired a permit!");
log("executed!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
log("released a permit!");
semaphore.release();
}
}
private void log(String msg){
if (name == null) {
name = Thread.currentThread().getName();
}
System.out.println(name + " " + msg);
}
}
這段代碼是比較典型的 Semaphore 示例,其邏輯是,線程試圖獲得工作允許,得到許可則進行任務,然後釋放許可,這時等待許可的其他線程,就可獲得許可進入工作狀態,直到全部處理結束。編譯運行,我們就能看到 Semaphore 的允許機制對工作線程的限制。
但是,從具體節奏來看,其實並不符合我們前面場景的需求,因爲本例中 Semaphore 的用法實際是保證,一直有 5 個人可以試圖乘車,如果有 1 個人出發了,立即就有排隊的人獲得許可,而這並不完全符合我們前面的要求。
那麼,我再修改一下,演示個非典型的 Semaphore 用法。
import java.util.concurrent.Semaphore;
public class AbnormalSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(0);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new MyWorker(semaphore));
t.start();
}
System.out.println("Action...GO!");
semaphore.release(5);
System.out.println("Wait for permits off");
while (semaphore.availablePermits()!=0) {
Thread.sleep(100L);
}
System.out.println("Action...GO again!");
semaphore.release(5);
}
}
class MyWorker implements Runnable {
private Semaphore semaphore;
public MyWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("Executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
注意,上面的代碼,更側重的是演示 Semaphore 的功能以及侷限性,其實有很多線程編程中的反實踐,比如使用了 sleep 來協調任務執行,而且使用輪詢調用 availalePermits 來檢測信號量獲取情況,這都是很低效並且脆弱的,通常只是用在測試或者診斷場景。
總的來說,我們可以看出 Semaphore 就是個計數器,其基本邏輯基於 acquire/release,並沒有太複雜的同步邏輯。
如果 Semaphore 的數值被初始化爲 1,那麼一個線程就可以通過 acquire 進入互斥狀態,本質上和互斥鎖是非常相似的。但是區別也非常明顯,比如互斥鎖是有持有者的,而對於 Semaphore 這種計數器結構,雖然有類似功能,但其實不存在真正意義的持有者,除非我們進行擴展包裝。
CountDownLatch
下面,來看看 CountDownLatch 和 CyclicBarrier的區別。它們的行爲有一定的相似度,經常會被考察二者有什麼區別,我來簡單總結一下。
- CountDownLatch 是不可以重置的,所以無法重用;而 CyclicBarrier 則沒有這種限制,可以重用。
- CountDownLatch 的基本操作組合是 countDown/await。調用 await 的線程阻塞等待 countDown 足夠的次數,不管你是在一個線程還是多個線程裏 countDown,只要次數足夠即可。所以就像 Brain Goetz 說過的,CountDownLatch 操作的是事件。
- CyclicBarrier 的基本操作組合,則就是 await,當所有的夥伴(parties)都調用了 await,纔會繼續進行任務,並自動進行重置。注意,正常情況下,CyclicBarrier 的重置都是自動發生的,如果我們調用 reset 方法,但還有線程在等待,就會導致等待線程被打擾,拋出 BrokenBarrierException 異常。CyclicBarrier 側重點是線程,而不是調用事件,它的典型應用場景是用來等待併發線程結束。
如果用 CountDownLatch 去實現上面的排隊場景,該怎麼做呢?假設有 10 個人排隊,我們將其分成 5 個人一批,通過 CountDownLatch 來協調批次,你可以試試下面的示例代碼。
import java.util.concurrent.CountDownLatch;
public class LatchSample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(6);
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new FirstBatchWorker(latch));
t.start();
}
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new SecondBatchWorker(latch));
t.start();
}
// 注意這裏也是演示目的的邏輯,並不是推薦的協調方式
while ( latch.getCount() != 1 ){
Thread.sleep(100L);
}
System.out.println("Wait for first batch finish");
latch.countDown();
}
}
class FirstBatchWorker implements Runnable {
private CountDownLatch latch;
public FirstBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
System.out.println("First batch executed!");
latch.countDown();
}
}
class SecondBatchWorker implements Runnable {
private CountDownLatch latch;
public SecondBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
latch.await();
System.out.println("Second batch executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
CountDownLatch 的調度方式相對簡單,後一批次的線程進行 await,等待前一批 countDown 足夠多次。這個例子也從側面體現出了它的侷限性,雖然它也能夠支持 10 個人排隊的情況,但是因爲不能重用,如果要支持更多人排隊,就不能依賴一個 CountDownLatch 進行了。其編譯運行輸出如下:
在實際應用中的條件依賴,往往沒有這麼彆扭,CountDownLatch 用於線程間等待操作結束是非常簡單普遍的用法。通過 countDown/await 組合進行通信是很高效的,通常不建議使用例子裏那個循環等待方式。
CyclicBarrier
如果用 CyclicBarrier 來表達這個場景呢?我們知道 CyclicBarrier 其實反映的是線程並行運行時的協調,在下面的示例裏,從邏輯上,5 個工作線程其實更像是代表了 5 個可以就緒的空車,而不再是 5 個乘客,對比前面 CountDownLatch 的例子更有助於我們區別它們的抽象模型,請看下面的示例代碼:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierSample {
public static void main(String[] args) throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(5, new Runnable() {
@Override
public void run() {
System.out.println("Action...GO again!");
}
});
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new CyclicWorker(barrier));
t.start();
}
}
static class CyclicWorker implements Runnable {
private CyclicBarrier barrier;
public CyclicWorker(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
for (int i=0; i<3 ; i++){
System.out.println("Executed!");
barrier.await();
}
} catch (BrokenBarrierException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
爲了讓輸出更能表達運行時序,我使用了 CyclicBarrier 特有的 barrierAction,當屏障被觸發時,Java 會自動調度該動作。因爲 CyclicBarrier 會自動進行重置,所以這個邏輯其實可以非常自然的支持更多排隊人數。其編譯輸出如下:
Java 併發類庫還提供了Phaser,功能與 CountDownLatch 很接近,但是它允許線程動態地註冊到 Phaser 上面,而 CountDownLatch 顯然是不能動態設置的。Phaser 的設計初衷是,實現多個線程類似步驟、階段場景的協調,線程註冊等待屏障條件觸發,進而協調彼此間行動,具體請參考這個例子。
線程安全 Map、List 和 Set
接下來,我來梳理下併發包裏提供的線程安全 Map、List 和 Set。首先,請參考下面的類圖。
你可以看到,總體上種類和結構還是比較簡單的,如果我們的應用側重於 Map 放入或者獲取的速度,而不在乎順序,大多推薦使用 ConcurrentHashMap,反之則使用 ConcurrentSkipListMap;如果我們需要對大量數據進行非常頻繁地修改,ConcurrentSkipListMap 也可能表現出優勢。
我在前面的專欄,談到了普通無順序場景選擇 HashMap,有順序場景則可以選擇類似 TreeMap 等,但是爲什麼併發容器裏面沒有 ConcurrentTreeMap 呢?
這是因爲 TreeMap 要實現高效的線程安全是非常困難的,它的實現基於複雜的紅黑樹。爲保證訪問效率,當我們插入或刪除節點時,會移動節點進行平衡操作,這導致在併發場景中難以進行合理粒度的同步。而 SkipList 結構則要相對簡單很多,通過層次結構提高訪問速度,雖然不夠緊湊,空間使用有一定提高(O(nlogn)),但是在增刪元素時線程安全的開銷要好很多。爲了方便你理解 SkipList 的內部結構,我畫了一個示意圖。
關於兩個 CopyOnWrite 容器,其實 CopyOnWriteArraySet 是通過包裝了 CopyOnWriteArrayList 來實現的,所以在學習時,我們可以專注於理解一種。
首先,CopyOnWrite 到底是什麼意思呢?它的原理是,任何修改操作,如 add、set、remove,都會拷貝原數組,修改後替換原來的數組,通過這種防禦性的方式,實現另類的線程安全。請看下面的代碼片段,我進行註釋的地方,可以清晰地理解其邏輯。
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
// 拷貝
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 替換
setArray(newElements);
return true;
}
}
final void setArray(Object[] a) {
array = a;
}
所以這種數據結構,相對比較適合讀多寫少的操作,不然修改的開銷還是非常明顯的。
今天我對 Java 併發包進行了總結,並且結合實例分析了各種同步結構和部分線程安全容器,希望對你有所幫助。
一課一練
關於今天我們討論的題目你做到心中有數了嗎?留給你的思考題是,你使用過類似 CountDownLatch 的同步結構解決實際問題嗎?談談你的使用場景和心得。
需求是五個下載過程,每個下載過程一個線程,分別在每個線程裏計算各自的數據,最終等到所有線程計算完畢,我還需要將每個有共通的對象進行合併,所以用它很合適。