共享鎖重入次數怎麼記錄都不知道,誰敢給你漲薪(AQS源碼閱讀之讀寫鎖)

讀鎖重入次數怎麼分別保存?讀寫鎖的獲取數量如何原子性修改?

其實之前在學習 Lock 的時候,學得比較粗糙,我也相信很多人都知道,像 ReentrantLock,ReadWriteLock 都是基於 AQS,CAS 實現的。
通過狀態位(或者說標誌位)state 來 CAS 搶鎖,通過一個 AQS 鏈表隊列,來實現線程的排隊,LockSupport 來實現線程的阻塞與喚醒,通過模板方法設計模式,來對代碼進行封裝。
甚至,可以說基於這些思想,手寫一個簡化版 lock。

確實,如果能理解、學習到這些知識,已經足夠去面試一些中小企業。只不過,相信很多人都有一顆積極向上的心,想要更好的就業平臺,那麼,這些知識還是比較 表層 的。
很多大廠的面試官只要一問,就知道,你是隨便背了幾篇博客,還是真正從 源碼 角度去研究過。這些源碼中的每一處細節,都是寫作者的 思維的結晶,體現出 高度嚴謹、縝密 的編程思想。

學習忌浮躁

下面舉幾個 ReentrantReadWriteLock 的幾道題目,你來簡單檢測一下自己的學習情況。

  1. 讀鎖寫鎖,被獲取的次數怎麼保存(並且如何保證原子性)
  2. 寫鎖是公平鎖還是非公平鎖
  3. 讀鎖面對寫鎖是否公平
  4. 讀鎖多線程共享,如何記錄每個線程的重入次數
  5. 多線程 交替 重入讀鎖(不存在線程競爭),是否會產生性能影響
  6. 共享鎖的自旋機會有哪些
  7. 讀鎖因爲寫鎖阻塞後,是如何被喚醒的

下面我會基於源碼,對 ReentrantReadWriteLock 進行分析。不過在此之前,我需要你閱讀過我的上一篇文章。
99%的人不知道AQS還有這種操作(源碼分析ReentrantLock的實現和優秀設計)(寫這麼詳細不信你還看不懂)

在上一篇文章中,我從源碼,並且畫圖,十分詳細地 講解了很多的 AQS 的設計理念,方法實現過程。
因爲 AQS 是一個抽象類,而 lock 不過是用一個 內部類 重寫需要的方法完成自己的實現,所以很多地方都是互通的。

而從 ReentrantLock 開始,學習 AQS,是一個不錯的入門,也是對基礎的紮根。
上一篇文章我寫得很詳細,而這一篇文章則會有涉及很多重複的知識點,我都不再去寫。
所以學完上一篇文章的知識,以此爲基礎,學習這裏的知識便會很輕鬆。

你學習完上一篇 ReentrantLock 之後,再來看這一偏文章,就會發現許多相通之處。
此時,基於 互斥鎖 和 共享鎖,你便可以將內部知識連接起來,便融會貫通。
從此,再去學習其他有關 AQS 的代碼時,便能心領神會,入眼即已瞭然。

ReadWriteLock 大致原理

要實現讀鎖共享,寫鎖互斥,那麼就分別需要兩個值來記錄鎖的重入次數

如果寫鎖的重入 >0,那麼就應該阻塞住所有的其他搶鎖線程
自己可以重入寫鎖,同時,也可以重入讀鎖

如果讀鎖的獲取次數大於 0,那麼此時一定沒有寫鎖被獲取,
要獲取寫鎖的必須進入隊列,在隊列中阻塞

源碼如何保證讀寫分別 CAS 搶鎖的原子性

兩個 int 會出現的問題

因爲 CAS 只能對一個 int 操作,沒有辦法同時對 兩個 int 操作
所以,如果用一個 state 記錄讀鎖數,一個 state 記錄寫鎖數就會出現問題(不加鎖,沒有辦法對兩個 int 進行原子操作)

在 JDK 源碼中,我們發現這是用一個 int 來實現的(所以可以用 CAS 保證原子)
可是一個 int 如何表示兩個值??

Doug Lee 很聰明,他把 32 位的 int,用前 16 位表示 共享鎖,後 16 位表示 互斥鎖。

從源碼分析互斥鎖的 state

獲取互斥鎖的 state

// 獲取互斥鎖的 state
static int exclusiveCount(int c) { 
    // 將整個int數 和 後16位都是1的二進制數做與運算
    return c & EXCLUSIVE_MASK; 
}

就像這樣,做與運算的這個數,二進制表示的後 16 位全部是1,
00000000000000001111111111111111
那麼運算結果只保留後 16 位的值,而前 16 位全部爲 0,就相當於互斥鎖的 state

