19、Java併發包提供了哪些併發工具類?(高併發編程----5)

目錄

Java 併發包提供了哪些併發工具類?

典型回答

 考點分析

知識擴展

Semaphore

CountDownLatch

CyclicBarrier

線程安全 Map、List 和 Set

一課一練


通過前面的學習,我們一起回顧了線程、鎖等各種併發編程的基本元素,也逐步涉及了 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 的同步結構解決實際問題嗎?談談你的使用場景和心得。

需求是五個下載過程,每個下載過程一個線程,分別在每個線程裏計算各自的數據,最終等到所有線程計算完畢,我還需要將每個有共通的對象進行合併,所以用它很合適。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章