四個知識點帶你輕鬆掌握執行緒飢餓利器 StampedLock
目錄
前言
在 JDK 1.8 引入 StampedLock,可以理解為對 ReentrantReadWriteLock 在某些方面的增強,在原先讀寫鎖的基礎上新增了一種叫樂觀讀(Optimistic Reading)的模式。該模式並不會加鎖,所以不會阻塞執行緒,會有更高的吞吐量和更高的效能。
- 有了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_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 鎖指向的棧的所有讀節點,但是喚醒與插入的順序相反。
文章到此就結束了!
以下是小編整理的一份大廠真題的面試資料,以及阿里的面試集錦,需要領取的小夥伴可以 點我 免費領取 ,程式設計的世界永遠向所有熱愛程式設計的人開放,這是一個自由,平等,共享的世界,我始終是這樣堅信的。
部分資料圖片:
喜歡小編的分享可以點贊關注哦,小編持續為你分享最新文章 和 福利領取哦
相關文章
- 高效能解決執行緒飢餓利器 StampedLock執行緒
- 執行緒基本知識點執行緒
- 執行緒池知識點詳解執行緒
- Thread執行緒知識點講解thread執行緒
- 建立執行緒知識點總結執行緒
- 多執行緒(三)、執行緒池 ThreadPoolExecutor 知識點總結執行緒thread
- 【Java多執行緒】輕鬆搞定Java多執行緒(二)Java執行緒
- 多執行緒基礎知識點梳理執行緒
- 三、執行緒池知識點整理筆記執行緒筆記
- Java常見知識點彙總(⑬)——執行緒Java執行緒
- 多執行緒基礎必要知識點!看了學習多執行緒事半功倍執行緒
- java多執行緒中的死鎖、活鎖、飢餓、無鎖都是什麼鬼?Java執行緒
- Java常見知識點彙總(⑭)——執行緒池Java執行緒
- Java多執行緒學習(五)執行緒間通訊知識點補充Java執行緒
- Android 基礎知識——執行緒Android執行緒
- 多執行緒基礎知識執行緒
- 前置知識—程式和執行緒執行緒
- Python程式與執行緒知識Python執行緒
- 為什麼?它能驅趕你的飢餓?”
- 【Mycat】Mycat核心開發者帶你輕鬆掌握Mycat路由轉發!!路由
- 帶你十天輕鬆搞定 Go 微服務系列(四)Go微服務
- 一文讓你輕鬆掌握 HTTPSHTTP
- 手寫一個執行緒池,帶你學習ThreadPoolExecutor執行緒池實現原理執行緒thread
- 達夢資料庫基礎知識(四)管理DM執行緒資料庫執行緒
- Nuxt.js必讀:輕鬆掌握執行時配置與 useRuntimeConfigUXJS
- C++知識點:對於多執行緒的總結C++執行緒
- Thread執行緒的基礎知識及常見疑惑點thread執行緒
- 執行緒學習知識總結執行緒
- Java多執行緒相關知識Java執行緒
- java自帶的四種執行緒池Java執行緒
- EventBus,輕鬆實現跨元件跨執行緒通訊元件執行緒
- FAO&WFP:2022年“飢餓熱點”報告
- Concurrency(十一: 飢餓與公平)
- [譯]輕鬆十步走,帶你領略JS呼叫棧的執行原理JS
- 清明花了幾天總結了多執行緒的知識點執行緒
- python中shell執行知識點Python
- 記錄Java執行緒相關知識Java執行緒
- 多執行緒之初識執行緒執行緒