同學,讀寫鎖水太深,你把握不住

以下文章來源于七哥聊編程 ,作者七哥

大家好,我是哼哧哼哧復習Java并發(fā)包的七哥。

這篇文章 我們在一起來看看讀寫鎖 ReentrantReadWriteLock 的源碼分析,基于Java8。

閱讀建議:由于Java并發(fā)包中的鎖都是基于AQS實現(xiàn)的,本篇的讀寫鎖也不例外,如果你還不了解的話,閱讀起來會比較吃力。建議先閱讀上一篇文章關于 AbstractQueuedSynchronizer(AQS)的源碼解析。

本文內(nèi)容圍繞以下幾點展開:

什么是讀寫鎖?
讀寫鎖接口與使用示例
ReentrantReadWriteLock概覽
讀寫鎖源碼分析
什么是讀寫鎖?
提到鎖,你可能會想到 synchronized 關鍵字、 ReentrantLock等實現(xiàn),這些都是排它鎖,即同一時刻只能有一個線程進行訪問。

而讀寫鎖在同一時刻,可以允許多個讀線程訪問,但是在寫線程訪問時,讀線程和寫線程都會被阻塞。讀寫鎖維護一對鎖,一個讀鎖,一個寫鎖,通過讀寫鎖分離使得并發(fā)性相比于排它鎖有了很大的提升。

我們可以想到,讀寫鎖存在的意義在于,一般情況下,讀場景是遠遠大于寫場景的。因此讀大于寫的場景下,能提供了比排它鎖更高并發(fā)性和吞吐量。Java并發(fā)包中提供的讀寫鎖實現(xiàn)就是 ReentrantReadWriteLock 。

下面列舉了 ReentrantReadWriteLock 的主要特性,先有個大概的了解,后面會結合源碼詳細分析。

特性    說明
公平性選擇    支持公平和非公平(默認)兩種鎖獲取方式,吞吐量非公平模式大
重進入    支持重進入,即讀線程在獲取到讀鎖后可以繼續(xù)獲取讀鎖,寫線程在獲取到寫鎖后可以繼續(xù)獲取到寫鎖,同時也可以獲取讀鎖
鎖降級    遵循先獲取寫鎖,再獲取讀鎖,在釋放寫鎖的時候,實現(xiàn)寫鎖降級為讀鎖的過程
讀寫鎖接口與使用示例
ReadWriteLock 僅定義了獲取讀鎖和寫鎖的兩個方法,即 readLock() 方法 和 writeLock() 方 法,而其實現(xiàn)——ReentrantReadWriteLock,除了接口方法之外,還提供了一些便于外界監(jiān)控其內(nèi)部工作狀態(tài)的方法,列舉如下:

方法名稱    說明
getReadLockCount()    所持有讀鎖的數(shù)量,非持有鎖的線程數(shù),因為一個線程可以多次獲取讀鎖,這里返回的是獲取讀鎖的總次數(shù)
getReadHoldCount()    當前線程持有讀鎖的次數(shù),保存在ThradLocal中
isWriteLocked()    判斷寫鎖是否被獲取
getWriteHoldCount()    返回當前線程持有寫鎖的次數(shù)
使用示例
下面這個例子,非常形象的說明了讀寫鎖的使用方式:



在讀操作get(String key)方法中,需要獲取讀鎖,這使得并發(fā)訪問該方法時不會被阻塞。寫操作put(String key,Object value)方法和clear()方法,在更新HashMap時必須提前獲取寫鎖。

Cache使用讀寫鎖提升讀操作的并發(fā)性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了編程方式。

ReentrantReadWriteLock 概覽



大家仔細看看上圖中的信息,讀寫鎖分別對應了兩個內(nèi)部嵌套類的實例,并且自定了一Sync同步器繼承了 AQS, ReadLock 和 WriteLock 共同持有一個Sync實例。

下面我們再來看看ReadLock 和WriteLock的具體代碼,就能更加清晰的理解:



很清楚了,ReadLock 和 WriteLock 中的方法都是通過 Sync 這個類來實現(xiàn)的。Sync 是 AQS 的子類,然后再派生了公平模式和不公平模式。

從它們調用的 Sync 方法,我們可以看到:ReadLock 使用了共享模式,WriteLock 使用了獨占模式。

這里問題來了,同一個Sync實例,只有一個state同步狀態(tài),如何做到可以同時使用共享模式和獨占模式 ???

如果你們上面這個問題無法理解,那么可能你對AQS并不熟悉,這里我簡要的列舉AQS的共享模式和獨占模式過程,你可以橫向對比了解:



AQS 實現(xiàn)鎖的精髓 就在于維護的內(nèi)部屬性 state:






