再次認識ReentrantReadWriteLock讀寫鎖

前言

最近研究了一下juc包的源碼。
在研究ReentrantReadWriteLock讀寫鎖的時候,對於其中一些細節的思考和處理以及關於提高效率的設計感到折服,難以遏制想要分享這份心得的念頭,所以在這裏寫一篇小文章做爲記錄。java

本片文章創建在已經瞭解併發相關基礎概念的基礎上,可能不會涉及不少源碼,以思路爲主。
若是文章有什麼紕漏或者錯誤,還請務必指正,預謝。設計模式

1. 從零開始考慮如何實現讀寫鎖

首先咱們須要知道獨佔鎖(RenentractLock)這種基礎的鎖,在juc中是如何實現的:
它基於java.util.concurrent.locks.AbstractQueuedSynchronizer(AQS)這個抽象類所搭建的同步框架,利用AQS中的一個volatile int類型變量的CAS操做來表示鎖的佔用狀況,以及一個雙向鏈表的數據結構來存儲排隊中的線程。緩存

簡單地說:一個線程若是想要獲取鎖,就須要嘗試對AQS中的這個volatile int變量(下面簡稱state)執行相似comapre 0 and swap 1的操做,若是不成功就進入同步隊列排隊(自旋和重入之類的細節不展開說了)同時休眠本身(LockSupport.park),等待佔有鎖的線程釋放鎖時再喚醒它。安全

那麼,若是不考慮重入,state就是一個簡單的狀態標識:0表示鎖未被佔用,1表示鎖被佔用,同步性由volatile和CAS來保證。數據結構

上面說的是獨佔鎖,state能夠不嚴謹地認爲只有兩個狀態位。併發

可是若是是讀寫鎖,那這個鎖的基本邏輯應該是:讀和讀共享讀和寫互斥寫和寫互斥app

如何實現鎖的共享呢?
若是咱們再也不把state當作一個狀態,而是當作一個計數器,那彷彿就能夠說得通了:獲取鎖時compare n and swap n+1,釋放鎖時compare n and swap n-1,這樣就可讓鎖不被獨佔了。框架

所以,要實現讀寫鎖,咱們可能須要兩個鎖,一個共享鎖(讀鎖),一個獨佔鎖(寫鎖),並且這兩個鎖還須要協做,寫鎖須要知道讀鎖的佔用狀況,讀鎖須要知道寫鎖的佔用狀況。ide

設想中的簡單流程大概以下:性能

clipboard.png

clipboard.png

設想中的流程很簡單,然而存在一些問題:

  • 關於讀寫互斥:

    • 對於某個線程,當它先得到讀鎖,而後在執行代碼的過程當中,又想得到寫鎖,這種狀況是否應該容許?
    • 若是容許,會有什麼問題?若是不容許,當真的存在這種需求時怎麼辦?
    • 關於寫寫互斥是否也存在上面兩條提到的狀況和問題呢?
  • 咱們知道通常而言,讀的操做數量要遠遠大於寫的操做,那麼頗有可能讀鎖一旦被獲取就長時間處於被佔有的狀況(由於新來的讀操做只須要進去+1就行了,不須要等待state回到0,這樣的話state可能永遠不會有回到0的一天),這會致使極端狀況下寫鎖根本沒有機會上位,該如何解決這種狀況?
  • 對於上面用計數器來實現共享鎖的假設,當任意一個線程想要釋放鎖(即便它並未獲取鎖,由於解鎖的方法是開放的,任何獲取鎖對象的線程均可以執行lock.unlock())時,如何判斷它是否有權限執行compare n and swap n-1

    • 是否應該使用ThreadLocal來實現這種權限控制?
    • 若是使用ThreadLocal來控制,如何保證性能和效率?

2. 帶着問題研究ReentrantReadWriteLock

在開始研究ReentrantReadWriteLock以前,應當先了解兩個概念:

