CountDownLatch、CyclicBarrier、Semaphore、Exchanger 的詳細解析

AnonyStar發表於2020-11-30


本文主要介紹和對比我們常用的幾種併發工具類,主要涉及 CountDownLatchCyclicBarrierSemaphoreExchanger 相關的內容,如果對多執行緒相關內容不熟悉,可以看筆者之前的一些文章:


  • 介紹 CountDownLatchCyclicBarrier 兩者的使用與區別,他們都是等待多執行緒完成,是一種併發流程的控制手段,
  • 介紹 SemaphoreExchanger 的使用,semaphore 是訊號量,可以用來控制允許的執行緒數,而 Exchanger 可以用來交換兩個執行緒間的資料。

CountDownLatch

  • CountDownLatchJDK5 之後加入的一種併發流程控制工具,它在 java.util.concurrent 包下
  • CountDownLatch 允許一個或多個執行緒等待其他執行緒完成操作,這裡需要注意,是可以是一個等待也可以是多個來等待
  • CountDownLatch 的建構函式如下,它接受一個 int 型別的引數作為計數器,即如果你想等待N 個執行緒完成,那麼這裡就傳入 N
    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }
  • 其中有兩個核心的方法 countDownawait ,其中 當我們呼叫 countDown 方法時相應的 N 的值減 1,而 await 方法則會阻塞當前執行緒,直到 N 的值變為零。
  • 說起來比較抽象,下面我們通過實際案例來說明。

多個執行緒等待一個執行緒

  • 在我們生活中最典型的案例就是體育中的跑步,假設現在我們要進行一場賽跑,那麼所有的選手都需要等待裁判員的起跑命令,這時候,我們將其抽象化每個選手對應的是一個執行緒,而裁判員也是一個執行緒,那麼就是多個選手的執行緒再等待裁判員執行緒的命令來執行
  • 我們通過 CountDownLatch 來實現這一案例,那麼等待的個數 N 就是上面的裁判執行緒的個數,即為 1,

    /**
     * @url i-code.onlien
     * 雲棲簡碼
     */
    public static void main(String[] args) throws InterruptedException {
        //模擬跑步比賽,裁判說開始,所有選手開始跑,我們可以使用countDownlatch來實現

        //這裡需要等待裁判說開始,所以時等著一個執行緒
        CountDownLatch countDownLatch = new CountDownLatch(1);

        new Thread(() ->{
            try {
                System.out.println(Thread.currentThread().getName() +"已準備");
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()+"開始跑~~");

        },"選手1").start();
        new Thread(() ->{
            try {
                System.out.println(Thread.currentThread().getName() +"已準備");
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()+"開始跑~~");

        },"選手2").start();

        TimeUnit.SECONDS.sleep(1);
        System.out.println("裁判:預備~~~");
        countDownLatch.countDown();
        System.out.println("裁判:跑~~~");
    }

  • 執行結果如下:

在上述程式碼中,我們首先建立了一個計數為1 的 CountDownLatch 物件,這代表我們需要等待的執行緒數,之後再建立了兩個執行緒,用來代表選手執行緒,同時在選手的執行緒中我們都呼叫了 await 方法,讓執行緒進入阻塞狀態,直到CountDownLatch的計數為零後再執行後面的內容,在主執行緒 main 方法中我們等待 1秒後執行 countDown 方法,這個方法就是減一,此時的 N 則為零了,那麼選手執行緒則開始執行後面的內容,整體的輸出如上圖所示