對於修改,我們只需要對 CAS 的結果值 加上我們期待的值便可

compareAndSetState(c, c + acquires)

從源碼分析共享鎖的 state

獲取共享鎖的 state

// 獲取共享鎖的 state
static int sharedCount(int c)    { 
    // 將整個in整數無符號右移16位
    return c >>> SHARED_SHIFT; 
}

將整個整數無符號右移 16 位,那麼最右邊的數已經全部被移除界外沒有了,而前 16 位則正好補在後 16 位,前面全部補 0,那麼這樣就能得到前 16 位的數字

設置共享鎖的 state

compareAndSetState(c, c + SHARED_UNIT))

其中的 SHARED_UNIT = 1 << 16
用二進制表示就是:
0000000000000001 0000000000000000
後面 16位 都是0,就不會影響到共享鎖的 state,每次加這個值,都相當於在前 16位 +1

讀鎖先來加鎖

首先調 AQS 的獲取共享鎖的模板方法

首先調用了 sync 的 acquireShared() 方法

我們都知道 AQS 是一個 抽象類,是基於 互斥鎖共享鎖封裝

之前學習的 ReentrantLock 的加鎖方法,就是去調用 AQS 獲取互斥鎖的方法 acquire(),只不過對公平和非公平的 tryAcquire() 進行了不同的重寫

而此處的 ReadLock 讀鎖屬於共享鎖,所以其中的 lock 調用了 AQS 的獲取共享鎖的方法 acquireShared(),所以很容易聯想到,此處的 ReadLock 一定對 tryAcquireShared() 方法進行了重寫

// 首先調用sync的模板方法
// sync集成了AQS,但不對該方法重寫
// 所以本質上調用了AQS的 acquireShared 獲取共享鎖
public void lock() {
    sync.acquireShared(1);
}

我們點進這個方法
首先嚐試加鎖,此時由於是第一次來加鎖,之前還沒有任何線程來過,所以一定會加鎖成功
於是 tryAcquireShared 返回當前讀鎖的數目,也就是隻有當前線程,返回 1。

// 獲取共享鎖
public final void acquireShared(int arg) {
    // if 中嘗試獲取共享鎖
    // 此時一定獲取到,返回 1
    if (tryAcquireShared(arg) < 0)
        // 這裏就不執行
        doAcquireShared(arg);
}

tryAcquireShared 嘗試獲取共享鎖(讀鎖實現的方法)

tryAcquireShared 方法是 AQS 沒有實現的,此時由讀鎖進行實現

首先要獲取 當前的線程 和 state 值
然後判斷是否被寫鎖佔有(我們知道寫鎖是互斥鎖,會阻塞所有其他需要搶鎖的線程),如果被寫鎖佔有了,那麼此時讀鎖一定會獲取失敗,直接返回 -1 表示失敗

如果沒有讀鎖獲取,那麼繼續執行,獲取讀鎖的佔有數量
但是,在嘗試 CAS 加鎖之前,它先判斷自己應不應該阻塞 !!!

有沒有覺得很奇怪,這個時候沒有寫鎖獲取鎖,按道理讀鎖應該去搶鎖纔對,但是,
它先判斷要不要阻塞,
這是什麼神仙邏輯????
你有沒有想過

這其實跟我們之前看 ReentrantLock 的時候碰到的邏輯類似
在 ReentrantLock 中,公平鎖 在 CAS 搶鎖前,要先看一下隊列裏有沒有人,不然就不搶鎖

這裏,讀鎖,首先 CAS 搶鎖之前,我們應該看一下隊列裏排隊的第一個是不是寫鎖,
如果下一個就是寫鎖了,那麼就不該去搶鎖

讀鎖,搶來搶去無所謂,因爲可以共享,第一次搶完了,沒搶到,根本不用等搶到鎖的線程釋放,就可以再去搶鎖,把持有數 加上一
但是寫鎖不一樣,它會阻塞,所以不能在寫鎖排隊結束,要開始搶鎖的時候,讀鎖去和寫鎖爭搶
所以要在 CAS 搶鎖前,先判斷一次是否有讀鎖在最前邊要出隊了

此時,沒有其他線程爭搶,所以一定會獲取,
然後設置 第一個讀鎖線程 爲自己

但是,有個問題,它可以設置第一個持有讀鎖的線程爲自己
那第二個線程獲取讀鎖了怎麼辦???它拿什麼記錄自己???
有第二個的變量嗎???
可是沒有
那第二個搶讀鎖的線程該怎麼記錄??
後文解答

這時返回 1,最終讓 lock() 方法返回,加鎖就成功結束了