重入性
一個很現實的問題是:咱們時常須要在鎖中加鎖。這多是由代碼複用產生的需求,也可能業務的邏輯就是這樣。
可是無論怎樣,在一個線程已經獲取鎖後,在釋放前再次獲取鎖是一個合理的需求,並且並不生硬。
上文在說獨佔鎖時說到若是不考慮重入的狀況,state會像boolean同樣只有兩個狀態位。那麼若是考慮重入,也很簡單,在加鎖時將state的值累加便可,表示同一個線程重入此鎖的次數,當state歸零,即表示釋放完畢。

公平、非公平
這裏的公平和非公平是指線程在獲取鎖時的機會是否公平。
咱們知道AQS中有一個FIFO的線程排隊隊列,那麼若是全部線程想要獲取鎖時都來排隊,你們先來後到井井有理,這就是公平;而若是每一個線程都不守秩序,選擇插隊,並且還插隊成功了,那這就是不公平。
可是爲何須要不公平呢?
由於效率。
有兩個因素會制約公平機制下的效率:

  • 上下文切換帶來的消耗
  • 依賴同步隊列形成的消耗

咱們之因此會使用鎖、使用併發,可能很大一部分緣由是想要挖掘程序的效率,那麼相應的,對於性能和效率的影響須要更加敏感。
簡單地說,上述的兩點因爲公平帶來的性能損耗極可能讓你的併發失去高效的初衷。
固然這也是和場景密切關聯的,好比說你很是須要避免ABA問題,那麼公平模式很適合你。
具體的再也不展開,能夠參考這篇文章:深刻剖析ReentrantLock公平鎖與非公平鎖源碼實現


回到咱們以前提的問題:

對於某個線程,當它先得到讀鎖,而後在執行代碼的過程當中,又想得到寫鎖,這種狀況是否應該容許?

咱們先考慮這種狀況是否實際存在:假設咱們有一個對象,它有兩個實例變量ab,咱們須要在實現:if a = 1 then set b = 2,或者換個例子,若是有個用戶名字叫張三就給他打錢。

這看上去彷彿是個CAS操做,然而它並非,由於它涉及了兩個變量,CAS操做並不支持這種針對多個變量的疑似CAS的操做。
爲何不支持呢?由於cpu不提供這種針對多個變量的CAS操做指令(至少x86不提供),代碼層面的CAS只是對cpu指令的封裝而已。
爲何cpu不支持呢?能夠,但不必鄙人也不是特別清楚(逃)。

總而言之這種狀況是存在的,可是在併發狀況下若是不加鎖就會有問題:好比先判斷獲得這個用戶確實名叫張三,正準備打錢,忽然中途有人把他的名字改了,那再打這筆錢就有問題了,咱們判斷名字和打錢這兩個行爲中間應當是沒有空隙的。
那麼爲了保證這個操做的正確性,咱們或許能夠在讀以前加一個讀鎖,在我釋放鎖以前,其餘人不得改變任何內容,這就是以前所說的讀寫互斥:讀的期間不許寫。
可是若是照着這個想法,就產生了自相矛盾的地方:都說了讀期間不能寫,那你本身怎麼在寫(打錢)呢?

若是咱們順着這個思路去嘗試解釋「本身讀的期間還在寫」的行爲的正當性,咱們也許能夠設立一個規則:若是讀鎖是我本身持有,則我能夠寫。
然而這會出現其餘的問題:由於讀鎖是共享的,你加了讀鎖,其餘人仍然能夠讀,這是否會有問題呢?假如咱們的打錢操做涉及更多的值的改變,只有這些值所有改變完畢,才能說此時的總體狀態正確,不然在改變完畢以前,讀到的東西都有多是錯的。
再去延伸這個思路彷佛會變得很是艱難,也許會陷入耦合的地獄。

可是實際上咱們不須要這樣作,咱們只須要反過來使用讀寫互斥的概念便可:由於寫寫互斥(寫鎖是獨佔鎖),因此咱們在執行這個先讀後寫的行爲以前,加一個寫鎖,這一樣能防止其餘人來寫,同時還能夠阻止其餘人來讀,從而實現咱們在單線程中讀寫並存的需求。

這就是ReentrantReadWriteLock中一個重要的概念:鎖降級

