死磕Java併發編程(7):讀寫鎖 ReentrantReadWriteLock 源碼解析

這是《死磕Java併發編程》系列的第7篇文章 我們在一起來看看 讀寫鎖 ReentrantReadWriteLock 的源碼分析,基於Java8。

閱讀建議:由於Java併發包中的鎖都是基於AQS實現的,本篇的讀寫鎖也不例外。如果你還不瞭解的話,閱讀起來會比較吃力。建議先閱讀上一篇文章關於 AbstractQueuedSynchronizer 的源碼解析。

什麼是讀寫鎖?

提到鎖,你可能會想到 synchronized 關鍵字 ReentrantLock等實現,這些都是排它鎖。即同一時刻只能有一個線程進行訪問,而讀寫鎖在同一時刻,可以允許多個讀線程訪問,但是在寫線程訪問時,讀線程和寫線程都會被阻塞。 讀寫鎖維護一對鎖,一個讀鎖,一個寫鎖,通過讀寫鎖分離使得併發性相比於排它鎖有了很大的提升。

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

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

特性 說明
公平性選擇 支持公平和非公平(默認)兩種鎖獲取方式,吞吐量非公平模式大
重進入 支持重進入,即讀線程在獲取到讀鎖後可以繼續獲取讀鎖,寫線程在獲取到寫鎖後可以繼續獲取到寫鎖,同時也可以獲取讀鎖
鎖降級 遵循先獲取寫鎖,在獲取讀鎖,在釋放寫鎖的次序,實現寫鎖降級爲讀寫的過程

讀寫鎖接口與使用示例

ReadWriteLock 僅定義了獲取讀鎖和寫鎖的兩個方法,即 readLock() 方法 和 writeLock() 方 法,而其實現——ReentrantReadWriteLock,除了接口方法之外,還提供了一些便於外界監控其 內部工作狀態的方法,列舉如下:

方法名稱 說明
getReadLockCount() 所持有讀鎖的數量,非持有鎖的線程數,因爲一個線程可以多次獲取讀鎖,這裏返回的是獲取讀鎖的總次數
getReadHoldCount() 當前線程持有讀鎖的次數,保存在ThradLocal中
isWriteLocked() 判斷寫鎖是否被獲取
getWriteHoldCount() 返回當前線程持有寫鎖的次數

使用示例

下面這個例子,非常形象的說明了讀寫鎖的使用方式:

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

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

ReentrantReadWriteLock 概覽

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

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

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

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

這裏問題來了,同一個Sync實例,只有一個state同步狀態,如何做到可以同時使用共享模式和獨佔模式 ???

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

AQS 實現鎖的精髓 就在於維護的內部屬性 state

  1. 對於獨佔式獲取同步狀態,通過 0 代表可以獲取鎖, 1 代表已經被別人搶了,不可獲取,當前重入是可以的;
  2. 共享式獲取同步狀態,每個線程都可以對 state 進行加減操作,所以和獨佔式區別在於要保證線程安全的操作同步狀態,一般通過循環和 CAS 來保證。

也就是說,獨佔模式和共享模式對於 state 的操作完全不一樣,那讀寫鎖 ReentrantReadWriteLock 中是怎麼使用 state 的呢?彆着急,繼續往下看,這塊設計相當之巧妙。

讀寫鎖源碼分析

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

1. 讀寫狀態設計

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

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

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

這個結論很重要,下面代碼會有體現。

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

2. 寫鎖的獲取與釋放

  • 寫鎖是獨佔鎖。
  • 如果有讀鎖被佔用,寫鎖獲取是要進入到阻塞隊列中等待的。

寫鎖獲取

我們先來看下 ReentrantReadWriteLock 讀寫鎖中的自定義同步器 Sync 實現的 寫鎖獲取方法。

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

上面的代碼你應該已經看懂了,這裏在解釋下爲什麼讀鎖已被獲取則不能獲取寫鎖的原因?

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

寫鎖釋放

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

3. 讀鎖的獲取與釋放

  • 讀鎖是共享鎖;
  • 讀鎖可以被多線程同時獲取,當寫狀態爲0(寫鎖未被獲取),讀鎖總是會被成功的訪問;

讀鎖的獲取源碼還是比較複雜的,從 Java 5 到 Java 6 變得複雜許多,主要原因是新增了一些功能,例如 getReadHoldCount() 方法,作用是返回當前線程獲取讀鎖的次數。讀狀態是所有線程獲取讀鎖次數的總和,而每個線程各自獲取讀鎖的次數只能選擇保存在 ThreadLocal 中,由線程自身維護,這使獲取讀鎖的實現變得複雜。

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

讀鎖獲取

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

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

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

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

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

  • readerShouldBlock() 返回 true,2 種情況:

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

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

  • compareAndSetState(c, c + SHARED_UNIT) 這裏 CAS 失敗,存在競爭。可能是和另一個讀鎖獲取競爭,當然也可能是和另一個寫鎖獲取操作競爭。

然後就會來到 fullTryAcquireShared 中再次嘗試:

上面的源碼分析應該說得非常詳細了,如果到這裏你不太能看懂上面的有些地方的註釋,那麼這裏我爲你總結下,去除 firstReader 、cachedHoldCounter 這些用於緩存第一個獲取讀鎖的線程和最後一個獲取讀鎖的線程,它們本質上是用於 提高性能的,基於的原理大概是這樣的:通常讀鎖的獲取很快就會伴隨着釋放,顯然,在 獲取->釋放 讀鎖這段時間,如果沒有其他線程獲取讀鎖的話,此緩存就能幫助提高性能,因爲這樣就不用到 ThreadLocal 中查詢 map 了。

總結下核心流程:

讀鎖釋放

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

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

然後是在 for 循環中將 state 的高 16 位減 1,如果發現讀鎖和寫鎖都釋放光了,那麼喚醒後繼的獲取寫鎖的線程。

鎖降級

鎖降級指的是寫鎖降級成爲讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程。

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

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

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

總結

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

(全文完)fighting!

參考資料:

  1. 周志明:《深入理解 Java 虛擬機》
  2. 方騰飛:《Java 併發編程的藝術》
掃碼關注

筆者水平有限,文章難免會有紕漏,如有錯誤歡迎掃碼 加好友 一起交流探討,我會第一時間更正的。都看到這裏了,碼字不易,可愛的你記得 "點贊" 哦,我需要你的正向反饋。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章