作者:碼哥位元組 公眾號:碼哥位元組如需轉載請聯絡我(微信ID):MageByte1024
概覽
在JDK 1.8 引入 StampedLock
,可以理解為對 ReentrantReadWriteLock
在某些方面的增強,在原先讀寫鎖的基礎上新增了一種叫樂觀讀(Optimistic Reading)的模式。該模式並不會加鎖,所以不會阻塞執行緒,會有更高的吞吐量和更高的效能。
它的設計初衷是作為一個內部工具類,用於開發其他執行緒安全的元件,提升系統效能,並且程式設計模型也比ReentrantReadWriteLock
複雜,所以用不好就很容易出現死鎖或者執行緒安全等莫名其妙的問題。
跟著“碼哥位元組”帶著問題一起學習StampedLock
給我們帶來了什麼…
- 有了
ReentrantReadWriteLock
,為何還要引入StampedLock
? - 什麼是樂觀讀?
- 在讀多寫少的併發場景下,
StampedLock
如何解決寫執行緒難以獲取鎖的執行緒“飢餓”問題? - 什麼樣的場景使用?
- 實現原理分析,是通過 AQS 實現還是其他的?
特性
三種訪問資料模式:
- Writing(獨佔寫鎖):
writeLock
方法會使執行緒阻塞等待獨佔訪問,可類比ReentrantReadWriteLock
的寫鎖模式,同一時刻有且只有一個寫執行緒獲取鎖資源; - Reading(悲觀讀鎖):
readLock
方法,允許多個執行緒同時獲取悲觀讀鎖,悲觀讀鎖與獨佔寫鎖互斥,與樂觀讀共享。 - Optimistic Reading(樂觀讀):這裡需要注意了,是樂觀讀,並沒有加鎖。也就是不會有 CAS 機制並且沒有阻塞執行緒。僅噹噹前未處於 Writing 模式
tryOptimisticRead
才會返回非 0 的郵戳(Stamp),如果在獲取樂觀讀之後沒有出現寫模式執行緒獲取鎖,則在方法validate
返回 true ,允許多個執行緒獲取樂觀讀以及讀鎖。同時允許一個寫執行緒獲取寫鎖。
支援讀寫鎖相互轉換
ReentrantReadWriteLock
當執行緒獲取寫鎖後可以降級成讀鎖,但是反過來則不行。
StampedLock
提供了讀鎖和寫鎖相互轉換的功能,使得該類支援更多的應用場景。
注意事項
StampedLock
是不可重入鎖,如果當前執行緒已經獲取了寫鎖,再次重複獲取的話就會死鎖;- 都不支援
Conditon
條件將執行緒等待; StampedLock
裡的寫鎖和悲觀讀鎖加鎖成功之後,都會返回一個 stamp;然後解鎖的時候,需要傳入這個 stamp。
詳解樂觀讀帶來的效能提升
那為何 StampedLock
效能比 ReentrantReadWriteLock
好?
關鍵在於StampedLock
提供的樂觀讀,我們知道ReentrantReadWriteLock
支援多個執行緒同時獲取讀鎖,但是當多個執行緒同時讀的時候,所有的寫執行緒都是阻塞的。
StampedLock
的樂觀讀允許一個寫執行緒獲取寫鎖,所以不會導致所有寫執行緒阻塞,也就是當讀多寫少的時候,寫執行緒有機會獲取寫鎖,減少了執行緒飢餓的問題,吞吐量大大提高。
這裡可能你就會有疑問,竟然同時允許多個樂觀讀和一個先執行緒同時進入臨界資源操作,那讀取的資料可能是錯的怎麼辦?
是的,樂觀讀不能保證讀取到的資料是最新的,所以將資料讀取到區域性變數的時候需要通過 lock.validate(stamp)
椒鹽蝦是否被寫執行緒修改過,若是修改過則需要上悲觀讀鎖,再重新讀取資料到區域性變數。
同時由於樂觀讀並不是鎖,所以沒有執行緒喚醒與阻塞導致的上下文切換,效能更好。
其實跟資料庫的“樂觀鎖”有異曲同工之妙,它的實現思想很簡單。我們舉個資料庫的例子。
在生產訂單的表 product_doc 裡增加了一個數值型版本號欄位 version,每次更新 product_doc 這個表的時候,都將 version 欄位加 1。
select id,... ,version
from product_doc
where id = 123
在更新的時候匹配 version 才執行更新。
update product_doc
set version = version + 1,...
where id = 123 and version = 5
資料庫的樂觀鎖就是查詢的時候將 version 查出來,更新的時候利用 version 欄位驗證,若是相等說明資料沒有被修改,讀取的資料是安全的。
這裡的 version 就類似於 StampedLock
的 Stamp。
使用示例
模仿寫一個將使用者id與使用者名稱資料儲存在 共享變數 idMap 中,並且提供 put 方法新增資料、get 方法獲取資料、以及 putIfNotExist 先從 map 中獲取資料,若沒有則模擬從資料庫查詢資料並放到 map 中。
public class CacheStampedLock {
/**
* 共享變數資料
*/
private final Map<Integer, String> idMap = new HashMap<>();
private final StampedLock lock = new StampedLock();
/**
* 新增資料,獨佔模式
*/
public void put(Integer key, String value) {
long stamp = lock.writeLock();
try {
idMap.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
/**
* 讀取資料,只讀方法
*/
public String get(Integer key) {
// 1. 嘗試通過樂觀讀模式讀取資料,非阻塞
long stamp = lock.tryOptimisticRead();
// 2. 讀取資料到當前執行緒棧
String currentValue = idMap.get(key);
// 3. 校驗是否被其他執行緒修改過,true 表示未修改,否則需要加悲觀讀鎖
if (!lock.validate(stamp)) {
// 4. 上悲觀讀鎖,並重新讀取資料到當前執行緒區域性變數
stamp = lock.readLock();
try {
currentValue = idMap.get(key);
} finally {
lock.unlockRead(stamp);
}
}
// 5. 若校驗通過,則直接返回資料
return currentValue;
}
/**
* 如果資料不存在則從資料庫讀取新增到 map 中,鎖升級運用
* @param key
* @param value 可以理解成從資料庫讀取的資料,假設不會為 null
* @return
*/
public String putIfNotExist(Integer key, String value) {
// 獲取讀鎖,也可以直接呼叫 get 方法使用樂觀讀
long stamp = lock.readLock();
String currentValue = idMap.get(key);
// 快取為空則嘗試上寫鎖從資料庫讀取資料並寫入快取
try {
while (Objects.isNull(currentValue)) {
// 嘗試升級寫鎖
long wl = lock.tryConvertToWriteLock(stamp);
// 不為 0 升級寫鎖成功
if (wl != 0L) {
// 模擬從資料庫讀取資料, 寫入快取中
stamp = wl;
currentValue = value;
idMap.put(key, currentValue);
break;
} else {
// 升級失敗,釋放之前加的讀鎖並上寫鎖,通過迴圈再試
lock.unlockRead(stamp);
stamp = lock.writeLock();
}
}
} finally {
// 釋放最後加的鎖
lock.unlock(stamp);
}
return currentValue;
}
}
上面的使用例子中,需要引起注意的是 get()
和 putIfNotExist()
方法,第一個使用了樂觀讀,使得讀寫可以併發執行,第二個則是使用了讀鎖轉換成寫鎖的程式設計模型,先查詢快取,當不存在的時候從資料庫讀取資料並新增到快取中。
在使用樂觀讀的時候一定要按照固定模板編寫,否則很容易出 bug,我們總結下樂觀讀程式設計模型的模板:
public void optimisticRead() {
// 1. 非阻塞樂觀讀模式獲取版本資訊
long stamp = lock.tryOptimisticRead();
// 2. 拷貝共享資料到執行緒本地棧中
copyVaraibale2ThreadMemory();
// 3. 校驗樂觀讀模式讀取的資料是否被修改過
if (!lock.validate(stamp)) {
// 3.1 校驗未通過,上讀鎖
stamp = lock.readLock();
try {
// 3.2 拷貝共享變數資料到區域性變數
copyVaraibale2ThreadMemory();
} finally {
// 釋放讀鎖
lock.unlockRead(stamp);
}
}
// 3.3 校驗通過,使用執行緒本地棧的資料進行邏輯操作
useThreadMemoryVarables();
}
使用場景和注意事項
對於讀多寫少的高併發場景 StampedLock
的效能很好,通過樂觀讀模式很好的解決了寫執行緒“飢餓”的問題,我們可以使用StampedLock
來代替ReentrantReadWriteLock
,但是需要注意的是 StampedLock 的功能僅僅是 ReadWriteLock 的子集,在使用的時候,還是有幾個地方需要注意一下。
StampedLock
是不可重入鎖,使用過程中一定要注意;- 悲觀讀、寫鎖都不支援條件變數
Conditon
,當需要這個特性的時候需要注意; - 如果執行緒阻塞在 StampedLock 的 readLock() 或者 writeLock() 上時,此時呼叫該阻塞執行緒的 interrupt() 方法,會導致 CPU 飆升。所以,使用 StampedLock 一定不要呼叫中斷操作,如果需要支援中斷功能,一定使用可中斷的悲觀讀鎖 readLockInterruptibly() 和寫鎖 writeLockInterruptibly()。這個規則一定要記清楚。
原理分析
我們發現它並不像其他鎖一樣通過定義內部類繼承 AbstractQueuedSynchronizer
抽象類然後子類實現模板方法實現同步邏輯。但是實現思路還是有類似,依然使用了 CLH 佇列來管理執行緒,通過同步狀態值 state 來標識鎖的狀態。
其內部定義了很多變數,這些變數的目的還是跟 ReentrantReadWriteLock
一樣,將狀態為按位切分,通過位運算對 state 變數操作用來區分同步狀態。
比如寫鎖使用的是第八位為 1 則表示寫鎖,讀鎖使用 0-7 位,所以一般情況下獲取讀鎖的執行緒數量為 1-126,超過以後,會使用 readerOverflow int 變數儲存超出的執行緒數。
自旋優化
對多核 CPU 也進行一定優化,NCPU 獲取核數,當核數目超過 1 的時候,執行緒獲取鎖的重試、入隊錢的重試都有自旋操作。主要就是通過內部定義的一些變數來判斷,如圖所示。
等待佇列
佇列的節點通過 WNode 定義,如上圖所示。等待佇列的節點相比 AQS 更簡單,只有三種狀態分別是:
- 0:初始狀態;
- -1:等待中;
- 取消;
另外還有一個欄位 cowait ,通過該欄位指向一個棧,儲存讀執行緒。結構如圖所示
同時定義了兩個變數分別指向頭結點與尾節點。
/** Head of CLH queue */
private transient volatile WNode whead;
/** Tail (last) of CLH queue */
private transient volatile WNode wtail;
另外有一個需要注意點就是 cowait, 儲存所有的讀節點資料,使用的是頭插法。
當讀寫執行緒競爭形成等待佇列的資料如下圖所示:
獲取寫鎖
public long writeLock() {
long s, next; // bypass acquireWrite in fully unlocked case only
return ((((s = state) & ABITS) == 0L &&
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
獲取寫鎖,如果獲取失敗則構建節點放入佇列,同時阻塞執行緒,需要注意的時候該方法不響應中斷,如需中斷需要呼叫 writeLockInterruptibly()
。否則會造成高 CPU 佔用的問題。
(s = state) & ABITS
標識讀鎖和寫鎖未被使用,那麼久直接執行 U.compareAndSwapLong(this, STATE, s, next = s + WBIT))
CAS 操作將第八位設定 1,標識寫鎖佔用成功。CAS失敗的話則呼叫 acquireWrite(false, 0L)
加入等待佇列,同時將執行緒阻塞。
另外acquireWrite(false, 0L)
方法很複雜,運用大量自旋操作,比如自旋入佇列。
獲取讀鎖
public long readLock() {
long s = state, next; // bypass acquireRead on common uncontended case
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
獲取讀鎖關鍵步驟
(whead == wtail && (s & ABITS) < RFULL
如果佇列為空並且讀鎖執行緒數未超過限制,則通過 U.compareAndSwapLong(this, STATE, s, next = s + RUNIT))
CAS 方式修改 state 標識獲取讀鎖成功。
否則呼叫 acquireRead(false, 0L)
嘗試使用自旋獲取讀鎖,獲取不到則進入等待佇列。
acquireRead
當 A 執行緒獲取了寫鎖,B 執行緒去獲取讀鎖的時候,呼叫 acquireRead 方法,則會加入阻塞佇列,並阻塞 B 執行緒。方法內部依然很複雜,大致流程梳理後如下:
- 如果寫鎖未被佔用,則立即嘗試獲取讀鎖,通過CAS修改狀態為標誌成功則直接返回。
- 如果寫鎖被佔用,則將當前執行緒包裝成 WNode 讀節點,並插入等待佇列。如果是寫執行緒節點則直接放入隊尾,否則放入隊尾專門存放讀執行緒的 WNode cowait 指向的棧。棧結構是頭插法的方式插入資料,最終喚醒讀節點,從棧頂開始。
釋放鎖
無論是 unlockRead
釋放讀鎖還是 unlockWrite
釋放寫鎖,總體流程基本都是通過 CAS 操作,修改 state 成功後呼叫 release 方法喚醒等待佇列的頭結點的後繼節點執行緒。
- 想將頭結點等待狀態設定為 0 ,標識即將喚醒後繼節點。
- 喚醒後繼節點通過CAS方式獲取鎖,如果是讀節點則會喚醒 cowait 鎖指向的棧所有讀節點。
釋放讀鎖
unlockRead(long stamp)
如果傳入的 stamp 與鎖持有的 stamp 一致,則釋放非排它鎖,內部主要是通過自旋 + CAS 修改 state 成功,在修改 state 之前做了判斷是否超過讀執行緒數限制,若是小於限制才通過CAS 修改 state 同步狀態,接著呼叫 release 方法喚醒 whead 的後繼節點。
釋放寫鎖
unlockWrite(long stamp)
如果傳入的 stamp 與鎖持有的 stamp 一致,則釋放寫鎖,whead 不為空,且當前節點狀態 status != 0 則呼叫 release 方法喚醒頭結點的後繼節點執行緒。
總結
StampedLock 並不能完全代替ReentrantReadWriteLock
,在讀多寫少的場景下因為樂觀讀的模式,允許一個寫執行緒獲取寫鎖,解決了寫執行緒飢餓問題,大大提高吞吐量。
在使用樂觀讀的時候需要注意按照程式設計模型模板方式去編寫,否則很容易造成死鎖或者意想不到的執行緒安全問題。
它不是可重入鎖,且不支援條件變數 Conditon
。並且執行緒阻塞在 readLock() 或者 writeLock() 上時,此時呼叫該阻塞執行緒的 interrupt() 方法,會導致 CPU 飆升。如果需要中斷執行緒的場景,一定要注意呼叫悲觀讀鎖 readLockInterruptibly() 和寫鎖 writeLockInterruptibly()。
另外喚醒執行緒的規則和 AQS 類似,先喚醒頭結點,不同的是 StampedLock
喚醒的節點是讀節點的時候,會喚醒此讀節點的 cowait 鎖指向的棧的所有讀節點,但是喚醒與插入的順序相反。
推薦閱讀
以下幾篇文章閱讀量與讀者反饋都很好,推薦大家閱讀:
參考內容