老闆讓只懂Java基本語法的我,基於AQS實現一個鎖

閃客sun發表於2020-11-23

10 點整,我到了公司,又成為全組最後一個到的員工。

正準備刷刷手機摸摸魚,看見老闆神祕兮兮地走了過來。

老闆:閃客呀,你寫個工具,基於 AQS 實現一個鎖,給我們們組其他開發用

:哦好的

老闆:你多久能搞好?

:就是一個工具類是吧,嗯今天下午就給你吧

老闆:嗯,那你抓緊時間搞吧,大家都等著用呢

:哦好的


先寫個框架

關於鎖,我還算有一個模糊的認識的,要讓使用者可以獲取鎖、釋放鎖,來實現多執行緒訪問時的安全性。於是我趕緊先把一個框架寫了出來。

// 給帥氣老闆用的鎖
public class FlashLock {
    // 釋放鎖
    public void lock() {}
    // 釋放鎖
    public void unlock() {}
}

工具類已經完成一半了,一想到全組的開發們下午就會這樣用到我的工具,我不禁笑出了聲音。

FlashLock flashLock = new FlashLock();

public void doSomeThing() {
    // 獲取鎖,表示同一時間只允許一個執行緒執行這個方法
    flashLock.lock();
    try {
        ...
    } finally {
        // 優雅地在 finally 裡釋放鎖
        flashLock.unlock();
    }
}

隨著同事們投來異樣的眼光,我回過神來。繼續想,我怎麼在這倆方法裡實現這種鎖的效果呢?腦子一片空白呀,誒不過老闆剛剛說要基於 AQS,那肯定這個東西可以給我提供一些方便吧,於是我在百度百科搜了一下什麼是 AQS

百度百科尚未收錄詞條 “AQS”

這老闆水平也太次了,給我推薦個百科上都搜不到的東西... 只能搜搜百度了

額!這看起來還是個 Java 面試的重點呢!真是錯怪老闆了。

我點了其中一篇,瞭解到 AQS 的全稱叫 AbstractQueuedSynchronizer(抽象的佇列式同步器),是一個 JDK 原始碼中的一個

嗨,搞了半天只是個類而已嘛,對我這種原始碼在手天下我有的神級碼農,還看什麼文章呀,我迅速開啟了 JDK1.8 原始碼,找到了這個類。

我的天,一共 2316 行!我趕緊把所有註釋都去掉,發現還有 914 行。

由於下午就要交稿,我打消了不看註釋硬啃原始碼的念頭,開始從頭看起了註釋...

2:使用 AQS 實現最簡單的鎖

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state. Subclasses must define the protected methods that change this state, and which define what that state means in terms of this object being acquired or released.

第一句話說這是個框架,之後說這個類是基於一個原子變數,這說的都是原理我先不管。

後面又說子類(Subclasses)必須實現一些改變狀態(change this state)和獲取釋放鎖(acquired or released)的方法。

哦!看來我需要用一個子類繼承它,然後實現它指定的一些方法,其他的事情這個父類都會幫我做好的。敏銳的我馬上察覺到,這用的模板方法這種設計模式,這是我最喜歡的設計模式了,因為只需要讀懂需要讓子類實現的模板方法的含義,即可以很好地使用這個類的強大功能。