對於另外一個子問題:若是在已經獲取寫鎖的期間還要再獲取寫鎖的時候怎麼辦?
這種狀況仍是很常見的,多數是因爲代碼的複用致使,不過相應的處理也很簡單:對寫鎖這個獨佔鎖增長容許單線程重入的規則便可。


極端狀況下寫操做根本沒有機會上位,該如何解決這種狀況?

若是咱們有兩把鎖,一把讀鎖,一把寫鎖,它們之間想要互通各自加鎖的狀況很簡單——只要去get對方的state就好了。
可是隻知道state是不夠的,對於讀的操做來講,它若是隻看到寫鎖沒被佔用,也無論有多少個寫操做還在排隊,就去在讀鎖上+1,那極可能發展成爲問題所說的場景:寫操做永遠沒機會上位。

那麼咱們理想的狀況應該是:讀操做若是發現寫鎖空閒,最好再看看寫操做的排隊狀況如何,酌情考慮放棄這一次競爭,讓寫操做有機會上位。
這也是我理解的,爲何ReentrantReadWriteLock不設計成兩個互相溝通的、獨立的鎖,而是公用一個鎖(class Sync extends AbstractQueuedSynchronizer)——由於它們看似獨立,實際上對於耦合的需求很大,它們不只須要溝通鎖的狀況,還要溝通隊列的狀況。

公用一個鎖的具體實現是:使用int state的高16位表示讀鎖的state,低16位表示寫鎖的state,而隊列公用的方式是給每一個節點增長一個標記,代表該節點是一個共享鎖的節點(讀操做)仍是一個獨佔鎖的節點(寫操做)。

上面說到的「酌情放棄這一次競爭」,ReentrantReadWriteLock中體如今boolean readerShouldBlock()這個方法裏,這個方法有兩個模式:公平非公平,咱們來稍微看一點源碼
先看公平模式的實現

final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

當線程發現本身能夠獲取讀鎖時(寫鎖未被佔用),會調用這個方法,來判斷本身是否應該放棄這次獲取。
hasQueuedPredecessors()這個方法咱們不去看源碼,由於它的意思很顯而易見(實際代碼也是):是否存在排隊中的線程(Predecessor先驅者能夠理解爲先來的)。
若是有,那就放棄競爭去排隊。
在公平模式下,不管讀寫操做,只須要你們都遵照FIFO的秩序,就不會出現問題描述的狀況

再來看看非公平模式下的實現代碼:

final boolean readerShouldBlock() {  
    return apparentlyFirstQueuedIsExclusive();
}

final boolean apparentlyFirstQueuedIsExclusive() {
    // Node表示同步隊列中的一個節點
    Node h, s;
    // head是當前隊列的頭節點的一個公共引用,它是一個沒有實際意義的節點,null or not只能標識隊列是否初始化過
    // next是當前節點的下一個節點的引用
    // isShared()方法代表這個節點是一個共享鎖(讀鎖)的節點仍是獨佔鎖(寫鎖)的節點
    return (h = head) != null &&
        (s = h.next)  != null &&
        !s.isShared()         &&
        s.thread != null;
}

總結一下語義:若是隊列不爲空,且隊列最前面的節點是個獨佔鎖的節點,則放棄競爭。也就是咱們上面說的「根據隊列狀況酌情放棄」。


如何控制讀鎖釋放的權限?應該使用ThreadLocal嗎?它會對性能形成影響嗎?

通常而言的讀操做的線程對於state的操做可能只是+1而後-1,而若是發生重入,那就會是n次+1而後n次-1。
可是無論怎樣,每個線程都應當有一份記錄本身持有共享鎖數量的信息,這樣釋放鎖的時候才能知道本身可不能夠去-1。
這也許很簡單,咱們能夠在鎖裏增長一個Map對象,用相似tid(k)-count(v)的數據結構來記錄每一個線程的持有數量;也能夠爲每一個線程建立一個ThreadLocal,讓它們本身拿着。

如今咱們面前有兩條路比較直觀:將全部線程的小計數器維護在一個Map中,或是每一個線程在ThreadLocal中維護本身的小計數器。
就這兩條途徑而言,應該是Map的這一條路比較高效,由於若是選擇ThreadLocal也許會頻繁進行其內部的ThreadLocalMap對象的建立和銷燬,這很消耗資源。

