高效能解決執行緒飢餓利器 StampedLock

碼哥位元組 發表於 2020-09-22
作者:碼哥位元組 公眾號:碼哥位元組

如需轉載請聯絡我(微信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提供了讀鎖和寫鎖相互轉換的功能,使得該類支援更多的應用場景。

注意事項

  1. StampedLock是不可重入鎖,如果當前執行緒已經獲取了寫鎖,再次重複獲取的話就會死鎖;
  2. 都不支援 Conditon 條件將執行緒等待;
  3. 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 的子集,在使用的時候,還是有幾個地方需要注意一下。

  1. StampedLock 是不可重入鎖,使用過程中一定要注意;
  2. 悲觀讀、寫鎖都不支援條件變數 Conditon ,當需要這個特性的時候需要注意;
  3. 如果執行緒阻塞在 StampedLock 的 readLock() 或者 writeLock() 上時,此時呼叫該阻塞執行緒的 interrupt() 方法,會導致 CPU 飆升。所以,使用 StampedLock 一定不要呼叫中斷操作,如果需要支援中斷功能,一定使用可中斷的悲觀讀鎖 readLockInterruptibly() 和寫鎖 writeLockInterruptibly()。這個規則一定要記清楚。

原理分析

StapedLock區域性變數

我們發現它並不像其他鎖一樣通過定義內部類繼承 AbstractQueuedSynchronizer抽象類然後子類實現模板方法實現同步邏輯。但是實現思路還是有類似,依然使用了 CLH 佇列來管理執行緒,通過同步狀態值 state 來標識鎖的狀態。

其內部定義了很多變數,這些變數的目的還是跟 ReentrantReadWriteLock 一樣,將狀態為按位切分,通過位運算對 state 變數操作用來區分同步狀態。

比如寫鎖使用的是第八位為 1 則表示寫鎖,讀鎖使用 0-7 位,所以一般情況下獲取讀鎖的執行緒數量為 1-126,超過以後,會使用 readerOverflow int 變數儲存超出的執行緒數。

自旋優化

對多核 CPU 也進行一定優化,NCPU 獲取核數,當核數目超過 1 的時候,執行緒獲取鎖的重試、入隊錢的重試都有自旋操作。主要就是通過內部定義的一些變數來判斷,如圖所示。

等待佇列

佇列的節點通過 WNode 定義,如上圖所示。等待佇列的節點相比 AQS 更簡單,只有三種狀態分別是:

  • 0:初始狀態;
  • -1:等待中;
  • 取消;

另外還有一個欄位 cowait ,通過該欄位指向一個棧,儲存讀執行緒。結構如圖所示

WNode

同時定義了兩個變數分別指向頭結點與尾節點。

/** 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 執行緒。方法內部依然很複雜,大致流程梳理後如下:

  1. 如果寫鎖未被佔用,則立即嘗試獲取讀鎖,通過CAS修改狀態為標誌成功則直接返回。
  2. 如果寫鎖被佔用,則將當前執行緒包裝成 WNode 讀節點,並插入等待佇列。如果是寫執行緒節點則直接放入隊尾,否則放入隊尾專門存放讀執行緒的 WNode cowait 指向的棧。棧結構是頭插法的方式插入資料,最終喚醒讀節點,從棧頂開始。

釋放鎖

無論是 unlockRead 釋放讀鎖還是 unlockWrite釋放寫鎖,總體流程基本都是通過 CAS 操作,修改 state 成功後呼叫 release 方法喚醒等待佇列的頭結點的後繼節點執行緒。

  1. 想將頭結點等待狀態設定為 0 ,標識即將喚醒後繼節點。
  2. 喚醒後繼節點通過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 鎖指向的棧的所有讀節點,但是喚醒與插入的順序相反。

推薦閱讀

以下幾篇文章閱讀量與讀者反饋都很好,推薦大家閱讀:

MageByte

參考內容

[Java多執行緒進階(十一)—— J.U.C之locks框架:StampedLock]