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