沒聽說過CyclicBarrier、Phaser、Semaphore?面試官說:你可能沒學過Java

Java 中的類非常非常多,雖然你不一定都用過,但是,對於一些關於線程交互、同步、通信等等相關的類我們還是需要去學習和了解的。
比如大名鼎鼎的 AQS,Lock…等等。
在我這一篇博客中,我主要去講一些平時大家可能會忽略的一些但是又不能不知道的一些併發工具類,我會從主要的使用、原理,和一部分底層的代碼來幫助大家理解。

前半部分講解使用,後半部分分析源碼
(源碼是有關 AQS 的知識的,如果不懂 AQS,可以看我之前的博客)
(因爲你要是懂 AQS 的話,這些代碼你掃一眼就能看懂了,通俗來說 AQS 本質上就是共享鎖和互斥鎖)
基於ReentrantLock互斥鎖,講解AQS
基於ReentrantReadWriteLock讀寫鎖,講解AQS

學習忌浮躁

CountDownLatch(計數門栓)應用場景

CountDown 叫倒數,Latch 是門栓的意思。
(比如倒數 5、4、3、2、1,然後門栓打開,所有在門栓前等待的人放行)

詳細點解釋就是:
1、首先有一個門栓,上面記錄了一個數值(比如 100);
2、然後有很多線程來想要經過這裏,但是門栓是鎖着的,它們只好在這裏調用 await 方法等待
3、同樣,有很多線程過來,它們就是工作人員,負責用 countDown 方法減去門栓上的數值。
4、這些線程不斷地減數值,等到這個 100 被減爲了 0,那麼門栓打開,所有之前 await 等待的線程全部向後執行

這時候可以舉兩個很經典的應用場景。

1、火箭發射
(等待一堆線程執行完畢某個任務後,某任務纔可以接着執行)

有 10 個人負責準備發射前的先關事項
等到 10 個人全部完成,就可以讓火箭升空

public class CountDownLatchTest1 {
    public static void main(String[] args) throws InterruptedException {
        // 創建計數爲10的門栓
        CountDownLatch latch = new CountDownLatch(10);
        // 創建火箭線程,會阻塞等待10位員工準備完畢
        new Thread(() -> {
            try {
                System.out.println("火箭即將發射,等待10位操作人員準備完成");
                latch.await(); // 等待員工計數減爲0
                System.out.println("點火!發射!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        Thread.sleep(1000);
        // 10個線程代表10個員工,分別準備自己的任務
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "準備完畢");
                latch.countDown(); // 計數-1
            }, i + "號員工").start();
        }
    }
}

火箭升空
2、運動員起跑
(等待一個倒計時,然後很多線程同時執行)

遠動員依次到場,然後開始等待起跑,
發令員發現所有的運動員到位後,在某個時刻,打響發令槍

public class CountDownLatchTest2 {
    public static void main(String[] args) throws InterruptedException {
        // 創建計數爲1的門栓
        CountDownLatch countDownLatch = new CountDownLatch(1);
        // 8個運動員依次到達
        for (int i = 0; i < 8; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + ":ready");
                    countDownLatch.await(); // 開始等待
                    System.out.println(Thread.currentThread().getName() + ":go");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, i + "號運動員").start();
        }
        Thread.sleep(1000);
        System.out.println("發令槍聲響");
        // 門栓計數-1
        countDownLatch.countDown();
    }
}

運動員起跑
所以很多時候,如果我們需要用到讓線程在其他一些線程執行完成後再執行,
或者很多線程同一時間執行,就可以用到 CountDownLatch

CyclicBarrier(循環柵欄)應用場景

上面理解了 計數 門栓 的使用之後,這裏就很容易理解。

循環柵欄,意味着同樣是一個門栓,只不過可以重複循環使用。

比如有一個遊戲項目,需要 4 個人一起參與。
那麼,很多人排着隊來了,
來了 4 個,就放行這 4 個人,然後再來了 4 個,就再一起放行這 4 個人…如此循環

CyclicBarrier 可以指定兩個參數:
一個是柵欄攔住的人數(需要有多少人才放行)
第二個是人滿了之後做什麼事(可以不指定)

