面經手冊 · 第18篇《AQS 共享鎖,Semaphore、CountDownLatch,聽說資料庫連線池可以用到!》

小傅哥發表於2020-11-19


作者:小傅哥
部落格:https://bugstack.cn
Github:https://github.com/fuzhengwei/CodeGuide/wiki

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

學Java怎麼能,突飛猛進的成長?

是不是你看見過的突飛猛進都是別人,但自己卻很難!

其實並沒有一天的突飛猛進,也沒有一口吃出來的胖子。有得更多的時候日積月累、不斷沉澱,最後才能爆發、破局!

舉個簡單的例子,如果你大學畢業時候已經寫了40萬行程式碼,還找不到工作嗎?但40萬行平均到每天並不會很多,重要的是持之以恆的堅持。

二、面試題

謝飛機,小記! 東風吹、戰鼓擂,不加班、誰怕誰!哈哈哈,找我大哥去。

謝飛機:喂,大哥。我女友面試卡住了,強人難,鎖我也不會!

面試官:你不應該不會呀,問你一個,基於 AQS 實現的鎖都有哪些?

謝飛機:嗯,有 ReentrantLock...

面試官:還有呢?

謝飛機:好像想不起來了,sync也不是!

面試官:哎,學點漏點,不思考、不總結、不記錄。你這樣人家面試你就沒法聊了,最起碼你要有點深度。

謝飛機:嘿嘿,記住了。來我家吃火鍋吧,細聊。

三、共享鎖 和 AQS

1. 基於 AQS 實現的鎖有哪些?

圖 18-1 基於 AQS 實現的鎖

AQS(AbstractQueuedSynchronizer),是 Java 併發包中非常重要的一個類,大部分鎖的實現也是基於 AQS 實現的,包括:

  • ReentrantLock,可重入鎖。這個是我們最開始介紹的鎖,也是最常用的鎖。通常會與 synchronized 做比較使用。
  • ReentrantReadWriteLock,讀寫鎖。讀鎖是共享鎖、寫鎖是獨佔鎖。
  • Semaphore,訊號量鎖。主要用於控制流量,比如:資料庫連線池給你分配10個連結,那麼讓你來一個連一個,連到10個還沒有人釋放,那你就等等。
  • CountDownLatch,閉鎖。Latch 門閂的意思,比如:說四個人一個漂流艇,坐滿了就推下水。

這一章節我們主要來介紹 Semaphore ,訊號量鎖的實現,其實也就是介紹一個關於共享鎖的使用和原始碼分析。

2. Semaphore 共享鎖使用

Semaphore semaphore = new Semaphore(2, false); // 建構函式入參,permits:訊號量、fair:公平鎖/非公平鎖
for (int i = 0; i < 8; i++) {
    new Thread(() -> {
        try {
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + "蹲坑");
            Thread.sleep(1000L);
        } catch (InterruptedException ignore) {
        } finally {
            semaphore.release();
        }
    }, "蹲坑編號:" + i).start();
}

這裡我們模擬了一個在高速服務區,廁所排隊蹲坑的場景。由於坑位有限,為了避免造成擁擠和踩踏,保安人員在門口攔著,感覺差不多,一次釋放兩個進去,一直到都釋放。你也可以想成早上坐地鐵上班,或者旺季去公園,都是一批一批的放行

測試結果

蹲坑編號:0蹲坑
蹲坑編號:1蹲坑

蹲坑編號:2蹲坑
蹲坑編號:3蹲坑

蹲坑編號:4蹲坑
蹲坑編號:5蹲坑

蹲坑編號:6蹲坑
蹲坑編號:7蹲坑

Process finished with exit code 0
  • Semaphore 的建構函式可以傳遞是公平鎖還是非公平鎖,最終的測試結果也不同,可以自行嘗試。
  • 測試執行時,會先輸出0坑、1坑之後2坑、3坑...,每次都是這樣兩個,兩個的釋放。這就是 Semaphore 訊號量鎖的作用。

3. Semaphore 原始碼分析

3.1 建構函式

public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

permits:n. 許可證,特許證(尤指限期的)