protected final int tryAcquireShared(int unused) {
    // 開頭與 ReentrantLock 的加鎖方法如出一轍
    // 先獲取當前線程和state值
    Thread current = Thread.currentThread();
    int c = getState();
    // 我們知道寫鎖是互斥鎖,會阻塞其他任何要獲取鎖的線程
    // 所以 如果寫鎖重入次數不爲0,說明有人加了寫鎖
    // 並且如果寫鎖不是自己(因爲如果是自己,自己既可以重入寫鎖,也能加讀鎖)
    // 那麼肯定不能獲取讀鎖
    // (此時由於第一次來,所以肯定沒有鎖在,所以不進 if)
    if (exclusiveCount(c) != 0 &&
        // 如果寫鎖不是自己
        getExclusiveOwnerThread() != current)
        return -1;
    // 獲取讀鎖的佔有數量
    // (讀鎖是很多線程都能獲得的)
    int r = sharedCount(c);
    // 先判斷讀鎖是不是應該被阻塞
    /**
     * 這裏你有沒有覺得奇怪,爲什麼互斥鎖沒人持有
     * 還要先判斷要不要阻塞???
     * 沒人持有鎖不應該去搶鎖嗎?
     */
    if (!readerShouldBlock() &&
        // 再判斷數量有沒有超過上限(正常情況下不會)
        r < MAX_COUNT &&
        // 這時候 CAS 去獲取讀鎖
        // 搶鎖成功就進入了if中的方法
        compareAndSetState(c, c + SHARED_UNIT)) {
        // 如果本來沒有人持有讀鎖,那麼自己設置成第一個讀鎖
        /**
         * 但是你有沒有想過,第二個線程怎麼辦
         * 第一個佔了 第一個讀鎖的變量
         * 但是有第二個讀鎖變量嗎??
         * 沒有
         * 那第二個線程如何保存重入次數??
         * 後文解答
         */
        if (r == 0) {
            firstReader = current;
            // 重入次數設置 1
            firstReaderHoldCount = 1;
            // 如果是自己來重入的,那就把重入次數加 1
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            // 這裏不進 else 方法,我們先不看
            // ... 省略無關代碼
        }
        return 1;
    }
    // 沒有進if
    // 可能是判斷需要需要阻塞
    // 可能是 CAS 搶鎖失敗
    // (正常情況不會數量達到上限的,一個 JVM 上搶幾萬個讀鎖也太誇張了)
    // 於是進入 fullTryAcquireShared 方法(傳入當前線程做參數)
    return fullTryAcquireShared(current);
}

第一次讀鎖加鎖小結

第一次加鎖很簡單

  1. 只需要先判斷是否有寫鎖持有鎖(此時沒有)
  2. 然後判斷是否有寫鎖正排隊結束需要搶鎖(此時肯定沒有)
  3. 然後 CAS 搶鎖,設置自己爲第一個 讀鎖

第二次讀鎖加鎖

進入 tryAcquireShared 方法

加鎖的過程和之前相同,都是:

  1. 先獲取 Thread 和 state
  2. 然後判斷有沒有互斥鎖
  3. 需不需要阻塞(有互斥鎖在隊列第一個正要搶鎖)
  4. CAS 搶鎖

關鍵是搶鎖結束後的操作不同

之前第一個線程讀鎖搶鎖後,設置自己爲第一個讀鎖線程,然後重入設置爲 1

這時不是同一個線程了,但是 !!
沒有第二個線程的變量給它直接設置爲自己,也沒有直接的 重入次數給它記錄。

所以用到了一個計數器的類 HoldCounter,專門來記錄讀鎖的重入次數

  1. 先取出緩存的 HoldCounter計數器
  2. 如果沒有(null),或者不屬於當前線程自己的
  3. 就去 readHolds 裏把自己的取出來,並且存入緩存
  4. 否則,如果計數器的值爲 0,就放入 readHolds
    (如果計數器爲 0,說明還沒用過,不敢保證 readHolds 中一定含有這個計數器,所以要把它放進去)
  5. 計數器的 值+1

(這樣用一個每個線程專有的計數器,保證了每個線程的讀鎖重入次數都能夠記錄)

