概覽
在 JDK 1.8 引入 StampedLock,可以理解為對 ReentrantReadWriteLock 在某些方面的增強,在原先讀寫鎖的基礎上新增了一種叫樂觀讀(Optimistic Reading)的模式,該模式并不會加鎖,所以不會阻塞執行緒,會有更高的吞吐量和更高的性能,
跟著“碼哥位元組”帶著問題一起來看StampedLock給我們帶來了什么…
- 有了ReentrantReadWriteLock,為何還要引入StampedLock?
- 什么是樂觀讀?
- 在讀多寫少的并發場景下,StampedLock如何解決寫執行緒難以獲取鎖的執行緒“饑餓”問題?
- 什么樣的場景使用?
- 實作原理分析,是通過 AQS 實作還是其他的?
特性
它的設計初衷是作為一個內部工具類,用于開發其他執行緒安全的組件,提升系統性能,并且編程模型也比ReentrantReadWriteLock 復雜,所以用不好就很容易出現死鎖或者執行緒安全等莫名其妙的問題,
三種訪問資料模式:
- 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_docwhere id = 123
在更新的時候匹配 version 才執行更新,
select id,... ,version from product_docwhere id = 123
資料庫的樂觀鎖就是查詢的時候將 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 = https://www.cnblogs.com/jiagoushijuzi/archive/2020/09/23/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 鎖指向的堆疊的所有讀節點,但是喚醒與插入的順序相反,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/115041.html
標籤:其他