預設情況下只需要傳入 permits 許可證數量即可,也就是一次允許放行幾個執行緒。建構函式會建立非公平鎖。如果你需要使用 Semaphore 共享鎖中的公平鎖,那麼可以傳入第二個建構函式的引數 fair = false/true。true:FairSync,公平鎖。在我們前面的章節已經介紹了公平鎖相關內容和實現,以及CLH、MCS 《公平鎖介紹》

初始許可證數量

FairSync/NonfairSync(int permits) {
    super(permits);
}

Sync(int permits) {
    setState(permits);
}

protected final void setState(int newState) {
    state = newState;
}

在建構函式初始化的時候,無論是公平鎖還是非公平鎖,都會設定 AQS 中 state 數量值。這個值也就是為了下文中可以獲取的訊號量扣減和增加的值。

3.2 acquire 獲取訊號量

方法 描述
semaphore.acquire() 一次獲取一個訊號量,響應中斷
semaphore.acquire(2) 一次獲取n個訊號量,響應中斷(一次佔2個坑)
semaphore.acquireUninterruptibly() 一次獲取一個訊號量,不響應中斷
semaphore.acquireUninterruptibly(2) 一次獲取n個訊號量,不響應中斷
  • 其實獲取訊號量的這四個方法,主要就是,一次獲取幾個和是否響應中斷的組合。
  • semaphore.acquire(),原始碼中實際呼叫的方法是, sync.acquireSharedInterruptibly(1)。也就是相應中斷,一次只佔一個坑。
  • semaphore.acquire(2),同理這個就是一次要佔兩個名額,也就是許可證。生活中的場景就是我給我朋友排的對,她來了,進來吧。

3.3 acquire 釋放訊號量

方法 描述
semaphore.release() 一次釋放一個訊號量
semaphore.release(2) 一次獲取n個訊號量

有獲取就得有釋放,獲取了幾個訊號量就要釋放幾個訊號量。當然你可以嘗試一下,獲取訊號量 semaphore.acquire(2) 兩個,釋放訊號量 semaphore.release(1),看看執行效果

3.4 公平鎖實現

訊號量獲取過程,一直到公平鎖實現。semaphore.acquire -> sync.acquireSharedInterruptibly(permits) -> tryAcquireShared(arg)

semaphore.acquire(1);

public void acquire(int permits) throws InterruptedException {
    if (permits < 0) throw new IllegalArgumentException();
    sync.acquireSharedInterruptibly(permits);
}

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

FairSync.tryAcquireShared

protected int tryAcquireShared(int acquires) {
    for (;;) {
        if (hasQueuedPredecessors())
            return -1;
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}
  • hasQueuedPredecessors,公平鎖的主要實現邏輯都在於這個方法的使用。它的目的就是判斷有執行緒排在自己前面沒,以及把執行緒新增到佇列中的邏輯實現。在前面我們介紹過CLH等實現,可以往前一章節閱讀
  • for (;;),是一個自旋的過程,通過 CAS 來設定 state 偏移量對應值。這樣就可以避免多執行緒下競爭獲取訊號量衝突。
  • getState(),在建構函式中已經初始化 state 值,在這裡獲取訊號量時就是使用 CAS 不斷的扣減。
  • 另外需要注意,共享鎖和獨佔鎖在這裡是有區別的,獨佔鎖直接返回true/false,共享鎖返回的是int值。
    • 如果該值小於0,則當前執行緒獲取共享鎖失敗。
    • 如果該值大於0,則當前執行緒獲取共享鎖成功,並且接下來其他執行緒嘗試獲取共享鎖的行為很可能成功。
    • 如果該值等於0,則當前執行緒獲取共享鎖成功,但是接下來其他執行緒嘗試獲取共享鎖的行為會失敗。

3.5 非公平鎖實現

NonfairSync.nonfairTryAcquireShared

protected int tryAcquireShared(int acquires) {
    return nonfairTryAcquireShared(acquires);
}

final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}
  • 有了公平鎖的實現,非公平鎖的理解就比較簡單了,只是拿去了 if (hasQueuedPredecessors()) 的判斷操作。
  • 其他的邏輯實現都和公平鎖一致。

3.6 獲取訊號量失敗,加入同步等待佇列

在公平鎖和非公平鎖的實現中,我們已經看到正常獲取訊號量的邏輯。那麼如果此時不能正常獲取訊號量呢?其實這部分執行緒就需要加入到同步佇列。