public class CyclicBarrierTest {
    public static void main(String[] args) throws InterruptedException {
        // 創建一個大小爲4的循環柵欄,在數量足夠之後打印一句話“放行”
        CyclicBarrier cyclicBarrier = new CyclicBarrier(4,() -> {
            System.out.println("放行");
        });
        // 不斷創建很多的排隊玩遊戲的遊客
        for(int i = 1; i <= 20; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + "開始等待");
                    cyclicBarrier.await(); // 遊客在這裏等待,如果人數夠了就繼續出發去玩
                    System.out.println(Thread.currentThread().getName() + "出發");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, "線程" + i).start();
            Thread.sleep(1000);
        }
    }
}

循環柵欄

Semaphore(信號量)應用場景

Semaphore(信號量),即允許的數量。

你可以往裏面傳一個數,代表同一時間有多少線程可以同時執行。
(相當於一把有上限的共享鎖)

比如說,有一張大桌子可以做 8 個人,有十多個人要在這張桌子上喫飯。
但是由於桌子只能做 8 個,所以同一時刻最多隻能有 8 個人在喫,其他人只能先等待着;
然後如果其中有人先喫完了,那麼剩下的人裏面中的一個就可以補上那個空出來的位置。
最後很多人都喫完了,桌子上也可以只坐着那麼兩三個人。

其中,可以指定公平和非公平,如果爲公平,那麼有人喫完飯之後,空出來的位置必須按照排隊的次序,排在最前面的人獲取,可以去喫飯。
否則,如果是非公平,如果有一箇中途來了,看到這剛好有一個空位,就可以直接坐下去,不管隊伍裏有沒有人。
(注意,插隊只有剛來的人才能插隊,要是剛來的時候沒機會插隊或者插隊失敗,排在隊伍裏之後,隊伍裏的人就沒有資格插隊了。這是 AQS 基礎,不知道的話可以去看我的AQS的源碼分析的博客)

這個小工具類可以做一個限流的裝置,假設你要限制對某一個資源的訪問。
可以指定同一時間最大的訪問量。
你也可以只指定爲 1,這樣就相當於一把互斥鎖。

你可以那我的小程序試一下,可以發現打印是兩行兩行間隔打印的

public class SemaphoreTest {
    public static void main(String[] args) {
        // 創建 2 的信號量,並且設置爲 公平
        Semaphore semaphore = new Semaphore(2, true);
        for(int i = 0; i < 12; i++) {
            new Thread(() -> {
                try {
                    // 獲取信號量,要是滿了就阻塞
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + ":我開始幹事了");
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 在finally釋放,和lock加鎖解鎖一樣
                    semaphore.release();
                }
            },"線程" + i).start();
        }
    }
}

Exchanger(交換機)應用場景

Exchanger(交換器),用於兩個線程之間交換數據。

假設有兩個人,他們一個人有 IPhoneX,一個人有 洛基亞,他們想要互換自己的物品;
於是,他們各自來到了一張桌子前,
甲往桌子上一拍,亮出自己的 iPhoneX,然後乙往桌子上一拍,拍出一個洛基亞,大喊:“成交!”
然後乙拿着 iPhoneX 走了,甲拿着洛基亞走了

這個方法,顯然必須得是阻塞的。
要是甲先來了,以還沒到,那他就必須等着,等到乙來了,才能夠互換產品

這些使用都是很簡單的,我就不寫很詳細的註釋了