然而事實是,ReentranctReadWriteLock選擇的實現方式是後者,即便用ThreadLocal來實現,可是爲何選擇這種方式正是我十分好奇的地方,由於根據經驗,必定是利用Map統一管理小計數器的方式較爲高效,且單個線程針對單個key的value進行+1或者-1的操做應該是知足as-if-serial原則的,也不存在安全問題。
所以針對兩種不一樣的實現方式進行了一些測試:四線程並行狀況下一千萬次加解鎖時間測試

  • Map統一管理實現
public static void main(String[] args) {
    long total = 0;
    for (int i = 0; i < 30; i++) {
        total += execute();
    }
    System.out.println(total / 30);
}

private static long execute() {

    var map = new HashMap<Long, Integer>();
    var readerPool = new ThreadPoolExecutor(
            4,
            4,
            5L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),
            new CustomizableThreadFactory("readerPool"));

    var countDown = new CountDownLatch(10000000);
    for (int i = 0; i < 10000000; i++) {
        readerPool.execute(() -> {
            try {
                countDown.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
                return;
            }
            mapImplement(map);
        });
        countDown.countDown();
    }

    long startTime = System.currentTimeMillis();
    while (readerPool.getCompletedTaskCount() < 10000000) {
        LockSupport.parkNanos(100);
    }
    long total = System.currentTimeMillis() - startTime;

    System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total);
    return total;
}

private static void mapImplement(HashMap<Long, Integer> map) {
    // lock
    var tid = Thread.currentThread().getId();
    Integer count;
    if ((count = map.get(tid)) != null) {
        map.put(tid, count + 1);
    } else {
        map.put(tid, 1);
    }

    // unlock
    int afterDecrement = -999;
    if ((count = map.get(tid)) == null ||
            (afterDecrement = (count - 1)) < 0) {
        System.out.println("error, count: " + count + ", afterDecrement: " + afterDecrement);
        return;
    }
    map.put(tid, afterDecrement);
}

三十次測試過程的平均執行時間爲:2378毫秒,我的認爲這個結果仍是比較樂觀的。

  • ThreadLocal各自持有實現
// 小計數器實體
static final class HoldCounter {
    int count;
}

// threadLocal
static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> {
    @Override
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}

public static void main(String[] args) {
    long total = 0;
    for (int i = 0; i < 30; i++) {
        total += execute();
    }
    System.out.println(total / 30);
}

private static long execute() {
    
    var readHolds = new ThreadLocalHoldCounter();
    var readerPool = new ThreadPoolExecutor(
            4,
            4,
            5L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),
            new CustomizableThreadFactory("readerPool"));

    var countDown = new CountDownLatch(10000000);
    for (int i = 0; i < 10000000; i++) {
        readerPool.execute(() -> {
            try {
                countDown.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
                return;
            }
            threadLocalImplement(readHolds);
        });
        countDown.countDown();
    }

    long startTime = System.currentTimeMillis();
    while (readerPool.getCompletedTaskCount() < 10000000) {
        LockSupport.parkNanos(100);
    }
    long total = System.currentTimeMillis() - startTime;

    System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total);
    return total;
}

private static void threadLocalImplement(ThreadLocalHoldCounter readHolds) {
    // lock
    var hc = readHolds.get();
    ++hc.count;

    // unlock
    hc = readHolds.get();
    --hc.count;
    if (hc.count == 0) {
        readHolds.remove();
    }
}

三十次測試過程的平均執行時間爲:3079毫秒

能夠看到,使用Map集中管理小計數器的實現方式的執行效率要比ThreadLocal的實現方式快20%以上。

難道我做爲一個Java萌新都能想到的性能差距,Doug Lea這樣的大神會想不到嗎?

固然不會

實際上,ReentranctReadWriteLock在針對小計數器的具體實現上,增長了相似兩層緩存的設計,大概以下:

// ThreadLocal對象本體
private transient ThreadLocalHoldCounter readHolds;

// 二級緩存:最近一次獲取鎖的線程所持有的小計數器對象的引用
private transient HoldCounter cachedHoldCounter;