// 進入嘗試加讀鎖的方法
protected final int tryAcquireShared(int unused) {

    // ...省略相同代碼(忘記的回到前面複習)
    
        } else {
            // 這一次由於不是第一個線程獲取讀鎖了,所以進 else 方法
            // 首先獲取一個 統計重入數量的 緩存
            // 因爲第一次獲取,所以一定是 空(null)
            /**
             * 這個東西是用來統計共享鎖的重入次數的
             */
            HoldCounter rh = cachedHoldCounter;
            /**
             * 如果爲空 或者 它的線程id不是當前線程id
             * 說明沒有直接拿到當前線程的 重入計數器
             * 那就要去 readHolds 取出它的計數器
             */
            if (rh == null ||    // 空
                rh.tid != LockSupport.getThreadId(current))//不是當前線程的
                // 從 readHolds 取出當前線程的計數器
                cachedHoldCounter = rh = readHolds.get();
            // 如果計數器爲 0,說明還沒用過
            // 不敢保證 readHolds 中一定含有這個計數器,所以要把它放進去
            else if (rh.count == 0)
                readHolds.set(rh);
            // 計數器的值+1
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

HoldCounter 計數器

一共有兩個變量,count 和 tid

  • count:記錄重入次數
  • tid:線程 id(final 不可更改保證安全)
static final class HoldCounter {
    int count;          // initially 0
    // 創建計數器初始化時 LockSupport 獲取線程 id
    // 並且 final 不可改變保證了安全
    final long tid = LockSupport.getThreadId(Thread.currentThread());
}

readHolds 保存各個計數器

首先我們可以看到 readHolds 是一個 ThreadLocalHoldCounter 對象

private transient ThreadLocalHoldCounter readHolds;

點進去,發現實際上,不過是一個 ThreadLocal
因此每個 不同的線程 都可以從 readHolds 裏取出屬於自己的 HoldCounter 計數器
(ThreadLocal 不知道的先去補補課。。。)

// 本質上是一個 ThreadLocal
static final class ThreadLocalHoldCounter
    // 泛型用 HoldCounter計數器 替換
    extends ThreadLocal<HoldCounter> {
    // 重寫了初始化方法,這樣使得第一次獲取的時候不爲null
    // 而是一個 屬於自己的 新的HoldCounter計數器
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}

cachedHoldCounter 作用

其實我覺得這個不說你們應該也能自己想出來
(但是我害怕,你們萬一犯懶了,沒有去想,那會不會就遺漏了點東西)
(我真是用心良苦 T_T)

這裏很明顯是以空間換時間的思想

首先,每一次操作的 HoldCounter計數器,都會緩存在這個變量中
這樣,如果下一次使用同樣的計數器,就不用從 readHolds 中去取了

所以這樣對同一個讀鎖線程重入可以提高效率(因爲這個 計數器被緩存了)
但是由於只緩存一個,所以一直切換線程重入讀鎖,就起不到提高效率的作用

firstReader 再思考

可以發現,從第二個來獲取讀鎖的線程開始,都是用 TheadLocal 來進行記錄的

但是,爲什麼第一個要單獨保存???

其實我覺得,看完上一個 cachedHoldCounter 的作用,你應該已經能明白

對於只有一個線程獲取讀鎖的情況下,就不用用到 readHolds。
這樣每次都能直接拿到變量,對重入次數進行操作
而不用去 readHolds 中把計數器找出來,就能提高效率

而對於多個線程的情況下,其它線程就不能提高效率了

第二個線程加讀鎖小結

首先和之前一樣

  1. 先判斷是否有寫鎖持有鎖(此時沒有)
  2. 然後判斷是否有寫鎖正排隊結束需要搶鎖(此時肯定沒有)
  3. 然後 CAS 搶鎖

但是搶鎖成功之後就不同了

  1. 先從緩存獲取計數器
    (如果是自己的,可以不用去 readHolds 拿,提高效率)
    (如果多個線程頻繁切換來獲取讀鎖,那麼緩存起不到作用)
  2. 如果爲空,或者不是自己的
  3. 就去 readHolds 拿去(本質是一個 ThreadLocal)
  4. 然後計數器自增 1

多線程競爭讀鎖

CAS 失敗後作何抉擇

當時說了 3 種情況

  1. 判斷髮現有 寫鎖 的線程正準備出隊,要獲取寫鎖
  2. 發現讀鎖的獲取次數爆滿
  3. CAS 失敗

這些情況會使得代碼進入最後一行的方法(fullTryAcquireShared),並傳入當前線程作爲參數

protected final int tryAcquireShared(int unused) {

    // ...省略無關代碼

    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        // 競爭條件總有線程會 CAS 失敗
        compareAndSetState(c, c + SHARED_UNIT)) {
        // ...省略無關代碼
    }
    // 所以不會進 if
    // 會進入該方法(傳入當前線程作爲參數)
    return fullTryAcquireShared(current);
}

fullTryAcquireShared CAS失敗後的加鎖方法

