深入併發-工具類

CountDownLatch

如果不熟悉 AQS 的同學建議先去看樓主的之前的博文《深入併發-AQS》,不然理解本章將很喫力。

概念

CountDownLatch 是一個同步工具類,它允許一個或多個線程一直等待,直到其他線程的操作執行完畢再執行。從命名可以解讀到 countdown 是倒數的意思,類似於我們倒計時的概念。
CountDownLatch 提供了兩個方法,一個是 countDown,一個是 await,countdownlatch 初始化的時候需要傳入一個整數,在這個整數倒數到 0 之前,調用了 await 方法的程序都必須要等待,然後通過 countDown 來倒數。

案例

public static void main(String[] args) {
    final CountDownLatch latch = new CountDownLatch(2);
    System.out.println("主線程開始執行");
    //第一個子線程執行
    new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("子線程:"+Thread.currentThread().getName()+"執行完成");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        latch.countDown();
    }).start();

    //第二個子線程執行
    new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("子線程:"+Thread.currentThread().getName()+"執行完成");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        latch.countDown();
    }).start();
    System.out.println("等待兩個線程執行完畢");
    try {
        latch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("兩個子線程都執行完畢,繼續執行主線程");
}

從代碼的實現來看,有點類似 join 的功能,但是比 join 更加靈活。CountDownLatch 構造函數會接收一個 int 類型的參數作爲計數器的初始值,當調用 CountDownLatch 的 countDown 方法時,這個計數器就會減一。

使用場景

凡是涉及到需要指定某個節點在執行之前,要等到前置節點執行完畢之後才執行的場景,都可以使用CountDownLatch。

源碼分析

我們從 latch.await(); 開始進入分析,進入之後回到 acquireSharedInterruptibly 方法

  1. 判斷當前線程是否被中斷,是則直接拋出異常
  2. 獲取計數器的值,如果小於0則說明當前線程需要加入到共享鎖隊列中並阻塞,然後等待被喚醒
public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

tryAcquireShared 直接獲取到 state 的值進行判斷

protected int tryAcquireShared(int acquires) {
   return (getState() == 0) ? 1 : -1;
}

來看下 doAcquireSharedInterruptibly 是怎麼實現的

  1. 將當前線程封裝成 node 並加入到同步隊列中變成尾結點
  2. 獲取當前線程的前置節點
  3. 如果前置節點是 head 節點則獲取計數器的值並且等於0,將當前線程節點設置爲 head 節點, 然後繼續喚醒隊列中的其他線程。
  4. 如果計數器的值不等0,進行阻塞等待其他線程喚醒繼續自旋等待計數器變爲0
private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
        	//被喚醒的線程進入下一次循環繼續判斷
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // 把當前節點移除 aqs 隊列
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

setHeadAndPropagate 這個方法的主要作用是把被喚醒的節點,設置成 head 節點然後繼續喚醒隊列中的其他線程。

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

doReleaseShared 是喚醒阻塞線程的具體實現,共享鎖的釋放和獨佔鎖的釋放有一定的差別

  1. 前面喚醒鎖的邏輯和獨佔鎖是一樣,先判斷頭結點是不是 SIGNAL 狀態,如果是,則修改爲 0,並且喚醒頭結點的下一個節點
  2. 通過檢查頭節點是否改變了,如果改變了就繼續循環。只有當頭節點被剛剛喚醒的線程佔有,才需要重新進入下一輪循環,喚醒下一個節點。
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            //CAS 失敗的場景是:執行到這裏的時候,剛好有一個節點入隊,入隊會將這個 ws 設置爲 -1
            //PROPAGATE: 標識爲 PROPAGATE 狀態的節點,是共享鎖模式下的節點狀態,處於這個狀態下的節點,會對線程的喚醒進行傳播
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

這裏就不對 unparkSuccessor 再次進行分析了,之前在 AQS 源碼分析講過。

接着我們從 latch.countDown() 進入看看是如何喚醒阻塞線程

  1. 只有當 state 減爲 0 的時候,tryReleaseShared 才返回 true, 否則只是簡單的 state = state - 1
  2. 如果 state=0, 則調用 doReleaseShared 喚醒處於 await 狀態下的線程
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

tryReleaseShared 是使用自旋的方法實現 state 減 1