一個/多個執行緒等待多個執行緒

  • 同樣從我們生活中的場景來抽象,假設公司要組織出遊,大巴車接送,當湊夠五個人大巴車則發車出發,這裡就是大巴車需要等待這五個人全部到齊才能繼續執行,我們抽象之後用 CountDownLatch 來實現,那麼的計數個數 N 則為5,因為要等待這五個,通過程式碼實現如下:

    public static void main(String[] args) throws InterruptedException {
        /**
         * i-code.online
         * 雲棲簡碼 
         */
        //等待的個數
        CountDownLatch countDownLatch = new CountDownLatch(5);

        for (int i = 0; i < 5; i++) {
            new Thread(()->{
                System.out.println(Thread.currentThread().getName() + "從住所出發...");
                try {
                    TimeUnit.SECONDS.sleep((long) (Math.random()*10));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 到達目的地-----");
                countDownLatch.countDown();
            },"人員-"+i).start();
        }

        System.out.println("大巴正在等待人員中.....");
        countDownLatch.await();
        System.out.println("-----所有人到齊,出發-----");
    }

  • 上面程式碼執行結果如下:

從上述程式碼中我們可以看到,定義了一個計數為5的 countDownLatch ,之後通過迴圈建立五個執行緒,模擬五個人員,當他們到達指定地點後執行 countDown 方法,對計數減一。主執行緒相當於是大巴車的執行緒,執行 await 方法進行阻塞,只有當 N 的值減到0後則執行後面的輸出

CountDownLatch 主要方法介紹

  • 建構函式:
public CountDownLatch(int count) {  };

它的建構函式是傳入一個引數,該引數 count 是需要倒數的數值。

  • await() :呼叫 await() 方法的執行緒開始等待,直到倒數結束,也就是 count 值為 0 的時候才會繼續執行。
  • await(long timeout, TimeUnit unit)await() 有一個過載的方法,裡面會傳入超時引數,這個方法的作用和 await() 類似,但是這裡可以設定超時時間,如果超時就不再等待了。
  • countDown():把數值倒數 1,也就是將 count 值減 1,直到減為 0 時,之前等待的執行緒會被喚起。

上面的案例介紹了 CountDownLatch 的使用,但是 CountDownLatch 有個特點,那就是不能夠重用,比如已經完成了倒數,那可不可以在下一次繼續去重新倒數呢?是可以的,一旦倒數到0 則結束了,無法再次設定迴圈執行,但是我們實際需求中有很多場景中需要迴圈來處理,這時候我們可以使用 CyclicBarrier 來實現

CyclicBarrier

  • CyclicBarrierCountDownLatch 比較相似,當等待到一定數量的執行緒後開始執行某個任務
  • CyclicBarrier 的字面意思是可以迴圈使用的屏障,它的功能就是讓一組執行緒到達一個屏障(同步點)時被阻塞,直到最後一個執行緒到達屏障時,屏障才會開會,此時所有被屏障阻塞的執行緒都將繼續執行。如下演示

  • 上圖中可以看到,到執行緒到達屏障後阻塞,直到最後一個也到達後,則全部放行
  • 首先我們來看下它的建構函式,如下:
    public CyclicBarrier(int parties) {
        this(parties, null);
    }

    public CyclicBarrier(int parties, Runnable barrierAction) {
        if (parties <= 0) throw new IllegalArgumentException();
        this.parties = parties;
        this.count = parties;
        this.barrierCommand = barrierAction;
    }
  • CyclicBarrier(int parties) 建構函式提供了int 型別的引數,代表的是需要攔截的執行緒數量,而每個執行緒通過呼叫 await 方法來告訴 CyclicBarrier 我到達屏障點了,然後阻塞
  • CyclicBarrier(int parties, Runnable barrierAction) 建構函式是為我們提供的一個高階方法,加了一個 barrierAction 的引數,這是一個Runnable型別的,也就是一個執行緒,它表示當所有執行緒到達屏障後,悠閒觸發 barrierAction 執行緒執行,再執行各個執行緒之後的內容

案例

  • 假設你要和你女朋友約會,約定了一個時間地點,那麼不管你們誰先到都會等待另一個到才會出發取約會~ 那麼這時候我們通過CyclicBarrier 的來實現,這裡我們需要來攔截的執行緒就是兩個。具體實現 如下:
    /*
    CyclicBarrier 與countDownLatch 比較相似,也是等待執行緒完成,
    不過countDownLatch 是await等待其他的執行緒通過countDown的數量,達到一定數則執行,
    而 CyclicBarrier 則是直接看await的數量,達到一定數量直接全部執行,
     */
    public static void main(String[] args) {
        //好比情侶約會,不管誰先到都的等另一個,這裡就是兩個執行緒,
        CyclicBarrier cyclicBarrier = new CyclicBarrier(2);

        new Thread(() ->{
            System.out.println("快速收拾,出門~~~");
            try {
                TimeUnit.MILLISECONDS.sleep(500);
                System.out.println("到了約會地點等待女朋友前來~~");
                cyclicBarrier.await();
                System.out.println("女朋友到來嗨皮出發~~約會");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }

        },"男朋友").start();
        new Thread(() ->{
            System.out.println("慢慢收拾,出門~~~");
            try {
                TimeUnit.MILLISECONDS.sleep(5000);
                System.out.println("到了約會地點等待男朋友前來~~");
                cyclicBarrier.await();
                System.out.println("男朋友到來嗨皮出發~~約會");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        },"女朋友").start();

    }
  • 程式碼執行結果如下:

上面程式碼,相對簡單,建立一個攔截數為2的屏障,之後建立兩個執行緒,呼叫await方法,只有當呼叫兩次才會觸發後面的流程。

  • 我們再寫一個案例sh,使用含有Runnable 引數的建構函式;和之前 CountDownLatch 的案例相似,公司組織出遊,這時候肯定有很多大巴在等待接送,大巴不會等所有的 人都到才出發,而是每坐滿一輛車就出發一輛,這種場景我們就可以使用 CyclicBarrier 來實現,實現如下:

    /*
    CyclicBarrier是可重複使用到,也就是每當幾個滿足是不再等待執行,
    比如公司組織出遊,安排了好多輛大把,每坐滿一輛就發車,不再等待,類似這種場景,實現如下:
     */

    public static void main(String[] args) {
        //公司人數
        int peopleNum = 2000;
        //每二十五個人一輛車,湊夠二十五則發車~
        CyclicBarrier cyclicBarrier = new CyclicBarrier(25,() ->{
            //達到25人出發
            System.out.println("------------25人數湊齊出發------------");
        });

        for (int j = 1; j <= peopleNum; j++) {
            new Thread(new PeopleTask("People-"+j,cyclicBarrier)).start();
        }

    }

    static class PeopleTask implements Runnable{

        private String name;
        private  CyclicBarrier cyclicBarrier;
        public PeopleTask(String name,CyclicBarrier cyclicBarrier){
            this.name = name;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            System.out.println(name+"從家裡出發,正在前往聚合地....");
            try {
                TimeUnit.MILLISECONDS.sleep(((int) Math.random()*1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name+"到達集合地點,等待其他人..");
            try {
                cyclicBarrier.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }

        }
    }

CyclicBarrier 和 CountDownLatch 的異同

相同點:

  • 都能阻塞一個或一組執行緒,直到某個預設的條件達成發生,再統一出發

不同點:

  • 可重複性:CountDownLatch 的計數器只能使用一次,到到達0後就不能再次使用了,除非新建例項;而 CyclicBarrier 的計數器是可以複用迴圈的,所以 CyclicBarrier 可以用在更復雜的場景,可以隨時呼叫 reset方法來重製攔截數,如計算髮生錯誤時可以直接充值計數器,讓執行緒重新執行一次。
  • 作用物件:CyclicBarrier 要等固定數量的執行緒都到達了屏障位置才能繼續執行,而 CountDownLatch 只需等待數字倒數到 0,也就是說 CountDownLatch 作用於事件,但 CyclicBarrier 作用於執行緒;CountDownLatch 是在呼叫了 countDown 方法之後把數字倒數減 1,而 CyclicBarrier 是在某執行緒開始等待後把計數減 1
  • 執行動作:CyclicBarrier 有執行動作 barrierAction,而 CountDownLatch 沒這個功能。

Semaphore

  • Semaphore (訊號量)是用來控制同時訪問特定資源的執行緒數量,它通過協調各個執行緒,以保證合理的使用公共資源,

  • 從圖中可以看出,訊號量的一個最主要的作用就是,來控制那些需要限制併發訪問量的資源。具體來講,訊號量會維護“許可證”的計數,而執行緒去訪問共享資源前,必須先拿到許可證(acquire 方法)。執行緒可以從訊號量中去“獲取”一個許可證,一旦執行緒獲取之後,訊號量持有的許可證就轉移過去了,所以訊號量手中剩餘的許可證要減一。
  • 同理,執行緒也可以“釋放”一個許可證,如果執行緒釋放了許可證(release 方法),這個許可證相當於被歸還給訊號量了,於是訊號量中的許可證的可用數量加一。當訊號量擁有的許可證數量減到 0 時,如果下個執行緒還想要獲得許可證,那麼這個執行緒就必須等待,直到之前得到許可證的執行緒釋放,它才能獲取。由於執行緒在沒有獲取到許可證之前不能進一步去訪問被保護的共享資源,所以這就控制了資源的併發訪問量,這就是整體思路。

案例

  • 如我們平時開發中典型的資料庫操作,這是一個密集IO 操作,我們可以啟動很多執行緒但是資料庫的連線池是有限制的,假設我們設定允許五個連結,如果我們開啟太多執行緒直接操作則會出現異常,這時候我們可以通過訊號量來控制,讓一直最多隻有五個執行緒來獲取連線。程式碼如下:
    /*
        Semaphore 是訊號量, 可以用來控制執行緒的併發數,可以協調各個執行緒,以達到合理的使用公共資源
     */

    public static void main(String[] args) {
        //建立10個容量的執行緒池
        final ExecutorService service = Executors.newFixedThreadPool(100);
        //設定訊號量的值5 ,也就是允許五個執行緒來執行
        Semaphore s = new Semaphore(5);
        for (int i = 0; i < 100; i++) {
            service.submit(() ->{
                try {
                    s.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                try {
                    System.out.println("資料庫耗時操作"+Thread.currentThread().getName());
                    TimeUnit.MILLISECONDS.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "正在執行....");
                s.release();
            });
        }

    }

如上程式碼,建立了一個容量100的執行緒池,模擬我們程式中大量的執行緒,新增一百個任務,讓執行緒池執行。建立了一個容量為5的訊號量,線上程中我們呼叫 acquire 來獲得訊號量的許可,只有獲得了才能只能下面的內容不然阻塞。當執行完後釋放該許可,通過 release 方法,

  • 通過上面的演示,有沒有覺得非常眼熟,對,就是和我們之前接觸過的鎖很相似,只是鎖是隻允許一個執行緒訪問,那我們能不能將訊號量的容量設定為1呢? 這當然是可以的,當我們設定為1時其實就和我們的鎖的功能是一致的,如下程式碼:
    private static int count = 0;
    /*
        Semaphore 中如果我們允許的的許可證數量為1 ,那麼它的效果與鎖相似。
     */
    public static void main(String[] args) throws InterruptedException {
        final ExecutorService service = Executors.newFixedThreadPool(10);

        Semaphore semaphore = new Semaphore(1);
        for (int i = 0; i < 10000; i++) {
            service.submit(() ->{
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "執行了");
                count ++;
                semaphore.release();
            });
        }
        service.shutdown();
        TimeUnit.SECONDS.sleep(5);
        System.out.println(count);

    }

其他主要方法介紹

  • public boolean tryAcquire()tryAcquire 和鎖的 trylock 思維是一致的,是嘗試獲取許可證,相當於看看現在有沒有空閒的許可證,如果有就獲取,如果現在獲取不到也沒關係,不必陷入阻塞,可以去做別的事。
  • public boolean tryAcquire(long timeout, TimeUnit unit):是一個過載的方法,它裡面傳入了超時時間。比如傳入了 3 秒鐘,則意味著最多等待 3 秒鐘,如果等待期間獲取到了許可證,則往下繼續執行;如果超時時間到,依然獲取不到許可證,它就認為獲取失敗,且返回 false。
  • int availablePermits():返回此訊號量中當前可用的許可證數
  • int getQueueLength():返回正在等待許可證的執行緒數
  • boolean hasQueuedThreads():判斷是否有執行緒正在等待獲取許可證
  • void reducePermits(int reduction):減少 reduction 個許可證,是個 protected 方法
  • Collection<Thread> getQueuedThreads():返回正在等待獲取許可證的執行緒集合,是個 protected 方法

Exchanger

  • Exchanger(交換者)是一個用於執行緒間協作的工具類,它主要用於進行執行緒間資料的交換,它有一個同步點,當兩個執行緒到達同步點時可以將各自的資料傳給對方,如果一個執行緒先到達同步點則會等待另一個到達同步點,到達同步點後呼叫 exchange 方法可以傳遞自己的資料並且獲得對方的資料。
  • 我們假設現在需要錄入一些重要的賬單資訊,為了保證準備,讓兩個人分別錄入,之後再進行對比後是否一致,防止錯誤繁盛。下面通過程式碼來演示:
public class ExchangerTest {

    /*
    Exchanger 交換, 用於執行緒間協作的工具類,可以交換執行緒間的資料,
    其提供一個同步點,當執行緒到達這個同步點後進行資料間的互動,遺傳演算法可以如此來實現,
    以及校對工作也可以如此來實現
     */

    public static void main(String[] args) {
        /*
        模擬 兩個工作人員錄入記錄,為了防止錯誤,兩者錄的相同內容,程式僅從校對,看是否有錯誤不一致的
         */

        //開闢兩個容量的執行緒池
        final ExecutorService service = Executors.newFixedThreadPool(2);

        Exchanger<InfoMsg> exchanger = new Exchanger<>();

        service.submit(() ->{
            //模擬資料 執行緒 A的
            InfoMsg infoMsg = new InfoMsg();
            infoMsg.content="這是執行緒A";
            infoMsg.id ="10001";
            infoMsg.desc = "1";
            infoMsg.message = "message";
            System.out.println("正在執行其他...");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                final InfoMsg exchange = exchanger.exchange(infoMsg);
                System.out.println("執行緒A 交換資料====== 得到"+ exchange);
                if (!exchange.equals(infoMsg)){
                    System.out.println("資料不一致~~請稽核");
                    return;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        service.submit(() ->{
            //模擬資料 執行緒 B的
            InfoMsg infoMsg = new InfoMsg();
            infoMsg.content="這是執行緒B";
            infoMsg.id ="10001";
            infoMsg.desc = "1";
            infoMsg.message = "message";
            System.out.println("正在執行其他...");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                final InfoMsg exchange = exchanger.exchange(infoMsg);
                System.out.println("執行緒B 交換資料====== 得到"+ exchange);
                if (!exchange.equals(infoMsg)){
                    System.out.println("資料不一致~~請稽核");
                    return;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        service.shutdown();
    }

    static class InfoMsg{
        String id;
        String name;
        String message;
        String content;
        String desc;

        @Override
        public String toString() {
            return "InfoMsg{" +
                    "id='" + id + '\'' +
                    ", name='" + name + '\'' +
                    ", message='" + message + '\'' +
                    ", content='" + content + '\'' +
                    ", desc='" + desc + '\'' +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            InfoMsg infoMsg = (InfoMsg) o;
            return Objects.equals(id, infoMsg.id) &&
                    Objects.equals(name, infoMsg.name) &&
                    Objects.equals(message, infoMsg.message) &&
                    Objects.equals(content, infoMsg.content) &&
                    Objects.equals(desc, infoMsg.desc);
        }

        @Override
        public int hashCode() {
            return Objects.hash(id, name, message, content, desc);
        }
    }
}

  • 執行結果如下:

上面程式碼執行可以看到,當我們執行緒 A/B 到達同步點即呼叫 exchange 後進行資料的交換,拿到對方的資料再與自己的資料對比可以做到稽核 的效果

  • Exchanger 同樣可以用於遺傳演算法中,選出兩個物件進行互動兩個的資料通過交叉規則得到兩個混淆的結果。
  • Exchanger 中嗨提供了一個方法 public V exchange(V x, long timeout, TimeUnit unit) 主要是用來防止兩個程式中一個一直沒有執行 exchange 而導致另一個一直陷入等待狀態,這是可以用這個方法,設定超時時間,超過這個時間則不再等待。


本文由AnonyStar 釋出,可轉載但需宣告原文出處。
歡迎關注微信公賬號 :雲棲簡碼 獲取更多優質文章
更多文章關注筆者部落格 :雲棲簡碼 i-code.online

相關文章