當時判斷有寫鎖線程正準備出 AQS 隊列搶寫鎖,方法叫 readerShouldBlock(讀鎖應該阻塞)
但是它並不是立刻就真的阻塞了
而是運行到這個方法裏面之後,又進行了一次重複的判斷,如果這次的 readerShouldBlock 還是返回 true,那麼才真的讓方法返回 -1,結束該方法
(這個方法和上個方法有很多地方類似、冗餘,很容易理解)

不過想一想,這樣有什麼好處??
爲什麼不直接阻塞,而是要多給它一次機會去再判斷一次??
如果我的上一篇 ReentrantLock 的 AQS 講解你們看了,我覺得你們應該能反應過來

這就是多自旋一次,避免直接阻塞

還有:
這一次也會重新判斷是不是,讀鎖的獲取數量是否爆滿,這一次如果還是爆滿,那就拋出異常了

當然最常見的還是 CAS:
第一次 CAS 失敗後,會進入到這個方法繼續給它機會去不斷地 CAS(只要沒有寫鎖來阻塞它)
我們看代碼:

// 讀鎖加鎖方法,會不斷循環
// 直到成功,或發現寫鎖,需要阻塞
final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    // 熟悉的死循環
    // (拿不到鎖誓不罷休)
    for (;;) {
        // 獲取 state
        int c = getState();
        // 判斷是否有其他線程佔有寫鎖
        // 如果有就 返回 -1 加鎖失敗
        if (exclusiveCount(c) != 0) {
            if (getExclusiveOwnerThread() != current)
                return -1;
        } else if (readerShouldBlock()) {
            /**
             * 熟悉的 應該阻塞方法
             * 此時需要阻塞,所以必須加鎖失敗,返回 -1
             * (除非是來重入的,也就是之前已經加過鎖了,
             * 那麼此次加鎖,不必阻塞)
             */
            // 如果不是第一個加讀鎖線程
            // (如果是的話,肯定是來重入的,就不用判斷了)
            if (firstReader == current) {
            } else {
                // 否則,就去找它的計數器,如果不爲0,那麼也是來重入的
                if (rh == null) {
                    rh = cachedHoldCounter; // 緩存中找
                    if (rh == null ||
                        rh.tid != LockSupport.getThreadId(current)) {
                        // 緩存沒就去 readHolds 中拿
                        rh = readHolds.get();
                        // 如果爲 0,那麼說明不是來重入的,此時要 返回-1
                        // 但是在返回 -1 之前,要把 readHolds 中的記錄先清除(避免內存泄漏)
                        // (ThreadLocal不手動清除會內存泄漏)
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                // 前面已經清楚了readHolds的記錄,這時可以返回了
                if (rh.count == 0)
                    return -1;
            }
        }
        // 如果讀鎖獲取數量爆滿了,就拋異常(一般不會)
        if (sharedCount(c) == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // CAS 搶鎖
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // (感覺這個if永遠不會執行到,因爲這是在CAS成功的條件下執行的
            // 所以至少會有 sharedCount 至少也要是 1)
            // 也許是 Doug Lee 只是爲了保證代碼不會出錯才寫的吧
            // 如果你們發現有這個情況的話可以給我留言
            if (sharedCount(c) == 0) {
                firstReader = current;
                firstReaderHoldCount = 1;
            } // 後面的代碼與之前的無太大差別
            else if (firstReader == current) { // 重入
                firstReaderHoldCount++;
            } else { // 將自己的計數器 +1
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null ||
                    rh.tid != LockSupport.getThreadId(current))
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                cachedHoldCounter = rh; // cache for release
            }
            return 1; // 加鎖成功,返回 1
        }
    }
}

寫鎖阻塞讀鎖

我們在上文分析過,
如果沒有寫鎖來阻塞的話,那麼在 tryAcquireShared 方法中,讀鎖一定會在某一刻搶到鎖(默認不要管爆滿情況)
所以 tryAcquireShared 就會返回獲取讀鎖的數量,也就不會 <0

假設,這時,有寫鎖來了,那麼讀鎖的 tryAcquireShared 就會失敗,返回 -1
那麼這時就會運行到下面的方法 doAcquireShared

public final void acquireShared(int arg) {
    // 嘗試加鎖失敗,說明碰到了寫鎖
    if (tryAcquireShared(arg) < 0)
        // 那麼纔會進下面的方法
        doAcquireShared(arg);
}

doAcquireShared 阻塞獲取讀鎖

看到這裏我們會發現,和之前我們閱讀 ReentrantLock 時的方法非常類似

首先也是調用 addWaiter 入隊,但是,
注意,這裏的 addWaiter 傳入了一個新的參數,叫 Node.SHARED