doAcquireSharedInterruptibly

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

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; // help GC
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
  • 首先 doAcquireSharedInterruptibly 方法來自 AQS 的內部方法,與我們在學習競爭鎖時有部分知識點相同,但也有一些差異。比如:addWaiter(Node.SHARED)tryAcquireShared,我們主要介紹下這內容。
  • Node.SHARED,其實沒有特殊含義,它只是一個標記作用,用於判斷是否共享。final boolean isShared() { return nextWaiter == SHARED; }
  • tryAcquireShared,主要是來自 Semaphore 共享鎖中公平鎖和非公平鎖的實現。用來獲取同步狀態。
  • setHeadAndPropagate(node, r),如果r > 0,同步成功後則將當前執行緒結點設定為頭結點,同時 helpGC,p.next = null,斷鏈操作。
  • shouldParkAfterFailedAcquire(p, node),調整同步佇列中 node 結點的狀態,並判斷是否應該被掛起。這在我們之前關於鎖的文章中已經介紹。
  • parkAndCheckInterrupt(),判斷是否需要被中斷,如果中斷直接丟擲異常,當前結點請求也就結束。
  • cancelAcquire(node),取消該節點的執行緒請求。

4. CountDownLatch 共享鎖使用

CountDownLatch 也是共享鎖的一種型別,之所以在這裡體現下,是因為它和 Semaphore 共享鎖,既相似有不同。

CountDownLatch 更多體現的組團一波的思想,同樣是控制人數,但是需要夠一窩。比如:我們說過的4個人一起上皮划艇、兩個人一起上蹺蹺板、2個人一起蹲坑我沒見過,這樣的方式就是門閂 CountDownLatch 鎖的思想。

public static void main(String[] args) throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(10);
    ExecutorService exec = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 10; i++) {
        exec.execute(() -> {
            try {
                int millis = new Random().nextInt(10000);
                System.out.println("等待遊客上船,耗時:" + millis + "(millis)");
                Thread.sleep(millis);
            } catch (Exception ignore) {
            } finally {
                latch.countDown(); // 完事一個扣減一個名額
            }
        });
    }
    // 等待遊客
    latch.await();
    System.out.println("船長急躁了,開船!");
    // 關閉執行緒池
    exec.shutdown();
}
  • 這一個公園遊船的場景案例,等待10個乘客上傳,他們比較墨跡。
  • 上一個扣減一個 latch.countDown()
  • 等待遊客都上船 latch.await()
  • 最後船長開船!!急躁了

測試結果

等待遊客上船,耗時:6689(millis)
等待遊客上船,耗時:2303(millis)
等待遊客上船,耗時:8208(millis)
等待遊客上船,耗時:435(millis)
等待遊客上船,耗時:9489(millis)
等待遊客上船,耗時:4937(millis)
等待遊客上船,耗時:2771(millis)
等待遊客上船,耗時:4823(millis)
等待遊客上船,耗時:1989(millis)
等待遊客上船,耗時:8506(millis)
船長急躁了,開船!

Process finished with exit code 0
  • 在你實際的測試中會發現,船長急躁了,開船!,會需要等待一段時間。
  • 這裡體現的就是門閂的思想,組隊、一波帶走。
  • CountDownLatch 的實現與 Semaphore 基本相同、細節略有差異,就不再做原始碼分析了。

四、總結

  • 在有了 AQS、CLH、MCS,等相關鎖的知識瞭解後,在學習其他知識點也相對容易。基本以上和前幾章節關於鎖的介紹,也是面試中容易問到的點。可能由於目前分散式開發較多,單機的多執行緒效能壓榨一般較少,但是對這部分知識的瞭解非常重要
  • 得益於Lee老爺子的操刀,併發包鎖的設計真的非常優秀。每一處的實現都可以說是精益求精,所以在學習的時候可以把小傅哥的文章當作拋磚,之後繼續深挖設計精髓,不斷深入。
  • 共享鎖的使用可能平時並不多,但如果你需要設計一款類似資料庫執行緒池的設計,那麼這樣的訊號量鎖的思想就非常重要了。所以在學習的時候也需要有技術遷移的能,不斷把這些知識複用到實際的業務開發中。

五、系列推薦

相關文章