對于獨占式獲取同步狀態(tài),通過 0 代表可以獲取鎖, 1 代表已經(jīng)被別人搶了,不可獲取,當然重入是可以的;
共享式獲取同步狀態(tài),每個線程都可以對 state 進行加減操作,所以和獨占式區(qū)別在于要保證線程安全的操作同步狀態(tài),一般通過循環(huán)和 CAS 來保證。
也就是說,獨占模式和共享模式對于 state 的操作完全不一樣,那讀寫鎖 ReentrantReadWriteLock 中是怎么使用 state 的呢?別著急,繼續(xù)往下看,這塊設計相當之巧妙。

讀寫鎖源碼分析
源碼分析這塊,主要包括 讀寫狀態(tài) state 設計、寫鎖的獲取和釋放、讀鎖的獲取和釋放以及鎖降級。

1. 讀寫狀態(tài)設計
上面說到,讀寫鎖的自定義同步器需要在同步狀態(tài)(一個整型變量)上維護多個讀線程和一個寫線程的狀態(tài),答案就是“按位切割使用”。讀寫鎖將 32位的 state 分為高16位 和 低16位,分別表示 讀和寫。

那么讀寫鎖是如何快速確定當前讀和寫的狀態(tài)呢?答案是通過位運算。假設當前同步狀態(tài)值為 S,寫狀態(tài)等于 S&0x0000FFFF(將高16位全部抹去),讀狀態(tài)等于S>>>16(無符號補0右移16位)。當寫狀態(tài)增加1時,等于 S+1,當讀狀態(tài)增加1時,等于 S+(1<<16),也就是 S+0x00010000。

根據(jù)狀態(tài)的劃分能得出一個推論:S不等于0時,當寫狀態(tài)(S&0x0000FFFF)等于0時,則讀狀態(tài)(S>>>16)大于0,即讀鎖已被獲取。

這個結論很重要,下面代碼會有體現(xiàn)。

有了上面這個基礎,我們下面就不啰嗦了,直接進入正題,看看源碼是如何實現(xiàn)的,代碼不多,相信你如果理解上面說的,一行行代碼往下看就是了。

2. 寫鎖的獲取與釋放
寫鎖是獨占鎖。
如果有讀鎖被占用,寫鎖獲取是要進入到阻塞隊列中等待的。
寫鎖獲取
我們先來看下 ReentrantReadWriteLock 讀寫鎖中的自定義同步器 Sync 實現(xiàn)的 寫鎖獲取方法。



下面看一眼 writerShouldBlock() 的判定,結合代碼注釋一目了然



上面的代碼你應該已經(jīng)看懂了,這里在解釋下為什么讀鎖已被獲取則不能獲取寫鎖的原因?

主要還是結合設計初衷以及使用的場景,讀寫鎖要保證寫鎖的操作對于讀鎖可見,如果讀鎖已被獲取,依然被其他線程獲取寫鎖,那么已經(jīng)獲取讀鎖的線程就無法感知到獲取寫鎖線程的操作。

寫鎖釋放
接下來,我們看看寫鎖的釋放:



3. 讀鎖的獲取與釋放
讀鎖是共享鎖;
讀鎖可以被多線程同時獲取,當寫狀態(tài)為0(寫鎖未被獲取),讀鎖總是會被成功的訪問;
讀鎖的獲取源碼還是比較復雜的,從 Java 5 到 Java 6 變得復雜許多,主要原因是新增了一些功能,例如 getReadHoldCount() 方法,作用是返回當前線程獲取讀鎖的次數(shù)。讀狀態(tài)是所有線程獲取讀鎖次數(shù)的總和,而每個線程各自獲取讀鎖的次數(shù)只能選擇保存在 ThreadLocal 中,由線程自身維護,這使獲取讀鎖的實現(xiàn)變得復雜。

所以也特地放到了后面,畢竟寫鎖獲取比較簡單,可以很大的提升讀者的自信,接下來,我們就一起來啃這個讀鎖的實現(xiàn)。

讀鎖獲取
下面展示了讀鎖 ReadLock 的lock流程:








上述代碼,主要還是 讀懂 tryAcquireShared(arg) 方法:

在 AQS 中,如果 tryAcquireShared(arg) 方法返回值小于 0 代表沒有獲取到共享鎖(讀鎖),大于 0 代表獲取到。

上面的代碼中,要進入 if 分支(即獲取到讀鎖),需要滿足:readerShouldBlock() 返回 false,并且 CAS 要成功(我們先不要糾結 MAX_COUNT 溢出)。

那么根據(jù)上面的流程,我們思考下,如何才能進入到 fullTryAcquireShared(current) 方法呢?