然後就會進入一個死循環,開始熟悉的套路

  1. 獲取前置結點
  2. 如果是頭結點,就嘗試加鎖(自旋的套路)
  3. 如果加鎖成功,就設置頭結點
  4. 否則判斷是否應該 park
  5. 如果應該 park,就開始 park 阻塞

這裏的套路和之前一模一樣
又是兩次自旋,
第一次,會將前一個等待的線程的結點的 ws 設置爲 -1
然後第二次,看到前一個 ws 是 -1,於是開始 阻塞

private void doAcquireShared(int arg) {
    // 熟悉的 addWaiter 方法
    // 但是注意,這裏的傳的參數就不一樣了
    // 用帶 Node.SHARED 參數的構造方法,創建了結點
    // (表示這是要獲取共享鎖的線程的結點)
    final Node node = addWaiter(Node.SHARED);
    
    // 下面的方法和我們學習過的一致
    boolean interrupted = false; // 記錄是否被打斷過
    try {
        // 熟悉的死循環
        for (;;) {
            // 獲取前置結點
            final Node p = node.predecessor();
            // 如果前面的結點就是頭結點
            if (p == head) {
                // 這時就再次嘗試加鎖
                int r = tryAcquireShared(arg);
                // 如果加鎖成功,就將自己設置爲頭結點
                // 不過要注意,此時的設置頭結點和ReentrantLock的互斥鎖有所不同
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    return;
                }
            }
            // 如果應該park
            if (shouldParkAfterFailedAcquire(p, node))
                // 然後開始park
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    } finally {
        if (interrupted)
            selfInterrupt();
    }
}

設置頭結點的注意點

之前我們學習 ReentrantLock 的時候,頭結點的設置僅僅是:
把前一個頭結點剔除,自己充當頭結點,並且 thread 屬性設置爲 null(當前持有鎖線程不在隊列中)

而此處的 讀鎖 則不同,在調用 setHead 方法之後,
要去喚醒後面的所有讀鎖

之前學習 ReentrantLock 的時候,我們知道,互斥鎖只會去喚醒後面那一個正在等待的線程
而讀鎖是共享的,不能只有自己一個線程被喚醒,
所以要去後面叫醒所有的讀鎖,才能讓讀鎖一起共享。

