JUC原始碼學習筆記6——ReentrantReadWriteLock

2022-12-01 06:01:23

系列文章目錄和關於我
閱讀此文需要有AQS獨佔和AQS共用的原始碼功底,推薦閱讀:

1.JUC原始碼學習筆記1——AQS獨佔模式和ReentrantLock

2.JUC原始碼學習筆記2——AQS共用和Semaphore,CountDownLatch

一丶類結構和原始碼註釋解讀

1.ReadWriteLock

維護了一對關聯鎖,一個用於唯讀操作,另一個用於寫入。讀讀可以共用資源,因為不會造成資料的變更,讀寫,寫寫互斥,因為讀寫可能造成髒讀,幻讀,不可重複讀等錯誤,寫寫可能造成髒寫等錯誤(ps:有點mysql innodb隔離級別的味道,只是mysql innodb 使用mvcc多版本並行控制讓並行能力更高,當然innodb也有S鎖,和X鎖,類似於讀寫鎖一樣對並行事務進行控制)。讀寫鎖在存取共用資料時允許比互斥鎖更高階別的並行性。與使用互斥鎖相比,讀寫鎖是否會提高效能取決於資料被讀取的頻率,讀寫鎖適用於讀多寫少的情況,如果寫操作變得頻繁,那麼資料的大部分時間都被獨佔鎖定,並行性幾乎沒有增加。

讀寫鎖面臨的問題:

  • 寫鎖被釋放時,存在多個執行緒拿讀鎖,和多個執行緒拿寫鎖,是將鎖給讀執行緒還是寫執行緒呢,通常情況下都是給寫鎖的,因為我們認為寫操作沒用讀操作頻繁。如果讀操作非常頻繁,且持有讀鎖的時間很長,寫鎖需要等待所有讀鎖釋放才能獲取,這樣將造成寫鎖長時間無法獲取鎖。當然這種情況下可以使用」公平「的策略,先來後到獲取鎖
  • 鎖是否可重入,讀鎖是否可重入,寫鎖是否可重入
  • 讀鎖是否可以升級為寫鎖,寫鎖是否可以降級為讀鎖

2.ReentrantReadWriteLock

ReadWriteLock的一個實現,它具備以下特性。

2.1支援公平和非公平

  • 非公平

    非公平情況下,會導致沒拿到鎖的執行緒處於」飢餓「狀態,但是擁有更高的吞吐率。為什麼?我認為是公平情況下需要排隊,排隊的執行緒會被LockSupport.park掛起,意味著釋放鎖的時候需要使用LockSupport.unpark喚醒排隊的執行緒,這時候並不允許其他執行緒搶佔先機,喚醒是需要時間的,公平情況下這一段時間鎖是沒用被任何執行緒獲取鎖的,所以說吞吐率不如非公平鎖。

  • 公平

    當構造為公平時,執行緒已近似到達順序策略來競爭進入(CAS入隊的順序,為什麼說是近似順序,入隊的瞬間存在消耗完時間片,讓老六執行緒搶先一步,這個順序是cpu決定的,並不是絕對時間上的先後順序)。當前持有的鎖被釋放時,等待時間最長的單個寫入執行緒將被分配寫入鎖,或者如果有一組讀取執行緒等待時間超過所有等待的寫入執行緒,則該組將被分配讀取鎖(這裡的意思是說,如果寫鎖排在佇列頭部,那麼寫鎖被持有,如果佇列頭部是一堆讀鎖,那麼讀鎖被持有)。

    公平情況下,如果寫鎖被持有,或者存在等待寫鎖的執行緒,那麼在此之後獲取讀鎖的執行緒會被阻塞。在最早等待獲取寫鎖的執行緒沒有釋放鎖的情況下,其他執行緒是無法獲取讀鎖的,除非等待獲取寫鎖的執行緒,放棄獲取寫鎖並在AQS佇列中不存在其他等待寫鎖的執行緒位於讀鎖之前。

    注意,非阻塞ReentrantReadWriteLock.ReadLock.tryLock()和ReentrandReadWriterLock.WriteLock.tryLock()方法不支援這種公平設定,如果可能的話,不管等待的執行緒如何,都會立即獲取鎖