// 一級緩存:首次獲取鎖的線程的線程對象引用以及它的計數
private transient Thread firstReader;
private transient int firstReaderHoldCount;

當線程嘗試獲取鎖時,會執行以下的流程:

  1. 判斷當前共享鎖總計數器是否爲0(當前鎖處於空閒狀態) firstReader == Thread.currentThread()
    是: 則直接在firstReaderHoldCount上進行+1(以及執行firstReader = Thread.currentThread()
    否: 前往2
  2. 判斷cachedHoldCounter.tid == Thread.currentThread().getId()
    是: 則直接在cachedHoldCounter.count上進行+1
    否: 前往3
  3. 執行readHolds.get()進行獲取或初始化,而後再對小計數器進行操做

當線程釋放鎖時,執行流程也大體類似,都是先對兩級緩存進行嘗試,逼不得已再去對ThreadLocal進行操做。
因爲讀操做的實際執行內容通常至關簡單(相似return a),因此在絕大多數狀況下,線程的加解鎖行爲都會命中一級緩存

我嘗試在ReentranctReadWriteLock的加解鎖行爲內埋了幾個計數點來測試兩級緩存的命中率,四線程並行1000萬次加解鎖操做,結果是:

  • 一級緩存命中率大概爲90~95%
  • 二級緩存命中率大概爲5~10%
  • ThreadLocal本體命中率大概爲1~5%

而執行效率,進行1000萬次加解鎖,循環三十次獲得的平均執行時間是:2027毫秒
比上面提到的使用Map實現的方式更要快了15%左右。

雖然說上面的小測試的編碼也好,測試環境也好,都不算特別嚴謹,可是仍是能很是直觀地說明問題的

3. ReentranctReadWriteLock實際設計概覽

clipboard.png

如圖,ReentranctReadWriteLock中有五個內部類:

  • Sync
    Sync繼承自AbstractQueuedSynchronizer,上文提到的volatile int state以及同步隊列的實際實現都是由AbstractQueuedSynchronizer這個抽象類提供的,它還提供了一些在鎖的性質不一樣時實現也會不一樣的可重寫方法,Sync須要作的事情就是將這些通用的方法和規則加以實現和擴充,造成本身想要實現的鎖。
    Sync也是咱們上文提到的,同時實現了讀寫兩種性質的鎖的根本。
    另外,上文提到的關於分段使用state、利用公平性避免機會不均衡的問題、分級緩存共享鎖小計數器等特性,均在此類中實現,須要特別關注。
  • NonfairSyncFairSync
    這兩個類都繼承自Sync,它們提供了由Sync定義的兩個用於進行公平性判斷的方法:boolean writerShouldBlock()boolean readerShouldBlock()。實際使用ReentranctReadWriteLock時,咱們會經過構造方法選擇須要構造公平仍是非公平的鎖,相應的會經過這兩個子類構造實際的Sync類的對象,從而影響到加解鎖過程當中的一些判斷。
  • ReadLockWriteLock
    這兩個類都會持有上面提到的Sync類的對象的引用,並向用戶(使用者)提供包裝好但實現不一樣的操做,好比:

    • 讀鎖獲取

      public void lock() {
          sync.acquireShared(1);
      }
    • 寫鎖獲取

      public void lock() {
          sync.acquire(1);
      }

更多的源碼就再也不贅述了,搜索一下就會有很是多的文章解讀源碼並不是做者懶得貼了

後記

juc這套併發框架的設計者和創始人Doug Lea,能夠說是java開發者金字塔頂端的巨佬之一了,他所編寫的juc包的代碼,不管是代碼結構的合理性、各類設計模式的使用、代碼的優雅程度都使人歎爲觀止。看完源碼以爲整我的都昇華了

筆者學習了源碼以後,以爲在面對這些充滿了考慮的設計細節時產生的思考,纔是真正可使人獲得長遠的提高的東西。

所以整理出來,做爲心得體會的記錄。若是能夠對其餘小夥伴帶來啓發,那就更好了。最後,若是文章內有什麼紕漏或是錯誤,還請務必指正,再次感謝。

相關文章
相關標籤/搜索