Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

NO IMAGE

​關於讀寫鎖

前面的章節中我們分析了Java語法層面的synchronized鎖和JDK內置可重入鎖ReentrantLock,我們在多線程併發場景中可以通過它們來控制對資源的訪問從而達到線程安全。這兩種鎖都屬於純粹的獨佔鎖,也就是說這些鎖任意時刻只能由一個線程持有,其它線程都得排隊依次獲取鎖。

有些場景下為了提高併發性能我們會對純粹的獨佔鎖進行改造,額外引入共享鎖來與獨佔鎖共同對外構成一個鎖,這種就叫讀寫鎖。為什麼叫讀寫鎖呢?主要是因為它的使用考慮了讀寫場景,一般認為讀操作不會改變數據所以可以多線程進行讀操作,但寫操作會改變數據所以只能一個線程進行寫操作。讀寫鎖在內部維護了一對鎖(讀鎖和寫鎖),它通過將鎖進行分離從而得到更高的併發性能。

如下圖中,存在一個讀寫鎖對象,其內部包含了讀鎖和寫鎖兩個對象。假如存在五個線程,其中線程一和線程二想要獲取讀鎖,那麼兩個線程是可以同時獲取到讀鎖的。但是寫鎖就不可以共享,它是獨佔鎖。比如線程三、線程四和線程五都想要持有寫鎖,那麼只能一個個線程輪著持有。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖

讀寫鎖的性質

  • 可以多個線程同時持有讀鎖,某個線程成功獲取讀鎖後其它線程仍然能成功獲取讀鎖,即使該線程不釋放讀鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖性質1
  • 在某個線程持有讀鎖的情況下其它線程不能持有寫鎖,除非持有讀鎖的線程全部都釋放掉讀鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖性質2
  • 在某個線程持有寫鎖的情況下其它線程不能持有寫鎖或讀鎖,某個線程成功獲取寫鎖後其它所有嘗試獲取讀鎖和寫鎖的線程都將進入等待狀態,只有當該線程釋放寫鎖後才其它線程能夠繼續往下執行。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖性質3
  • 如果我們要獲取讀鎖則需要滿足兩個條件:目前沒有線程持有寫鎖和目前沒有線程請求獲取寫鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖性質4
  • 如果我們要獲取寫鎖則需要滿足兩個條件:目前沒有線程持有寫鎖和目前沒有線程持有讀鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖性質5

簡單的實現版本

為了加深對讀寫鎖的理解,在分析JDK實現的讀寫鎖之前我們先來看一個簡單的讀寫鎖實現版本。其中三個整型變量分別表示持有讀鎖的線程數、持有寫鎖的線程數以及請求獲取寫鎖的線程數,四個方法分別對應讀鎖、寫鎖的獲取和釋放操作。acquireReadLock方法用於獲取讀鎖,如果持有寫鎖的線程數量或請求讀鎖的線程數大於0則讓線程進入等待狀態。releaseReadLock方法用於釋放讀鎖,將讀鎖線程數減一併喚醒其它線程。acquireWriteLock方法用於獲取寫鎖,如果持有讀鎖的線程數量或持有寫鎖的線程數量大於0則讓線程進入等待狀態。releaseWriteLock方法用於釋放寫鎖,將寫鎖線程數減一併喚醒其它線程。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖簡單版本

讀鎖升級為寫鎖

在某些場景下,我們希望某個已經擁有讀鎖的線程能夠獲得寫鎖,並將原來的讀鎖釋放掉,這種情況就涉及到讀鎖升級為寫鎖操作。讀寫鎖的升級操作需要滿足一定的條件,這個條件就是某個線程必須是唯一擁有讀鎖的線程,否則將無法成功升級。如下圖中,線程二已經持有讀鎖了,而且它是唯一的一個持有讀鎖的線程,所以它可以成功獲得寫鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀鎖升級

寫鎖降級為讀鎖

與鎖升級相對應的是鎖降級,鎖降級就是某個已經擁有寫鎖的線程希望能夠獲得讀鎖,並將原來的寫鎖釋放掉。鎖降級操作幾乎沒有什麼風險,因為寫鎖是獨佔鎖,持有寫鎖的線程肯定是唯一的,而且讀鎖也肯定不存在持有線程,所以寫鎖可以直接降級為讀鎖。如下圖中,線程三持有寫鎖,此時其它線程不可能持有讀鎖和寫鎖,所以可以安全地將寫鎖降為讀鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

寫鎖降級

ReadWriteLock接口

ReadWriteLock實際上是一個接口,它僅僅提供了兩個方法:readLock和writeLock。分別表示獲取讀鎖對象和獲取寫鎖對象,JDK為我們提供了一個內置的讀寫鎖工具,那就是ReentrantReadWriteLock類,我們將對其進行深入分析。ReentrantReadWriteLock類包含的屬性和方法較多,為了讓分析思路清晰且方便讀者理解,我們將剔除非核心源碼,只對核心功能進行分析。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

ReentrantReadWriteLock三要素