2.2 可重入,寫鎖降級為讀鎖,讀鎖無法升級為寫鎖

ReentrantReadWriteLock允許讀執行緒和寫執行緒以ReentrantLock類似的方式重新獲取讀或寫鎖。``此外,獲取寫鎖的執行緒可以獲取讀讀鎖,僅持有讀鎖的執行緒試圖獲取寫鎖,它將死鎖。註釋裡面還提了一嘴這個重入,以及寫鎖可以拿到讀鎖有啥用——A方法獲取寫鎖,呼叫B方法,B方法是讀取資料進行校驗,這時候B需要獲取讀鎖,B呼叫C方法,C也需要獲取讀鎖,這些方法可以正常進行,可以看出重入和寫鎖可降級的用處了吧

可以先持有寫鎖,然後獲取讀鎖(這時候肯定直接可以拿到)然後釋放掉寫鎖,將寫鎖降級為讀鎖,這種使用方式可以保證,持有寫鎖的執行緒一定可以成功拿到讀鎖。

2.3讀寫鎖都支援等待獲取鎖的途中響應中斷

這是synchronized所不支援的,在ReentrantLock原始碼解讀中,解讀過原始碼,重點是LockSupport.park方法掛起執行緒A,執行緒A有兩種方式可以從LockSupport.park中返回:1.被unpark,2.被中斷。原始碼的實現是如果不支援等待鎖的途中中斷,會記錄下當前執行緒被中斷過,然後Thread.interrupted()重置中斷標註(因為中斷的執行緒無法再次被park)然後繼續park當前執行緒,讓其等待鎖,獲取到鎖之後,發現之前被中斷過會自我中斷補上中斷標誌。在獲取鎖的途中響應中斷,則是從LockSupport.park檢查中斷標誌,如果被中斷了說明是由於中斷從LockSupport.park中返回,這時候將丟擲中斷異常。

2.4 寫鎖支援基於Condition的等待喚醒

寫鎖支援Condition,但是讀鎖不支援Condition等待喚醒,讀鎖本身是共用的,需要所有讀鎖釋放後才有必要喚醒寫鎖。

二丶讀寫鎖使用範例

這裡使用讀寫鎖實現了一個執行緒安全,讀寫分離的TreeMap,doug lea還提醒到想讓這個TreeMap並行效能很好,必須實在讀多寫少的情況下。

三丶屬性和構造方法

可以看到讀寫鎖,內部使用final修飾讀鎖和寫鎖,然後通過writeLock,readLock兩個方法將鎖暴露出去。有意思的是其構造方法,構造讀寫鎖把this傳遞了進去

可以看到傳遞this的目的,是讓讀寫鎖使用sync = fair ? new FairSync() : new NonfairSync();生成的AQS子類物件,讓讀寫鎖使用同一個Sync物件,這樣才能做到讀寫互斥,下面我們分析FairSync,NonfairSync是怎麼一回事

三丶原始碼解析

1.FairSync,NonfairSync類結構

熟悉的套路,把具體鎖的實現,放到內部類Sync中,讀寫鎖只是呼叫對應的Sync的方法,整個Sync內容內容很多,我們先看具體原始碼,Sync自然就柳暗花明了。

2.讀鎖加鎖-ReadLock#lock

2.1原始碼解析

問題:
讀鎖如何實現公平,
讀鎖如何實現重入(重入需要記錄獲取次數,那麼doug lea 如何實現的),
寫鎖被獲取如何讓其他執行緒無法獲取讀鎖,寫鎖如何降級為讀鎖
獲取讀鎖然後獲取寫鎖為啥會死鎖

ReadLock的lock方法直接呼叫SyncacquireShared(1)方法,此方法在AQS中進行了實現

最終是呼叫Sync的tryAcquireShared方法