public class ExchangerTest {
    public static void main(String[] args) {
        // 創建交換器
        Exchanger<String> exchanger = new Exchanger<>();
        new Thread(() -> {
            try {
                String str1 = "物品1";
                String str2 = exchanger.exchange(str1);
                System.out.println("線程1:我獲得了" + str2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                String str2 = "物品2";
                String str1 = exchanger.exchange(str2);
                System.out.println("線程2:我獲得了" + str1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

CountDownLatch 源碼

如果你懂 AQS 的話,你只要看到這一個內部類裏的兩個方法的名字:tryAcquireShared、tryReleaseShared,你應該就能明白它的實現原理了。

很明顯,這裏是重寫了 AQS 的兩個關於 共享鎖 的方法。
所以,CountDownLatch 實際上,就是一把共享鎖。(只不過它的共享鎖的持有數量是固定的)

我們只看這個內部類就應該能明白它的原理:

  1. 首先,構造方法,初始化 state 狀態值
  2. 很多線程調用 await,應該就是調用 tryAcquireShared 嘗試獲取共享鎖
  3. 調用 計數器-1 countDown 方法,就調用 tryReleaseShared 將 state -1
  4. 等到 state 爲 0,那麼所有等待的線程就能獲取共享鎖,向後執行
// 內部類 繼承AQS
private static final class Sync extends AbstractQueuedSynchronizer {
    // 構造方法,就是設置 state 初始值
    Sync(int count) {
        setState(count);
    }
    // 包裝了一層方法,獲取 state 值
    int getCount() {
        return getState();
    }
    // 嘗試獲取共享鎖,在 stat爲0 的情況下才能獲取
    // 很顯然符合 CountDownLatch 的應用場景
    // 一堆線程在等待,然後把 state 最終減爲 0,就能獲取到了
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }
    // 這個方法肯定是用來把 state-1 的
    protected boolean tryReleaseShared(int releases) {
        // 熟悉的死循環+CAS
        for (;;) {
            int c = getState();
            // 直到 state爲0 了,就不能再減了
            if (c == 0)
                return false;
            int nextc = c - 1;
            // CAS 把 state-1,如果失敗了,循環下一次
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}

下面我從源碼中找出對應代碼,驗證上面的猜想

構造方法,新建 Sync 對象,並對其賦予初始值
state 值在新建對象時就初始化爲 我們指定的數值

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

await 方法,就是調用 acquireSharedInterruptibly,用來獲取共享鎖
(AQS 的 acquireSharedInterruptibly 裏封裝了 tryAcquireShared 的基本方法)
上面的 重寫的 tryAcquireShared 表示在 state=0 時能夠獲取到鎖
所以完全符合預期

public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

帶超時的方法也是如此,直接調用了 AQS 的 tryAcquireSharedNanos 方法

public boolean await(long timeout, TimeUnit unit)
    throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

countDown 方法也是調用 AQS 方法,將 state-1

public void countDown() {
    sync.releaseShared(1);
}

CyclicBarrier 源碼

像 CyclicBarrier 這樣的工具類已經是對 AQS 的一種使用了,所以它的原理,我僅做大致講解,而不會去扣裏面的每一個細節。

其實應用要是比較簡單的。
最基本的是用了 ReentrantLock,來一個一個排隊進入柵欄前等待,防止一堆人一下子全部衝到了柵欄前,這樣子開柵欄的時候,就無法指定準確的人數
(因爲全部都擠過來了,不知道該放誰過去,難不成再重新排一下隊?這肯定不合理)

還有,爲了將先後到來的人羣區分開,於是採用了分代的方式。
當人數齊了之後,在釋放前更改下一代,這時,後面來的人就會被歸爲下一代,這樣就將前後的人羣區分了開來。

其次,這裏面線程的等待與喚醒,實際上也就是用的 Condition。
在 Lock 加鎖後,在裏面 await 釋放鎖並等待,然後人數齊了,最後一個人會用 condition 的 signalAll 方法將他們喚醒。

這裏面的 Condition 就是那個 trip 變量,在 breakBarrier 方法中則就是用了 trip.signalAll()

private int dowait(boolean timed, long nanos)
    throws InterruptedException, BrokenBarrierException,
           TimeoutException {
    // 比如說4個人爲1組,但是如果6個人同時來了,
    // 爲了避免6個人擠到一組,所以需要加鎖(一個一個進)
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 這個Generation是用來分代的
        // (就是前幾個一起出發的是一代,後幾個又是一代)
        // 區分開是爲了避免後一代的線程,誤和前一代一起釋放
        final Generation g = generation;
        // 如果線程跑到了前一代,那就會報錯
        // 因爲來的時候,必須是一個新的柵欄去攔住它,而不能是前一代的已經被釋放的
        if (g.broken)
            throw new BrokenBarrierException();
        // 打斷後直接釋放
        if (Thread.interrupted()) {
            breakBarrier();
            throw new InterruptedException();
        }
        // 這裏纔是重點
        // 首先,index會是要攔住的人數
        // 來一個人攔起來,然後把計數 -1
        int index = --count;
        // 這樣等到計數爲0的時候,說明人齊了
        // 就全部釋放
        if (index == 0) {
            boolean ranAction = false;
            try {
                // 記得構造方法可以傳入一個可執行方法
                // 這時,釋放前,就會去執行這個方法
                final Runnable command = barrierCommand;
                if (command != null)
                    command.run(); // 執行
                ranAction = true;
                // 這時候就會切換到下一代
                nextGeneration();
                return 0;
            } finally {
                // finally保證一定釋放柵欄
                if (!ranAction)
                    breakBarrier();
            }
        }
        // 如果還沒有到齊,就會執行到這裏
        // 死循環,防止異常甦醒而退出
        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 {
                    // We're about to finish waiting even if we had not
                    // been interrupted, so this interrupt is deemed to
                    // "belong" to subsequent execution.
                    Thread.currentThread().interrupt();
                }
            }
            if (g.broken)
                throw new BrokenBarrierException();
            // 因爲最後一個人來了之後,在釋放這一代前已經換成了下一代
            // 所以這時候他們醒來會發現已經不是這一代了
            if (g != generation)
                // 就會return結束
                return index;
            if (timed && nanos <= 0L) {
                breakBarrier();
                throw new TimeoutException();
            }
        }
    } finally {
        lock.unlock();
    }
}

Semaphore 源碼

可以看到本質也是 AQS,所以我們只需要看一下 AQS 實現類即可

根據 nonfairTryAcquireShared、tryReleaseShared 方法,我們發現
在 tryAcquire 的方法中用 CAS 對 state 進行減的操作,
在 tryRelease 的方法中,對 state 進行加的操作。

所以我們可以推斷出:
這個 Semaphore(信號量)本質就是一個共享鎖
這個 state,應該是在構造方法初始化時,傳入的參數,即最大共享數。
然後,每一個線程來加鎖,就用 CAS,把 state -1,如果 state 爲 0 了,就無法再加鎖,其他線程就會阻塞
而解鎖,就是用 CAS,將 state +1,這時候,別的線程又能夠加鎖

abstract static class Sync extends AbstractQueuedSynchronizer {
    // 構造方法,賦予 state 初始值
    Sync(int permits) {
        setState(permits);
    }
    // 封裝一層 用於獲取 state 值
    final int getPermits() {
        return getState();
    }
    // 非公平的用於加鎖方法
    final int nonfairTryAcquireShared(int acquires) {
        // 熟悉的死循環 + CAS
        for (;;) {
            int available = getState();
            int remaining = available - acquires;
            // 當減到 0 時,就沒法減了
            if (remaining < 0 ||
                // CAS 修改值
                compareAndSetState(available, remaining))
                return remaining;
        }
    }
    // 釋放鎖方法
    protected final boolean tryReleaseShared(int releases) {
        // 熟悉的死循環 + CAS
        for (;;) {
            int current = getState();
            int next = current + releases;
            if (next < current)
                throw new Error("Maximum permit count exceeded");
            // CAS
            if (compareAndSetState(current, next))
                return true;
        }
    }
    // 省略其它代碼
}

我們看構造參數,其中不止有一個 int 值用於賦予 state 值
還有一個 boolean 參數用來指定公平還是非公平

public Semaphore(int permits, boolean fair) {
    // 和 ReentrantLock 的原理一樣
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

這時我們只需要看一下公平的加鎖方法,就知道兩個的差別在哪裏

我們只要看 for 循環的開頭的方法,就是 hasQueuedPredecessors,
記不記得,當初看 ReentrantLock 的時候,公平鎖的加鎖方法中,也是出現了這個方法:
(在加鎖前判斷要不排隊,如果隊伍中有人,那必須乖乖排在後面,不能插隊,因此實現公平)

static final class FairSync extends Sync {
    // ...省略
    // 公平加鎖方法
    protected int tryAcquireShared(int acquires) {
        for (;;) {
            // 公平加鎖的方法唯一不同的,
            // 就是在 CAS 加鎖之前,先判斷一下要不要排隊
            // (和 ReentrantLock 公平加鎖方法一樣)
            if (hasQueuedPredecessors())
                return -1;
            // 以下代碼和非公平加鎖相同(你可以去和上面的對比)
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章