ReentrantReadWriteLock類的三要素為:公平/非公平模式、讀鎖對象和寫鎖對象。其中公平/非公平模式表示多個線程同時去獲取鎖時是否按照先到先得的順序獲得鎖,如果是則為公平模式,否則為非公平模式。讀鎖對象負責實現讀鎖功能,而寫鎖對象負責實現寫鎖功能,這兩個類都屬於ReentrantReadWriteLock的內部類,下面會詳細講解。

ReentrantReadWriteLock實現思想

總的來說,ReentrantReadWriteLock類的內部包含了ReadLock內部類和WriteLock內部類,分別對應讀鎖和寫鎖,這兩種鎖都提供了公平模式和非公平模式。不管公平模式還是非公平模式、不管是讀鎖還是寫鎖都是基於AQS同步器來實現的。實現的主要難點在於只使用一個AQS同步器對象來實現讀鎖和寫鎖,這就要求讀鎖和寫鎖共用同一個共享狀態變量,下面會具體講解如何用一個狀態變量來供讀鎖和寫鎖使用。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

實現思想

對應ReentrantReadWriteLock類的結構如下,ReentrantReadWriteLock.ReadLock和ReentrantReadWriteLock.WriteLock分別為讀鎖對象和寫鎖對象。Sync對象表示ReentrantReadWriteLock類的同步器,它基於AQS同步器,而FairSync類和NonfairSync類分別表示公平模式和非公平模式的同步器,可以看到默認情況下使用的是非公平模式。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀寫鎖共用狀態變量

前面提到過ReentrantReadWriteLock的難點在於讀鎖和寫鎖都共用一個共享變量,下面看具體是如何共用的。我們知道AQS同步器的共享狀態是整型的,即32位,那麼最簡單的共用方式就是讀鎖和寫鎖分別使用16位。其中高16位用於讀鎖的狀態,而低16位則用於寫鎖的狀態,這樣便達到共用效果。但是這樣設計後當我們要獲取讀鎖和寫鎖的狀態值時則需要一些額外的計算,比如一些移位和邏輯與操作。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

共用狀態變量

ReentrantReadWriteLock的同步器共用狀態變量的邏輯如下,其中SHARED_SHIFT表示移動的位數為16;SHARED_UNIT表示讀鎖每次加鎖對應的狀態值大小,1左移16位剛好對應高16位的1;MAX_COUNT表示讀鎖能被加鎖的最大次數,值為16個1(二進制);EXCLUSIVE_MASK表示寫鎖的掩碼,值為16個1(二進制)。sharedCount方法用於獲取讀鎖(高16位)的狀態值,左移16位即能得到。exclusiveCount方法用於獲取寫鎖(低16位)的狀態值,通過掩碼即能得到。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

ReadLock與WriteLock簡介

ReadLock與WriteLock是ReentrantReadWriteLock的兩個要素,它們都屬於ReentrantReadWriteLock的內部類。它們都實現了Lock接口,我們主要關注lock、unlock和newCondition這幾個核心方法。分別表示對讀鎖和寫鎖的加鎖操作、釋放鎖操作和創建Condition對象操作,可以看到這些方法都間接調用了ReentrantReadWriteLock的同步器的方法,需要注意的是讀鎖不支持創建Condition對象。我們在可重入鎖ReentrantLock章節中已經講解過Condition對象,本節將不再贅述。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

公平/非公平模式

ReentrantReadWriteLock的默認模式為非公平模式,其內部類Sync是公平模式FairSync類和非公平模式NonfairSync類的抽象父類。因為ReentrantReadWriteLock的讀鎖使用了共享模式,而寫鎖使用了獨佔模式,所以該父類將不同模式下的公平機制抽象成readerShouldBlock和writerShouldBlock兩個抽象方法,然後子類就可以各自實現不同的公平模式。換句話說,ReentrantReadWriteLock的公平機制就由這兩個方法來決定了。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

下面看公平模式的FairSync類,該類的readerShouldBlock和writerShouldBlock兩個方法都直接返回hasQueuedPredecessors方法的結果,這個方法是AQS同步器的方法,用於判斷當前線程前面是否有排隊的線程。如果有排隊隊列就要讓當前線程也加入排隊隊列中,這樣按照隊列順序獲取鎖也就保證了公平性。

繼續看非公平模式NonfairSync類,該類的writerShouldBlock方法直接返回false,表明不要讓當前線程進入排隊隊列中,直接進行鎖的獲取競爭。readerShouldBlock方法則調用apparentlyFirstQueuedIsExclusive方法,這個方法是AQS同步器的方法,用於判斷頭結點的下一個節點線程是否在請求獲取獨佔鎖(寫鎖)。如果是則讓其它線程先獲取寫鎖,而自己則乖乖去排隊。如果不是則說明下一個節點線程是請求共享鎖(讀鎖),此時直接與之競爭讀鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

公平/非公平

寫鎖WriteLock的實現

上面的介紹中我們知道WriteLock有兩個核心方法:lock和unlock。它們都會間接調用了ReentrantReadWriteLock內部同步器的對應方法,在同步器中需要重寫tryAcquire方法和tryRelease方法,分別用於獲取寫鎖和釋放寫鎖操作。