於是我趕緊去找,有哪些這樣的模板方法,需要子類去實現,果然在註釋中發現了這樣一段話。

 * To use this class as the basis of a synchronizer, redefine the
 * following methods
 *
 * <li> {@link #tryAcquire}
 * <li> {@link #tryRelease}
 * <li> {@link #tryAcquireShared}
 * <li> {@link #tryReleaseShared}
 * <li> {@link #isHeldExclusively}
 * </ul>

在原始碼中找到這幾個類

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

一看清一色都是丟擲異常我就放心了,這正是留給我們子類實現的模板方法呀,接下來就是我寫個類實現他們就好咯,可是怎麼寫...

正想去百度,突然發現註釋中居然給出了一段 基於 AQS 的實現小 demo,還挺長,我理解了它的意思,並且把我看不懂的都去掉了,寫出了很簡潔的鎖

public class FlashLock {

    // 獲取鎖(這回填好骨肉了)
    public void lock() {
        sync.acquire(1);
    }
    // 釋放鎖
    public void unlock() {
        sync.release(1);
    }

    private final Sync sync = new Sync();

    // 這個內部類就是繼承並實現了 AQS 但我這裡只先實現兩個方法
    private static class Sync extends AbstractQueuedSynchronizer {

        @Override
        public boolean tryAcquire(int acquires) {
            // CAS 方式嘗試獲取鎖,成功返回true,失敗返回false
            if (compareAndSetState(0, 1)) {
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int releases) {
            // 釋放鎖,這裡為什麼不像上面那樣也是 CAS 操作呢?請讀者思考
            setState(0);
            return true;
        }
    }
}

lock 和 unlock 方法都實現了,我趕緊寫個經典的測試程式碼

// 可能發生執行緒安全問題的共享變數
private static long count = 0;

// 兩個執行緒併發對 count++
public static void main(String[] args) throws Exception {
    // 建立兩個執行緒,執行add()操作
    Thread th1 = new Thread(()-> add());
    Thread th2 = new Thread(()-> add());
    // 啟動兩個執行緒
    th1.start();
    th2.start();
    // 等待兩個執行緒執行結束
    th1.join();
    th2.join();
    // 這裡應該是 20000 就對了,說明鎖生效了
    System.out.println(count);
}

// 我畫了一上午寫出來的鎖,哈哈
private static ExampleLock exampleLock = new ExampleLock();

// 迴圈 count++,進行 10000 次
private static void add() {
    exampleLock.lock();
    for (int i = 0; i < 10000; i++) {
        count++;
    }
    add2();
    // 沒啥異常,我就直接釋放鎖了
    exampleLock.unlock();
}

測了好幾次,發現都是 20000,哈哈,大功告成,我趕緊在大群裡 @所有人,告訴大家我寫的新工具。同事和老闆紛紛給我點了贊。

我又忍不住笑出了聲音。走出了公司,準備找個地方吃午飯。

不得不研究下 AQS 的原理

下午兩點整,我又成為公司最後一個午睡起床的人...

小宇:閃客,你的工具類確實好用,而且原始碼也很簡潔

:哈哈,大家喜歡用就好

小宇:不過我有個問題,就是我用你的這個鎖工具,有的執行緒總是搶不到鎖,有的執行緒總是能搶到鎖。雖說執行緒們搶鎖確實看命,但能不能加入一種設計,讓各個執行緒機會均等些,起碼不要出現某幾個執行緒總是特倒黴搶不到鎖的情況呢?

:這怎麼可能,我就是寫個鎖工具,還能影響到人家 CPU 和作業系統層面的機制?

小宇:你想想吧,作為公司最帥的程式猿,我相信你哦

:額這...

我這人最禁不住妹子誇獎,趕緊開啟電腦螢幕,盯著我的獲取鎖的程式碼看

@Override
public boolean tryAcquire(int acquires) {
    // 一上來就 CAS 搶鎖
    if (compareAndSetState(0, acquires)) {
        return true;
    }
    return false;
}

我發現這段程式碼中在嘗試獲取鎖時,一上來就 CAS 搶鎖,一旦成功就返回了 true。那我這裡是否能加入某些機制,使這些執行緒不要一有機會就開始直接開始搶鎖,而是先考慮一下其他執行緒的感受再決定是否搶鎖呢?

我發現此時不得不研究一下 AQS 的內部實現邏輯了,也就是原理,看看能不能得到一些思路。

我看 AQS 雖然方法一大堆,但屬性一共就四個(有一個是內部類 Node)

public abstract class AbstractQueuedSynchronizer {
    private transient volatile Node head;
    private transient volatile Node tail;
    private volatile int state;
    static final class Node {}
}

static final class Node {
    // ... 省略一些暫不關注的
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
}

結合最開始看那段對 AQS 高度概括的註釋

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state.

不難猜到這裡的內部類 Node 以及其型別的變數 headtail 就表示 AQS 內部的一個等待佇列,而剩下的 state 變數就用來表示鎖的狀態

等待佇列應該就是執行緒獲取鎖失敗時,需要臨時存放的一個地方,用來等待被喚醒並嘗試獲取鎖。再看 Node 的屬性我們知道,Node 存放了當前執行緒的指標 thread,也即可以表示當前執行緒並對其進行某些操作,prev 和 next 說明它構成了一個雙向連結串列,也就是為某些需要得到前驅後繼節點的演算法提供便利。

太好了,僅僅看一些屬性和一段註釋,就得到了一個關於 AQS 大致原理的猜測,看起來還挺靠譜,我趕緊把它畫成幾張圖來加深理解。(由於這裡非常重要,就不再賣關子了,直接畫出最正確的圖理解,但不會過於深入細節)

以下的圖是 AQS 最為核心的幾行程式碼的直觀理解過程,請大家仔細品味

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

看完圖沒太消化的,這裡還有一次機會,我來捋一捋。

首先初始狀態 AQS 的 state=0,表示沒有執行緒持有鎖。head 和 tail 也都為空,表示此時等待佇列裡也沒有執行緒。

這時第一個執行緒(執行緒1)來了,沒有任何執行緒和它搶,直接拿到了鎖(state=1)

然後執行緒2也來了,假設此時執行緒1沒有釋放鎖,那麼執行緒2搶鎖失敗(執行你自己寫的 tryAcquire)。失敗後,剩下的事都是 AQS 幫你做的,首先加入等待佇列的隊尾 addWaiter​(此時佇列為空,所以要先初始化一個佔位的頭結點),然後在迴圈裡嘗試獲取鎖 acquireQueue(注意這裡面有相當多的細節,首先前驅結點是頭結點的才能嘗試獲取鎖,即排在隊頭的才有機會。再有迴圈裡獲取鎖並不是一直 CAS,而是通過一個標誌控制了次數,使得 CAS 兩次都失敗後就將執行緒掛起 park,之後等待持有鎖的執行緒釋放鎖之後再喚醒 unpark。其餘各種細節,希望讀者閱讀原始碼,不是一句兩句說清楚的)。

然後執行緒3也來了​,經歷了和執行緒2一樣的經歷,只不過它的前驅結點不是頭結點,因此還不能有機會嘗試獲取鎖,只有等執行緒2搶到了鎖並且出隊,自己的前驅結點變成了頭結點,才可以。

這時執行緒1終於釋放了鎖(state=0),與此同時找到佇列的頭結點進行喚醒 unpark。此時頭結點是執行緒2表示的 Node,因此對執行緒2進行了喚醒操作。如果此時執行緒2沒有被掛起,說明還在嘗試獲取鎖的過程中,那麼就嘗試好了。如果已經被掛起了,那麼喚醒執行緒2,使得執行緒2繼續不斷嘗試 CAS 獲取鎖,直到成功為止。

如此,迴圈往復... 你大概懂了麼?

嗯原理搞懂了,實現一個公平鎖

仔細看上面的倒數第二張圖。

好好好,你懶得往上翻,我給你粘過來。

原本在佇列中等待的執行緒 2,被執行緒 1 釋放鎖之後喚醒了,但它仍然需要搶鎖,而且有可能搶失敗

那如果每次這個執行緒 2 嘗試搶鎖時,都有其他新來的執行緒把鎖搶去,那執行緒 2 就一直得不到執行機會,而且排線上程 2 後面的等待執行緒,也都沒有機會執行。

導致有的執行緒一直得不到執行機會的,就是這個新進來的執行緒每次都不管有沒有人排隊,都直接上來就搶鎖導致的。

妥了,剛剛小宇提出的問題,我終於有了思路,就是讓新來的執行緒搶鎖時,先問一句,“有沒有人排隊呀?如果有人排隊那我先排到隊尾好了”。

@Override
public boolean tryAcquire(int acquires) {
    // 原有基礎上加上這個
    if (有執行緒在等待佇列中) {
        // 返回獲取鎖失敗,AQS會幫我把該執行緒放在等待佇列隊尾的
        return false;
    }
    if (compareAndSetState(0, 1)) {
        return true;
    }
    return false;
}

怎麼判斷是否有執行緒在等待佇列呢?機智的我覺得,AQS 這麼優秀的框架一定為上層提供了一個方法,不會讓我們深入到它實現的內部的,果然我找到了。

public final boolean hasQueuedPredecessors()

再經過優化結構後,最終的程式碼變成了這樣

@Override
public boolean tryAcquire(int acquires) {
    if (hasQueuedPredecessors() &&
            compareAndSetState(0, 1)) {
        return true;
    }
    return false;
}

哈哈,大功告成,趕緊去找小宇顯擺一下。

等等...

那我原來的那種實現方式就沒了,肯定有其他人找我質問,emmm,我兩種方式都暴露給大家吧,隨大家選。

我將原來的暴力搶鎖方式起了個名,叫非公平鎖,因為執行緒搶鎖不排隊,純看臉。按小宇需求實現的排隊獲取鎖,我叫它公平鎖,因為只要有執行緒在排隊,新來的就得乖乖去排隊,不能直接搶。

// 想要公平鎖,就傳 true 進來
public FlashLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

哈哈,有了高大上的名字和程式碼實現,我興高采烈去找小宇交差了。

老闆要求方法可以重入

晚上五點半,我正準備成為全組第一個去吃飯的人,突然老闆陰著臉跑了過來。

老闆:閃客,我用你這工具導致一個執行緒卡死了呀,一直獲取不到鎖

:嗯怎麼會呢?

老闆:程式碼發你了,你趕緊看看!

我開啟了鎖了屏的電腦,點開了老闆發來的程式碼

public void doSomeThing2() {
    flashLock.lock();
    doSomeThing2();
    flashLock.unlock();
}

public void doSomeThing2() {
    flashLock.lock();
    ...
    flashLock.unlock();
}

我恍然大悟,原來一個執行緒執行了一個方法,獲取了鎖,這個方法沒有結束,又呼叫了另一個需要鎖的方法,於是卡在這再也不走了。

這個原理很容易理解,但這似乎用起來確實不太友好,怪不得老闆那麼生氣。有沒有辦法,讓同一個執行緒持有鎖時,還能繼續獲取鎖(可重入),只有當不同執行緒才互斥呢?

我苦思冥想,感覺不對呀,現在 AQS 裡面的所有變數我都用到了,沒見哪個變數可以記錄當前執行緒呀。

哦對!AQS 本身還繼承了 AbstractOwnableSynchronizer 這個類!我很快在這個類裡面發現了這個屬性!

/**
 * The current owner of exclusive mode synchronization.
 */
private transient Thread exclusiveOwnerThread;

熟悉了之前的套路,我很快又找到了這兩個方法!

protected final void setExclusiveOwnerThread(Thread thread);
protected final Thread getExclusiveOwnerThread();

大功告成,此時我只要在一個執行緒發現鎖已經被佔用時,不直接放棄,而是再看一下佔用鎖的執行緒是不是正是我自己,就好了。有了前面的經驗,這次我直接寫出了最終的可重入的公平鎖程式碼。

@Override
public boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (hasQueuedPredecessors() && compareAndSetState(0, 1)) {
            // 拿到鎖記得記錄下持鎖執行緒是自己
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 看見鎖被佔了(state=0)也別放棄,看看是不是自己佔的
        setState(c + acquires);
        return true;
    }
    return false;
}

6. 下班!

我把這個最終版的鎖程式碼提交,霸氣地收拾東西下班了,今天真是收穫滿滿。

好啦故事講完了,如果你堅持讀到了這裡並且完全理解了上面的所有事情,那麼恭喜你,你已經掌握了 AQS 的核心原理以及基於它的一個經典的鎖實現 ReentrantLock 的幾乎全部知識點,AQS 的體系骨架算是被你不知不覺建立起來了,這兩個都是 Java 程式設計師面試必備的東西。

雖然這只是皮毛,但如果你是第一次接觸這兩個概念,那本篇文章的最大意義在於對他們有了一個三觀很正的第一印象。我希望 AQS 的給你的第一印象不是什麼抽象的佇列式同步器,而只是一個為了更方便實現各種鎖而提供的一個包含幾個模板方法的類而已,雖然並不準確,而且顯得很 low,但實則可能恰恰是說到了本質。

7. 繼續深入 AQS

我之後也會出關於 AQS 繼續深入的文章,不過下面的三篇系列文章你可以花上兩三個小時在電腦上看一下,真的非常非常非常給力。

另外,我也推薦你,用跟蹤原始碼或 debug 的方式,從頭到尾自己跟一遍下面三行程式碼,是幾乎 AQS 的全部核心邏輯,這個看懂了,其他的都是浮雲。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

最後呢,如果你這兩個都看不下去,關注低併發程式設計,同樣能得到有趣且深入的理解,哈哈哈。

相關文章