【漫畫】讀寫鎖ReadWriteLock還是不夠快?再試試StampedLock!

本文來源於公眾號【胖滾豬學編程】 轉載請註明出處!

互斥鎖ReentrantLock不好用?試試讀寫鎖ReadWriteLock一文中,我們對比了互斥鎖ReentrantLock和讀寫鎖ReadWriteLock的區別,說明了讀寫鎖在讀多寫少的場景下具有明顯的性能優勢,但是人的慾望是無窮的,還是不能被滿足。。

StampedLock1

數據庫中的鎖

由於大部分碼農接觸鎖都是從數據庫中的鎖開始的,所以這裡不妨先聊聊數據庫中的鎖。

我們以火車票售票的例子,假設如下場景,兩處火車票售票點同時讀取某一趟列車車票數據庫中的余票數量,然後兩處售票點同時賣出一張車票,同時修改余票為 X -1,寫回數據庫,這樣就造成了實際賣出兩張火車票而數據庫中的記錄卻只減少了一張。

如果你閱讀了公眾號【胖滾豬學編程】的並發系列文章,包括:如何解決原子性問題ReentrantLock互斥鎖讀寫鎖ReadWriteLock,那麼你一定知道出現原因和解決方案,對了,可以使用鎖

鎖可以分為兩大類,樂觀鎖和悲觀鎖:

  • 悲觀鎖:顧名思義,就是很悲觀,總是假設最壞的情況,每次去拿數據的時候都認為別人會修改, 所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖。
  • 樂觀鎖:樂觀鎖,每次去拿數據的時候想法都是「沒事,肯定沒被改過」,於是就開心地獲取到數據,不放心嗎?那就在更新的時候判斷一下在此期間別人有沒有去更新過這個數據,可以使用版本號等機制。

一般情況下,數據庫都會有讀共享寫獨佔的鎖並發的方案,也就是說讀讀並發是沒問題的,但在讀寫並發時,則有可能出現讀取不一致情況,也就是常說的臟讀,所以在悲觀鎖的模式下,在有寫線程的時候,是不允許有任何其他的讀和寫線程的,也就是說寫是獨佔的,這樣會導致系統的吞吐明顯下降。我們所說的ReadWriteLock的寫鎖就屬於悲觀鎖。

如何避免這一情況,答案是使用樂觀鎖。每個線程都不會修改原始數據,而是從原始數據上拷貝上一份數據,同時記錄版本號,不同的線程更新自己的數據,在最終寫會時會判斷版本號是否變更,如果變更則意味有人已經更改過了,那麼當前線程需要做的就是自旋重試,如果重試指定的次數依然失敗,那麼就應該放棄更新,這種策略僅僅適合寫並發並不強烈的場景,如果寫競爭嚴重,那麼多次自旋重試的開銷也是非常耗性能的,如果競爭激烈,那麼寫鎖獨佔的方式則更加適合。

那麼具體怎麼使用版本號機制呢?

很簡單,對數據庫表添加了一個version字段,設置為bigint類型。查詢的時候我們需要查出版本信息,更新的時候,需要將版本信息+1。

1.查詢數據信息
select xxx,version from xxx where id= #{id}
2.根據數據信息是判斷當前數據庫中的version是否還是剛才查出來的那個version
update xxx set xxx=xxx ,version = version+1 where id=#{id} and version= #{version};

由於update指定了where條件,可根據返回修改記錄條數來判斷當前更新是否生效,如果成功改動0條數據,說明version發生了變更,這時候可以根據自己業務邏輯來決定是否需要回滾事務。

數據庫里的樂觀鎖,查詢的時候需要把 version 字段查出來,更新的時候要利用 version 字段做驗證。這個 version 字段就類似於今天我們要說的 StampedLock 裏面的 stamp。基於上面談到的這些內容,我們再來分析StampedLock類,就會非常比較容易理解。

本文來源於公眾號【胖滾豬學編程】 以漫畫形式讓編程so easy and interesting !轉載請註明出處!

StampedLock

Java 在 1.8 這個版本里,提供了一種叫 StampedLock 的鎖,它的性能比讀寫鎖還要好。

對比ReadWriteLock

我們先來看看StampedLock 和上一篇文章講的 ReadWriteLock 有哪些區別。

ReadWriteLock 支持兩種模式:一種是讀鎖,一種是寫鎖。而 StampedLock 支持三種模式,分別是:寫鎖、悲觀讀鎖和樂觀讀

其中,寫鎖、悲觀讀鎖的語義和 ReadWriteLock 的寫鎖、讀鎖的語義非常類似,允許多個線程同時獲取悲觀讀鎖,但是只允許一個線程獲取寫鎖,寫鎖和悲觀讀鎖是互斥的。

不同的是:StampedLock 里的寫鎖和悲觀讀鎖加鎖成功之後,都會返回一個 stamp;然後解鎖的時候,需要傳入這個 stamp,這裡的stamp就類似剛剛我們說的數據庫version,相信你已經明白了。