先看tryAcquire方法的邏輯,獲取狀態值並通過exclusiveCount方法得到低16位的寫鎖狀態值。c!=0時有兩種情況,一種是高16位的讀鎖狀態不為0,一種是低16位的寫鎖狀態不為0。w等於0時表示還有線程持有讀鎖,直接返回false表示獲取寫鎖失敗。如果持有寫鎖的線程為當前線程,則表示寫鎖重入操作,此時需要將狀態變量進行累加,此外需要校驗的是寫鎖重入狀態值不能超過MAX_COUNT。通過writerShouldBlock方法判斷是否需要將當前線程放入排隊隊列中,同時通過擁有CAS算法的compareAndSetState方法對狀態變量進行累加操作,CAS失敗的話也需要將當前線程放入排隊隊列中。對於非公平模式,這裡的CAS操作就是闖入操作,即線程先嚐試一次競爭寫鎖。最後通過setExclusiveOwnerThread設置當前線程持有寫鎖,該方法只是簡單的設置變量方法。

繼續看tryRelease方法的邏輯,先用isHeldExclusively方法檢查當前線程必須為寫鎖持有線程。然後將狀態值減去釋放的值,並通過exclusiveCount得到低16位的寫鎖狀態值,如果其值為0則表示已經沒有重入可以徹底釋放鎖了,調用setExclusiveOwnerThread(null)設置沒有線程持有寫鎖。最後設置新的狀態值。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

讀鎖ReadLock的實現

ReadLock同樣有兩個核心方法:lock和unlock。它們都會間接調用了ReentrantReadWriteLock內部同步器的對應方法,在同步器中需要重寫tryAcquireShared方法和tryReleaseShared方法,分別用於獲取讀鎖和釋放讀鎖操作。

tryAcquireShared方法的邏輯為:先通過getState方法獲取狀態值,然後通過exclusiveCount方法獲取低16位的寫鎖狀態,如果不為0則表示有其它線程持有寫鎖而且當前線程沒有持有寫鎖,則此時嘗試獲取讀鎖失敗,返回-1,即將當前線程放到排隊隊列。注意這裡如果當前線程持有寫鎖的話則可以繼續獲取讀鎖。繼續通過sharedCount得到高16位的讀鎖,然後嘗試用CAS算法設置新的狀態值,如果成功則返回1表示成功獲取讀鎖。如果不成功則繼續調用fullTryAcquireShared方法。

fullTryAcquireShared方法的邏輯為:這是一個無限自旋操作,首先獲取狀態值,如果寫鎖不為0且當前線程不為持有寫鎖程序,則返回-1,表示嘗試獲取讀鎖失敗,將當前線程加入排隊隊列中。如果寫鎖的狀態為0,則表示沒有線程持有寫鎖,繼續通過readerShouldBlock方法判斷是否需要將該線程加入到排隊隊列中,如果需要則返回-1,AQS同步器會將其加入到排隊隊列中。此外,讀鎖的狀態值不能等於MAX_COUNT,即已經達到最大讀鎖數了。最後,通過CAS算法的compareAndSetState方法設置新的狀態值,這裡的for無限循環就是自旋,指通過自旋方式來競爭讀鎖。需要注意的是,在非公平模式下如果排隊隊列中下一個線程是要獲取寫鎖,則這個自旋操作也會被打破。

tryReleaseShared方法的邏輯為:通過for無限循環實現自旋,自旋的邏輯就是不斷計算新的狀態值,然後通過CAS算法的compareAndSetState方法來設置新的狀態值。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

例子

如下是一個讀寫鎖的使用例子,我們實例化了一個ReentrantReadWriteLock對象,然後通過它的讀鎖和寫鎖來控制對某個線程不安全的TreeMap對象的訪問。我們可以看到get方法屬於讀取數據的操作,所以使用共享的讀鎖即可。而put和clear兩個方法涉及到修改數據的操作,需要使用獨佔的寫鎖。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

例子

總結

本文介紹了Java中的讀寫鎖ReentrantReadWriteLock,從名字上看就知道它具有可重入性且提供了讀寫鎖的功能。我們講解了它的核心三要素以及實現原理。在ReentrantReadWriteLock讀寫鎖中,寫鎖是一種獨佔鎖,包括了公平模式和非公平模式。而讀寫則是一種共享鎖,它也包含了公平模式和非公平模式。ReentrantReadWriteLock類的實現基於AQS同步器,其中最重要的點是它通過某些技巧讓讀鎖和寫鎖公共了同一個狀態變量,高16位與低16位。通過本文的講解相信大家已經很好地掌握了JDK提供的讀寫鎖的實現原理。

專注於人工智能、讀書與感想、聊聊數學、計算機科學、分佈式、機器學習、深度學習、自然語言處理、算法與數據結構、Java深度、Tomcat內核等。

Java併發原理抽絲剝繭,讀寫鎖ReadWriteLock實現深入剖析

相關文章

輕鬆理解JS中的面向對象,順便搞懂prototype和__proto__

MyBatis源碼解析(三)—緩存篇

Android主流三方庫源碼分析(六、深入理解Leakcanary源碼)

還不會七大排序,是準備家裡蹲嗎!?