同學(xué),讀寫鎖水太深,你把握不住

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

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

這篇文章 我們?cè)谝黄饋砜纯醋x寫鎖 ReentrantReadWriteLock 的源碼分析,基于Java8。

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

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

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

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

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

下面列舉了 ReentrantReadWriteLock 的主要特性,先有個(gè)大概的了解,后面會(huì)結(jié)合源碼詳細(xì)分析。

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

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



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

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

ReentrantReadWriteLock 概覽



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

下面我們?cè)賮砜纯碦eadLock 和WriteLock的具體代碼,就能更加清晰的理解:



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

從它們調(diào)用的 Sync 方法,我們可以看到:ReadLock 使用了共享模式,WriteLock 使用了獨(dú)占模式。

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

如果你們上面這個(gè)問題無法理解,那么可能你對(duì)AQS并不熟悉,這里我簡(jiǎn)要的列舉AQS的共享模式和獨(dú)占模式過程,你可以橫向?qū)Ρ攘私猓?br>


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






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

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

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

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

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

這個(gè)結(jié)論很重要,下面代碼會(huì)有體現(xiàn)。

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

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



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



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

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

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



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

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

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








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

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

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

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

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

在 NonFairSync 中說的是 apparentlyFirstQueuedIsExclusive(),即判斷阻塞隊(duì)列中 head 的第一個(gè)后繼節(jié)點(diǎn)是否是來獲取寫鎖的,如果是的話,讓這個(gè)寫鎖先來,避免寫鎖饑餓。作者給寫鎖定義了更高的優(yōu)先級(jí),所以如果碰上獲取寫鎖的線程馬上就要獲取到鎖了,獲取讀鎖的線程不應(yīng)該和它搶。如果 head.next 不是來獲取寫鎖的,那么可以隨便搶,因?yàn)槭欠枪侥J?,大家比?CAS 速度;

compareAndSetState(c, c + SHARED_UNIT) 這里 CAS 失敗,存在競(jìng)爭(zhēng)。可能是和另一個(gè)讀鎖獲取競(jìng)爭(zhēng),當(dāng)然也可能是和另一個(gè)寫鎖獲取操作競(jìng)爭(zhēng)。
然后就會(huì)來到 fullTryAcquireShared 中再次嘗試:



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

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

總結(jié)下核心流程:



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



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

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

鎖降級(jí)
鎖降級(jí)指的是寫鎖降級(jí)成為讀鎖。如果當(dāng)前線程擁有寫鎖,然后將其釋放,最后再獲取讀鎖,這種分段完成的過程不能稱之為鎖降級(jí)。鎖降級(jí)是指把持?。ó?dāng)前擁有的)寫鎖,再獲取到讀鎖,隨后釋放(先前擁有的)寫鎖的過程。

接下來看一個(gè)鎖降級(jí)的示例:



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

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

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

作者:七哥


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