我們通過代碼演示一下寫鎖、悲觀讀鎖是如何使用的:

    // 鎖實例
    private final StampedLock sl = new StampedLock();

    // 排它鎖-寫鎖
    void writeLock() {
        long stamp = sl.writeLock();//獲取寫鎖
        try {
          // 業務邏輯
        } finally {
            sl.unlockWrite(stamp);//釋放寫鎖
        }
    }

    // 悲觀讀鎖
    void readLock() {
        long stamp = sl.readLock();
        try {
          // 業務邏輯
        } finally {
            sl.unlockRead(stamp);
        }
    }

樂觀讀

StampedLock 的性能之所以比 ReadWriteLock 還要好,其關鍵是 StampedLock 支持樂觀讀的方式。所謂樂觀讀,即讀的時候也能允許一個線程獲取寫鎖,也就是說不是所有的寫操作都被阻塞,自然而然的會比所有寫都阻塞性能要強。

還是通過代碼來說明一下樂觀讀是如何使用的:

    // 樂觀讀
    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();//(1)
        double currentX = x, currentY = y;

        // 檢查在(1)獲取到讀鎖票據後,鎖有沒被其他寫線程排它性搶佔
        if (!sl.validate(stamp)) {
            // 如果被搶佔則獲取一個共享讀鎖(悲觀讀鎖)
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX*currentX + currentY*currentY);
    }

tryOptimisticRead() 就是我們前面提到的樂觀讀。不過需要注意的是,由於 tryOptimisticRead() 是無鎖的,所以共享變量 x 和 y 讀入方法局部變量時,x 和 y 有可能被其他線程修改了。因此最後讀完之後,還需要再次驗證一下是否存在寫操作,這個驗證操作是通過調用 validate(stamp) 來實現的。

還有一個巧妙的地方:如果執行樂觀讀操作的期間,存在寫操作,會把樂觀讀升級為悲觀讀鎖。這個做法挺合理的,否則你就需要在一個循環里反覆執行樂觀讀,直到執行樂觀讀操作的期間沒有寫操作(只有這樣才能保證 x 和 y 的正確性和一致性),而循環讀會浪費大量的 CPU。升級為悲觀讀鎖,代碼簡練且不易出錯。

StampedLock2

鎖的升級

在上一篇讀寫鎖文章中,我們說到鎖的升級和降級,ReadWriteLock是只允許降級而不允許升級的,而StampedLock 支持鎖的降級(通過 tryConvertToReadLock() 方法實現)和升級(通過 tryConvertToWriteLock() 方法實現),讀鎖居然也可以升級為寫鎖,這也是它區別於讀寫鎖的一大特性!

    // 讀鎖升級成寫鎖
    void moveIfAtOrigin(double newX, double newY) { // upgrade
        // Could instead start with optimistic, not read mode
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                // 嘗試將獲取的讀鎖升級為寫鎖
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    // 讀鎖升級寫鎖失敗則釋放讀鎖,顯示獲取獨佔寫鎖,然後循環重試
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }

StampedLock 使用注意事項

StampedLock真有這麼完美嗎?挑刺時間又來咯!

1、StampedLock 在命名上並沒有增加 Reentrant,顯然,StampedLock 不支持重入。這個是在使用中必須要特別注意的。

2、StampedLock 的悲觀讀鎖、寫鎖都不支持條件變量(Condition),這個也需要你注意。

3、使用 StampedLock 一定不要調用中斷操作,即不要調用interrupt() 方法,因為內部實現里while循環裏面對中斷的處理有點問題。如果需要支持中斷功能,一定使用可中斷的悲觀讀鎖 readLockInterruptibly() 和寫鎖 writeLockInterruptibly()。

總結

如何解決原子性問題為起點,我們初始了鎖的概念,了解了synchronized鎖模型,之後又走進了J.U.C Lock包,首先接觸到了ReentrantLock互斥鎖,由於互斥鎖在讀多寫少場景的效率不高,因此接觸了讀寫鎖ReadWriteLock,而今天,又學習了一種比讀寫鎖還要快的鎖StampedLock。說明JAVA真是博大精深,連鎖都有那麼多種,需要根據實際情況合理選擇才是!

關於StampedLock,重點應該了解它獨特的思想:樂觀的思想。就像人一樣,不能總是悲觀思想,樂觀思想積極面對生活效率才更高!StampedLock通過一個叫做stamp的類似於數據庫版本號的字段,實現了樂觀讀。當然永遠樂觀也是不行的,StampedLock也有它的缺陷,對於這些,你也需要特別注意。

本文來源於公眾號【胖滾豬學編程】 以漫畫形式讓編程so easy and interesting !轉載請註明出處!

本文轉載自公眾號【胖滾豬學編程】 用漫畫讓編程so easy and interesting!歡迎關注!形象來源於微信表情包【胖滾家族】喜歡可以下載哦~