protected final int tryAcquireShared(int unused) {
    //當前執行緒
    Thread current = Thread.currentThread();
	//狀態 低16為寫鎖重入次數 高16位元讀鎖被多少個執行緒持有
    int c = getState();
    
    //寫鎖被佔有 且不是自己佔有寫鎖 返回-1 表示獲取失敗,這個時候會共用入隊
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    
    //寫鎖共用次數
    int r = sharedCount(c);
    //readerShouldBlock 公平情況下就是看前面是否有人排隊
    //非公平情況下就是看頭結點的下一個節點是否是共用模式,如果是共用說明,寫鎖被持有多個讀鎖都被阻塞了
    if (!readerShouldBlock() &&
        //讀鎖被持有小於最大值
        r < MAX_COUNT &&
        //CAS更改成功讀鎖數量
        compareAndSetState(c, c + SHARED_UNIT)) {
        //當前讀鎖是第一個拿讀鎖的
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {//讀鎖執行緒再次獲取讀鎖
            firstReaderHoldCount++;
        } else {
            //記錄當前執行緒持有讀鎖數量
            //利用ThreadLocal進行記錄
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    
    //到這 1.當前執行緒需要排隊,2.讀鎖被持有數量大於最大值 3.CAS失敗,多個執行緒在拿讀鎖
    return fullTryAcquireShared(current);
}

這段程式碼可以看做兩部分,最後一句return之前的內容,我稱為快速獲取寫鎖fullTryAcquireShared我稱之為完全嘗試獲取寫鎖

  • 快速獲取寫鎖

    首先如果發現獨佔鎖重入數量不為0,且獨佔的執行緒不是自己,也就是說當前寫鎖被其他執行緒持有,那麼直接返回-1,這裡可以看出寫鎖被持有,其他執行緒無法獲取讀鎖

    其次readerShouldBlock這個方法返回true 代表當前讀執行緒需要阻塞,什麼是否需要阻塞?

    1. 公平鎖FairSync的邏輯是看是否有排隊的執行緒,前面有人排隊為什麼說讀執行緒需要阻塞——說明寫鎖被持有,多個讀執行緒在排隊,這時候是看是否有人排隊,這是公平的體現
    2. 非公平鎖是看第一個排隊的執行緒是否是獨佔模式,這是不公平的體現,如果第一個排隊的執行緒是共用模式,那麼還是會嘗試獲取鎖,相當於插隊

    douglea,使用CAS修改state記錄寫鎖被多少執行緒持有,這裡CAS是因為可能存在多個執行緒獲取讀鎖,修改成功後,會用ThreadLocal記錄當前這個執行緒寫鎖重入數量

  • 完全獲取寫鎖

    進入完全獲取寫鎖的條件

    1. if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)判斷寫鎖沒有被持有,但是readerShouldBlock發現讀執行緒需要排隊,可能是突然寫鎖被持有,也可能是讀鎖被很多執行緒持有,當前執行緒需要排隊
    2. cas更新state失敗,意味著多個執行緒獲取寫鎖,當前執行緒cas失敗,有點類似之前AQS中的快速入隊

    完全獲取寫鎖的邏輯和快速類似,但是它是自選+CAS保證,要麼獲取到寫鎖,要麼返回-1進行排隊

2.2讀鎖如何實現公平

上面說到了,是readerShouldBlock來實現公平,在公平鎖的情況下,加入當前寫鎖被持有,多個讀執行緒在排隊,如 A->B->C,然後執行緒D嘗試獲取讀鎖,這時候是看佇列中是否有排隊的執行緒,如果有那麼執行緒D會進行排隊。

非公平鎖是看第一個排隊的執行緒是否是獨佔模式,如果佇列頭是獨佔,那麼會進行排隊,這樣做的目的是避免寫執行緒一直飢餓,如果不是那麼會嘗試獲取鎖,這相當於廁所門口多人(讀執行緒)排隊,你硬搶,是非公平的體現

2.3讀鎖如何實現重入(重入需要記錄獲取次數,那麼doug lea 如何實現的)

對於第一個獲取寫鎖的執行緒,它會使用firstReader,firstReaderHoldCount記錄執行緒和其重入數量,對於後面獲取寫鎖的執行緒,會使用ThreadLocal進行記錄

2.4寫鎖被獲取如何讓其他執行緒無法獲取讀鎖,寫鎖如何降級為讀鎖

首先快速獲取讀鎖的有如下判斷

 if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;

如果持有寫鎖的執行緒不是當前執行緒,那麼返回-1,進行排隊,在完全入隊的自旋中也有這一段邏輯

如果當前執行緒就是持有寫鎖的執行緒則不會返回-1,依舊還是自旋+CAS獲取讀鎖,從而獲取到讀鎖

然後此時執行緒再釋放寫鎖,就實現了寫鎖降級為讀鎖。

2.5獲取讀鎖然後獲取寫鎖為啥會死鎖

執行上述程式碼,你會發現發生了死鎖,make it永遠不會列印出來,為啥呢?這裡需要我們看完寫鎖獲取的原始碼。

3.讀鎖釋放ReadLock#unLock

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    
    //更新當前執行緒的重入數量
    if (firstReader == current) {
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    } else {
        
        HoldCounter rh = cachedHoldCounter;
        if (rh == null || rh.tid != getThreadId(current))
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        --rh.count;
    }
    
    //cas改變state 
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

原始碼不難,主要兩部分

  1. 更改ThreadLocal,或者是firstReaderHoldCount中記錄的當前執行緒寫鎖重入數量

    如果是第一個獲取寫鎖的執行緒那麼firstReaderHoldCount--完全釋放的時候firstReader設定為null

    如果不是第一個或者說第一個執行緒重入2次,釋放3此,都會進入到else分支,減少ThreadLocal中記錄的重入數量,如果發現釋放次數>重入次數,會丟擲異常

  2. cas改變state,state使用低16位元記錄寫鎖重入數量,使用高16為記錄讀鎖重入數量

    低16位元 = 寫鎖重入數

    高16位元 = 每一個讀鎖持有執行緒的重入數之和

    這裡需要使用CAS修改state,因為存在多個讀執行緒同時釋放讀鎖的情況

讀鎖的tryLock lockInterruptibly()還是哪些老套路,沒啥好看的

4.寫鎖加鎖-WriteLock#Lock

問題
寫鎖如何實現公平,
寫鎖如何實現重入(重入需要記錄獲取次數,那麼doug lea 如何實現的),
獲取讀鎖然後獲取寫鎖為啥會死鎖

4.1.原始碼解析

寫鎖加鎖呼叫的是AQS的acquire方法

我們在AQS獨佔原始碼分析中,說到過,如果tryAcquire失敗,意味著後續會呼叫acquireQueued入隊然後獲取鎖後才能出隊,其中selfInterrupt是為了將獲取鎖途中受到的中斷補上,tryAcquire被讀寫鎖中的Sync內部類重寫,如下

protected final boolean tryAcquire(int acquires) {
    
            Thread current = Thread.currentThread();
            int c = getState();
    		//寫鎖被重入數
            int w = exclusiveCount(c);
            //c!=0說明寫鎖或者讀鎖至少有一個鎖被持有
    		if (c != 0) {
			   //w == 0說明讀鎖被持有,那麼寫鎖需要阻塞返回false
                //w!=0 但是 current != getExclusiveOwnerThread() 說明寫鎖被其他執行緒持有
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                //到這說明寫鎖被當前執行緒持有,那麼執行緒不需要使用CAS
                //確保重入數不能大於 MAX_COUNT
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                
                //重入+1
                setState(c + acquires);
                return true;
            }
    		
    		//writerShouldBlock——寫鎖是否需要阻塞
    		//公平鎖的情況下:如果有排隊的執行緒 那麼返回true
    		//非公平的情況下,恆定返回false
            if (writerShouldBlock() ||
                //如果cas失敗 說明寫鎖被其他執行緒持有 那麼返回false需要進行排隊 寫寫互斥
                !compareAndSetState(c, c + acquires))
                return false;
    		
    		//到此說明當前執行緒 拿到了寫鎖 記錄下獨佔的執行緒
            setExclusiveOwnerThread(current);
            return true;
        }

原始碼相比於共用更簡單,主要分為兩部分

  1. 寫鎖或者讀鎖被持有

    這部分的重點在於處理重入

    C!=0說明state不為0,那麼寫或者讀鎖必定有一種鎖被持有,然後(w == 0 || current != getExclusiveOwnerThread(),如果w==0成立,說明讀鎖被持有了,這時候直接返回false,因為讀寫互斥,如果w!=0說明此時寫鎖被持有,繼續判斷current != getExclusiveOwnerThread(),當前執行緒是否是持有寫鎖的執行緒,如果不是返回false,說明寫鎖被其他執行緒持有。

    如果當前執行緒就是持有寫鎖的執行緒,接下來就是使用setState設定重入數量,這一步不需要CAS,本身便是執行緒安全的

  2. 寫鎖和讀鎖都未被持有

    這裡的未被持有,是上面第一個if中的判斷結果,也許第一個if執行完就有其他執行緒獲取到了讀鎖或者寫鎖

    首先writerShouldBlock判斷寫執行緒是否需要阻塞,在公平情況下是看是否具備排隊的執行緒,非公平情況下恆定返回false,只有第一個if結束有其他執行緒搶先獲取了寫鎖,並且後續有其他執行緒獲取鎖,才可能出現公平情況下判斷得到有排隊的執行緒,這是一種公平的體現,其他執行緒在排隊那麼當前獲取鎖的執行緒也需要加入佇列尾部。非公平情況下,writerShouldBlock恆定返回false,這裡可以看出寫鎖偏好——即使前面有A已經獲取了寫鎖,BC兩個執行緒排隊獲取讀鎖,非公平情況下,只要A釋放了寫鎖的一瞬間,當前執行緒可不管釋放有人排毒,就是一個CAS搶鎖,這裡即是非公平也是寫鎖偏好的體現

    compareAndSetState(c, c + acquires)這便是當前執行緒CAS搶鎖,保證執行緒安全,後續如果搶鎖成功 setExclusiveOwnerThread(current)記錄寫鎖被當前執行緒獲取。

4.2寫鎖如何實現公平

上面說到,公平情況下,寫鎖的獲取需要看是否具備排隊的執行緒,如果具備其他執行緒排隊,那麼直接返回false,這樣會讓當前執行緒呼叫acquireQueued(addWaiter(Node.EXCLUSIVE), arg)進行排隊並等待其他執行緒釋放鎖後等待,這便是公平。

4.3寫鎖如何實現重入(重入需要記錄獲取次數,那麼doug lea 如何實現的)

和ReentrantLock一樣,通過state記錄重入數量,只是低16位元才是寫鎖的重入數量,每當寫鎖被重入便setState進行記錄

4.4獲取讀鎖然後獲取寫鎖為啥會死鎖

前面讀鎖加鎖解讀,我們就丟擲了這個問題,這裡我們結合寫鎖加鎖原始碼看下,為什麼會死鎖

5.寫鎖釋放鎖-WriteLock#unLock

寫鎖的釋放就是呼叫內部類Sync的release方法,此方法會呼叫tryRelease如果返回true後續會喚醒其他等待的執行緒

ReentrantReadWriteLock內部類Sync的tryRelease方法如下

這裡首先會判斷當前執行緒釋放是獨佔寫鎖的執行緒,如果不是那麼就是其他執行緒想釋放持有寫鎖執行緒的鎖,這是不允許的

然後計算釋放後的重入數量,這裡一般是重入數量-1,如果減少重入後的數量為0,那麼free為true,這意味著完全釋放了寫鎖,會將獨佔寫鎖的執行緒屬性置為null,如果free為true這個方法結束後就會呼叫AQS中的unparkSuccessor喚醒其他執行緒

setState改變重入數量,這裡不需要加鎖因為還沒有喚醒其他執行緒,所以此時不會存線上程安全問題,這是獨佔釋放和共用釋放的一個區別,共用鎖的釋放需要使用自旋+CAS保證執行緒安全的更新state