readerShouldBlock() 返回 true,2 種情況:
在 FairSync 中說的是 hasQueuedPredecessors(),即阻塞隊列中有其他元素在等待鎖。也就是說,公平模式下,有人在排隊呢,你新來的不能直接獲取鎖;

在 NonFairSync 中說的是 apparentlyFirstQueuedIsExclusive(),即判斷阻塞隊列中 head 的第一個后繼節(jié)點是否是來獲取寫鎖的,如果是的話,讓這個寫鎖先來,避免寫鎖饑餓。作者給寫鎖定義了更高的優(yōu)先級,所以如果碰上獲取寫鎖的線程馬上就要獲取到鎖了,獲取讀鎖的線程不應該和它搶。如果 head.next 不是來獲取寫鎖的,那么可以隨便搶,因為是非公平模式,大家比比 CAS 速度;

compareAndSetState(c, c + SHARED_UNIT) 這里 CAS 失敗,存在競爭??赡苁呛土硪粋€讀鎖獲取競爭,當然也可能是和另一個寫鎖獲取操作競爭。
然后就會來到 fullTryAcquireShared 中再次嘗試:



上面的源碼分析應該說得非常詳細了,如果到這里你不太能看懂上面的有些地方的注釋,那么這里我為你總結下,去除 firstReader 、cachedHoldCounter 這些用于緩存第一個獲取讀鎖的線程和最后一個獲取讀鎖的線程,它們本質上是用于提高性能的。

基于的原理大概是這樣的:通常讀鎖的獲取很快就會伴隨著釋放,顯然,在 獲取->釋放 讀鎖這段時間,如果沒有其他線程獲取讀鎖的話,此緩存就能幫助提高性能,因為這樣就不用到 ThreadLocal 中查詢 map 了。

總結下核心流程:



讀鎖釋放
下面我們看看讀鎖釋放的流程:



讀鎖釋放的過程還是比較簡單的,主要就是將當前線程持有的讀鎖數(shù)量 count 減 1,如果減到 0 的話,還要將其對應的 HoldCounter 從 ThreadLocal 中 remove 掉。

然后是在 for 循環(huán)中將 state 的高 16 位減 1,如果發(fā)現(xiàn)讀鎖和寫鎖都釋放光了,那么喚醒后繼的獲取寫鎖的線程。

鎖降級
鎖降級指的是寫鎖降級成為讀鎖。如果當前線程擁有寫鎖,然后將其釋放,最后再獲取讀鎖,這種分段完成的過程不能稱之為鎖降級。鎖降級是指把持?。ó斍皳碛械模戞i,再獲取到讀鎖,隨后釋放(先前擁有的)寫鎖的過程。

接下來看一個鎖降級的示例:



上述示例中,當數(shù)據(jù)發(fā)生變更后,update變量(布爾類型且volatile修飾)被設置為false,此時所有訪問 processData() 方法的線程都能夠感知到變化,但只有一個線程能夠獲取到寫鎖,其他線程會被阻塞在讀鎖和寫鎖的lock()方法上。當前線程獲取寫鎖完成數(shù)據(jù)準備之后,再獲取讀鎖,隨后釋放寫鎖,完成鎖降級。

鎖降級中的讀鎖的獲取是否是必須?答案是必須的,為了保證數(shù)據(jù)可見性,因為當前線程如果不獲取讀鎖直接釋放了寫鎖,那么此時如果另外一個線程獲取了寫作并且修改了數(shù)據(jù),那么當前線程是無法感知到獲取寫鎖線程所做的變化的。

總結
讀寫鎖內(nèi)部定義了一把讀鎖和一把寫鎖,可以同時持有寫鎖、讀鎖,反之則不行;
當讀鎖被持有時,獲取寫鎖必然失敗,進入到阻塞隊列,可以查看寫鎖獲取的源碼 tryAcquire(int acquires) 加深理解;
獲取讀鎖時,如果寫鎖已被獲取但是和獲取寫鎖的線程是當前線程,那么依然可以獲取到讀鎖,這里也正好理解鎖降級的步驟;
讀寫鎖的源碼解析,讀鎖的獲取理解起來有難度,主要是因為 jdk1.6 引入了獲取當前線程鎖次數(shù)等功能,而每個線程的讀狀態(tài)只能保存在 ThradLocal 中,由線程自身維護,同時考慮到大部分情況下 獲取鎖沖突的幾率較小,引入了 firstReader、cachedHoldCounter 等緩存第一個獲取讀鎖和最后一個獲取讀鎖的線程和重入次數(shù)。查看源碼時,我的注釋應該寫的很細了,本著這個思維去查看應該是能看懂的。

作者:七哥


公眾號:牧小農(nóng),微信掃碼關注或搜索公眾號名稱