private void setHeadAndPropagate(Node node, int propagate) {
    // 設置頭結點
    Node h = head; // Record old head for check below
    setHead(node);
    // 我們是加鎖成功後來到的這個方法,所以 propagate 的值 >0
    // 這時我們進 if 方法
    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 喚醒讀鎖

喚醒共享鎖的方法,是讀鎖特有的,
因爲傳統的互斥鎖,只會去叫醒身後的那一個線程,
而讀鎖,是多線程共享的,因此要去喚醒身後的讀鎖,才能大家共享

private void doReleaseShared() {
    // 死循環,用來喚醒下一個線程
    // 其中如果CAS失敗,就會continue重新一輪循環來操作
    for (;;) {
        // 獲取頭結點
        Node h = head;
        // 頭不爲空,並且不是尾(說明隊列中有其他線程在排隊)
        // (否則說明沒有其他線程在等)
        if (h != null && h != tail) {
            // 獲取 ws
            int ws = h.waitStatus;
            // 如果是 -1 (SIGNAL = -1)
            // (我們之前學習過ReentrantLock,已經知道 -1 表示後面有線程在等待)
            if (ws == Node.SIGNAL) {
                // 這時 CAS 把 -1 改成 0
                if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
                    continue; // 否則,說明被其他線程改了,那就去下一次循環
                // 改成功就喚醒下一個線程
                unparkSuccessor(h);
            }
            // 如果 ws 爲 0 (說明下一個線程開始排隊了,但還沒開始阻塞)
            else if (ws == 0 &&
                     // 那就用 CAS 改成 -3
                     // (因爲改成功了就保證它並沒有開始阻塞)
                     !h.compareAndSetWaitStatus(0, Node.PROPAGATE))
                // 如果 CAS 失敗,就進行下一次循環
                // 因爲可能 ws 已經從0,變成了-1,這時就需要多一個循環去將其喚醒
                continue;
        }
        // 如果 h == head,就可以結束循環
        // 如果head被修改,說明隊列狀態改變,則重新進行一次循環
        if (h == head)
            break;
    }
}

寫鎖加鎖

互斥鎖:類似 ReentrantLock

不管怎麼樣,互斥鎖的加鎖都會調用 AQS 的模板方法 acquire
這裏我在 ReentrantLock 裏面詳細分析過了

我們需要關注的,則是 它們的不同點,子類重寫的 tryAcquire 方法

// final 模板方法
public final void acquire(int arg) {
    // tryAcquire 由子類實現,是不同之處
    if (!tryAcquire(arg) &&
        // 後面代碼都是相同的
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

寫鎖的重寫方法 tryAcquire

AQS 你把之前基礎的方法和隊列關係學習理解之後,其實你再來看這些方法,都是類似的,
像這些方法,都是一些基本套路的簡單修改

ReadWriteLock 的 寫鎖 和 ReentrantLock 一樣,都是互斥鎖
所以它們的方法都是共用一個 模板方法
而不同點,只是在於它們重寫的 tryAcquire 而已

  1. 獲取當前線程
  2. 獲取 state,
    通過 state,可以獲得 互斥鎖的 重入值
  3. 如果不爲 state 不爲 0,說明有線程持有鎖
  4. 如果寫鎖的重入爲 0,說明讀鎖搶佔着鎖
    否則,如果不是當前線程持有寫鎖,就是別的線程正持有寫鎖
    那麼就無法上鎖
  5. 否則就可以重入
  6. 如果 c 爲 0,那麼就說明沒有任何線程持有鎖
    這時就直接 CAS 搶鎖
    (雖然說前面有一個判斷應不應該阻塞的方法,但是寫鎖重寫的方法是直接返回 true,也就是寫鎖是不管有沒有排隊,直接 CAS 搶鎖,是非公平的)
protected final boolean tryAcquire(int acquires) {
    // 先獲取當 前線程和 state值
    Thread current = Thread.currentThread();
    int c = getState();
    // 獲取出 互斥鎖的重入次數(state的後16位)
    int w = exclusiveCount(c);
    // c不爲0,說明有線程持有鎖(不管是讀鎖還是寫鎖)
    if (c != 0) {
        // 如果w(互斥鎖的重入次數)爲0,那麼目前只有讀鎖被線程持有
        // 或者 持有寫鎖的線程不是當前線程
        if (w == 0 || current != getExclusiveOwnerThread())
            return false; // 這樣就無法獲取寫鎖
        // 如果互斥鎖的重入次數,加上這次,就超過上線(正常沒人重入那麼多次。。)
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            // 就拋異常(不過一般不會發生)
            throw new Error("Maximum lock count exceeded");
        // 前面的 if 都沒有進
        // 那麼說明目前只有自己持有鎖,可以重入
        setState(c + acquires);
        return true;
    }
    // c爲0,那麼說明目前沒有任何線程持有鎖
    // 還是先判斷 寫鎖是否應該阻塞
    // (不過這個方法,寫鎖的重寫是直接 返回 true)
    // 也就是說,這裏的寫鎖是非公平的(不像ReentrantLock,可以公平)
    if (writerShouldBlock() ||
        // 因爲非公平,所以不管怎樣都允許搶鎖
        // 因此重寫的方法一定返回 true,所以會進行CAS加鎖
        !compareAndSetState(c, c + acquires))
        return false; // 加鎖不成功返回 false
    // 老套路,加鎖成功設置互斥鎖的持有者爲當前線程
    setExclusiveOwnerThread(current);
    return true;
}

writerShouldBlock 代表非公平

我們可以看到,寫鎖重寫的 writerShouldBlock 方法直接返回 false
可見 ReentrantReadWriteLock 的寫鎖是非公平的

final boolean writerShouldBlock() {
    return false; // writers can always barge
}

寫鎖的解鎖

final 模板方法 release

同樣的,類似 ReentrantLock,解鎖方法調用 release 方法
寫鎖只是對 tryRelease 方法進行了重寫

// 互斥鎖模板方法
public final boolean release(int arg) {
    // 我們只需要關注 重寫的 tryRelease 方法
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

寫鎖重寫的方法 tryRelease

同樣的,類似 ReentrantLock
都是對 互斥鎖的重入次數 -1

只有當重入的次數變爲 0,這時說明鎖已經完全釋放,
於是設置持有鎖的線程爲 null,然後返回 true;
否則返回false。

protected final boolean tryRelease(int releases) {
    // 如果持有鎖的不是當前線程,拋異常(很好理解,不解釋)
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    // 這裏直接獲取 state 作爲寫鎖的重入次數
    // 雖然寫鎖只有 後16位 !
    // 不過,由於寫鎖佔有者鎖,所以讀鎖的 前16位 一定全爲0
    // 所以,此時的 state 的值就等於 寫鎖的重入次數
    // (就沒必要再 與運算 了)
    int nextc = getState() - releases;
    // 減了 1 之後,看看互斥鎖的重入數 是不是 0
    boolean free = exclusiveCount(nextc) == 0;
    // 0的話表示,鎖已經完全釋放
    if (free)
        // 然後就可以設置互斥鎖的 持有線程爲 空
        setExclusiveOwnerThread(null);
    // 將 state 寫回內存
    setState(nextc);
    // 返回是否完全解鎖
    return free;
}

從 ReentrantLock 到 ReentrantReadWriteLock 做小結

  1. AQS 是一個抽象類,裏面封裝了 共享、互斥 獲取鎖的邏輯,而各種實現,都是對 AQS 的未實現方法做一個重寫
  2. AQS 內部提供了一個 state 值,不同的實現可以用它作爲不同的用途。
    比如,ReentrantLock 那它做重入次數。ReadWriteLock 分別各用一半作爲鎖的加鎖次數。
  3. 讀鎖可以共享,於是用 ThreadLocal 來分別記錄每一個線程 重入次數
  4. 共享鎖充分利用了緩存,如果同一個線程反覆重入共享鎖,是不會去 ThreadLocal 中取,而在緩存中拿。
    多個線程依次重入共享鎖就無法利用緩存。
  5. 如果是單個線程獲取共享鎖,是不會用到 ThreadLocal 的
  6. AQS 內部有一個隊列,而且只有在嘗試加鎖失敗之後,在入隊的時候,纔會初始化
    所以沒有互斥鎖競爭的情況下,是不會初始化隊列的
  7. ReentrantLock 可以公平是因爲可以在 CAS 搶鎖前看一下隊列裏有沒有線程在排隊
  8. ReentrantReadWriteLock 中的寫鎖是非公平的;
    而讀鎖面對寫鎖時,是公平的
  9. 隊列設計巧妙,用一個 ws(waitStatus)狀態值來表示目前的線程狀態
    而對狀態值的修改,是後一個來排隊時修改前一個的狀態(將前一個改成 -1,表示自己要開始阻塞了)
    而前一個看到自己變成 -1,就知道後面有線程開始阻塞了,則會喚醒後一個
  10. 有個小細節,就是隊列的頭中存儲的線程永遠爲 null(也就是持有鎖的線程,是不存儲在隊列結點中的)
  11. 有個精妙的設計,就是位於下一個獲取鎖的線程,不會立刻阻塞,會有一定次數的自旋
  12. interrupt 的處理十分精妙,如果不允許被打斷,先把它獲取並抹去,最後再給線程添加回去
  13. interrupt 的獲取也有個小細節, |= 保證了是否被打斷的記錄,不會被覆蓋
  14. 如果可以被打斷,自己拋出異常,自己 catch,處理了隊列之後,再把它重新拋出去
  15. 帶超時的 lock 有個小細節,如果等待時間 < 1秒,就不阻塞了,自旋獲取鎖
  16. 結點被打斷失效時,會嘗試將前面的有效結點,連接後面的有效結點。
    如果失敗了,就叫醒後面的有效結點,讓它自己去前面找有效結點連接。
    如果前一個就是頭結點,叫醒後一個是爲了避免剔除自己後,頭結點已經去喚醒過了
  17. 在寫鎖釋放鎖後,會先喚醒第一個等待的線程,如果是讀鎖
    則被喚醒的讀鎖,會去喚醒後面的讀鎖

作者的話

其實,一開始我是以爲這篇文章我會寫很久很久,因爲之前寫 ReentrantLock 的時候,花了幾天時間才寫完。
而且我寫 ReadWriteLock 的時候,之前也沒有直接閱讀過源碼。
結果只用了八九個小時,我就將其寫完了。

但是,寫着寫着才發現,其實很多地方都是共通的,很多知識點和我在 ReentrantLock 裏面寫到的知識點都相同。
畢竟它們都是基於 AQS 實現的。掌握了 AQS 的核心,這些實現便很容可以領會。

所以我很推薦,大家從我的那篇 ReentrantLock 的文章來開始學習。
我從底層的源碼,幾乎將所有涉及的知識點,和其中的設計思想都給講到了。

所以這一篇文章,我也沒有花精力去畫圖,也沒有去講解知識點重複的代碼段,因爲通過上一篇 ReentrantLock 的學習,如果真的學習明白了,那你們就要已經具備了直接能看懂這些代碼的能力。
而且我認爲,你們也完全可以不用看博客,直接閱讀源碼進行學習。

不過,實際上,很少有用心學習的。很多人對源碼的懼怕,很多人對閱讀和分析的懶惰,致使 AQS 真正理解的人不多。

其實這也無妨,畢竟對技術的學習,本來就會有精通的人,也本來就會有隻是需要使用的人。
所以,我的文章博客,也僅需要供給給那些真正需要,從底層掌握透徹的人。

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