protected boolean tryReleaseShared(int releases) {
   // Decrement count; signal when transition to zero
    for (;;) {
        int c = getState();
        if (c == 0)
            return false;
        int nextc = c-1;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

Semaphore

概念

semaphore 也就是我們常說的信號燈,semaphore 可以控制同時訪問的線程個數,通過 acquire 獲取一個許可,如果沒有就等待,通過 release 釋放一個許可。有點類似限流
的作用。叫信號燈的原因也和他的用處有關,比如某商場就 3 個停車位,每個停車位只能停一輛車,如果這個時候來了 10 輛車,必須要等前面有空的車位才能進入。

案例

public static void main(String[] args) {
    //信號量,只允許3個線程同時訪問
    Semaphore semaphore = new Semaphore(3);
    for (int i=0;i<10;i++){
        final long num = i;
        new Thread(() -> {
            try {
                //獲取許可
                semaphore.acquire();
                //執行
                System.out.println("進入停車場:" + num);
                Thread.sleep(2000);
                //釋放
                semaphore.release();
                System.out.println("離開停車場:" + num);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

使用場景

Semaphore 比較常見的就是用來做限流操作。

源碼分析

從 Semaphore 的功能來看,我們基本能猜測到它的底層實現一定是基於 AQS 的共享鎖,因爲需要實現多個線程共享一個令牌池。
創建 Semaphore 實例的時候,需要一個參數 permits,這個是設置給 AQS 的 state 的,然後每個線程調用 acquire 的時候,執行 state = state - 1。release 的時候執行 state = state + 1,當然,acquire 的時候,如果 state = 0,說明沒有資源了,需要等待其他線程 release。
Semaphore 分公平策略和非公平策略,區別在於每次獲取令牌前會判斷是否有線程在排隊,然後才進行 CAS 獲取令牌操作。
由於後面的代碼和 CountDownLatch 的是完全一樣,都是基於共享鎖的實現,所以就沒再花時間來分析了。

CyclicBarrier

概念

CyclicBarrier 的字面意思是可循環使用(Cyclic)的屏障(Barrier)。它要做的事情是,讓一組線程到達一個屏障(也可以叫同步點)時被阻塞,直到最後一個線程到達屏障時,屏障纔會開門,所有被屏障攔截的線程纔會繼續工作並且屏障恢復成初始狀態攔截下一波線程。CyclicBarrier 默認的構造方法是 CyclicBarrier(int parties),其參數表示屏障攔截的線程數量,每個線程調用 await 方法告訴 CyclicBarrier 當前線程已經到達了屏障,然後當前線程被阻塞。

案例

public static void main(String[] args) {
    //等待線程的數量,需要最後到達的線程執行的操作
    CyclicBarrier barrier = new CyclicBarrier(5, () -> System.out.println(Thread.currentThread().getName() + " 完成最後任務"));
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + " 到達柵欄 A");
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " 衝破柵欄 A");

                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + " 到達柵欄 B");
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " 衝破柵欄 B");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

注意:

  1. 對於指定計數值 parties,若由於某種原因,沒有足夠的線程調用 CyclicBarrier 的 await,則所有調用 await 的線程都會被阻塞;
  2. 同樣的 CyclicBarrier 也可以調用 await(timeout, unit),設置超時時間,在設定時間內,如果沒有足夠線程到達則解除阻塞狀態,繼續工作;
  3. 通過 reset 重置計數,會使得進入 await 的線程出現BrokenBarrierException;
  4. 如果採用是 CyclicBarrier(int parties, RunnablebarrierAction) 構造方法,執行 barrierAction 操作的是最後一個到達的線程;

使用場景

當存在需要所有的子任務都完成時,才執行主任務,這個時候就可以選擇使用 CyclicBarrier

源碼分析

我們首先來看下 CyclicBarrier 的屬性定義

//同步操作鎖
private final ReentrantLock lock = new ReentrantLock();
//線程攔截器,對線程進行阻塞
private final Condition trip = lock.newCondition();
//每次攔截的線程數,該值在構造時進行賦值
private final int parties;
//換代前執行的任務,在喚醒所有線程之前由最後一個被喚醒的線程執行該操作
private final Runnable barrierCommand;
//表示柵欄的當前代,利用它可以實現循環等待
private Generation generation = new Generation();
//內部計數器,它的初始值和 parties 相同。以後隨着每次 await 方法的調用而減1,直到減爲0就將所有線程喚醒
private int count;
 
//靜態內部類Generation
private static class Generation {
  boolean broken = false;
}

我們來分析來最主要的等待方法 dowait

  1. 判斷當前線程是否被中斷,如果被中斷過則恢復計數器、喚醒所有等待線程後拋出異常
  2. 將 count 減一後判斷是否爲0,如果爲0則執行換代前的任務,然後喚醒所有線程並將恢復計數器
  3. 如果不爲0,則執行線程阻塞等待喚醒
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();
        }

        int index = --count;
        if (index == 0) {  // tripped
            boolean ranAction = false;
            try {
                final Runnable command = barrierCommand;
                if (command != null)
                    command.run();
                ranAction = true;
                nextGeneration();
                return 0;
            } finally {
            	//確保在任務拋出異常時也能將所有線程喚醒
                if (!ranAction)
                    breakBarrier();
            }
        }

        // loop until tripped, broken, interrupted, or timed out
        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();
    }
}

其他一些方法很直觀,這裏就不做分析了。
CyclicBarrier 相比 CountDownLatch 來說,要簡單很多,源碼實現是基於 ReentrantLock 和 Condition 的組合使用。

總結

CyclicBarrier與CountDownLatch的區別

  1. CyclicBarrier 的計數器由自己控制,而 CountDownLatch 的計數器則由使用者來控制。在CyclicBarrier 中線程調用 await 方法不僅會將自己阻塞還會將計數器減1,而在 CountDownLatch 中線程調用await方法只是將自己阻塞而不會減少計數器的值。
  2. CountDownLatch只能攔截一輪,而CyclicBarrier可以實現循環攔截。

參考鏈接:https://blog.csdn.net/qq_39241239/article/details/87030142

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