面試官:素有Java鎖王稱號的‘StampedLock’你知道嗎?我:這什麼鬼?

JavaBuild發表於2024-04-29

一、寫在開頭


我們在上一篇寫ReentrantReadWriteLock讀寫鎖的末尾留了一個小坑,那就是讀寫鎖因為寫鎖的悲觀性,會導致 “寫飢餓”,這樣一來會大大的降低讀寫效率,而今天我們就來將此坑填之!填坑工具為:StampedLock,一個素有Java鎖王稱號的同步類,也是在 java.util.concurrent.locks 包中。

需要宣告的是,這個類在Java的面試過程中極少被問及,如果僅僅是為了準備面試的話,這部分內容可以忽略,但這個類的實現邏輯還是值得一學的。


二、StampedLock 是什麼?


StampedLock是由Java8時引入的一個效能更好的讀寫鎖,作者:Doug Lea,支援讀鎖、寫鎖,這與ReentrantReadWriteLock類似,但同時多了一個樂觀讀鎖的實現,這一點直接提升了它的效能。


三、StampedLock的原理


雖然StampedLock效能更好,但是!不可重入且不支援條件變數 Condition,且並沒有直接實現Lock或者ReadWriteLock介面,而是與AQS類似的採用CLH(Craig, Landin, and Hagersten locks)作為底層實現。

在Java的官方docs中對於它進行了如下的描述:

image

並且官方還提供了一個示例,我們來看一下:

class Point {
	//共享變數
   private double x, y;
   private final StampedLock sl = new StampedLock();

   // 寫鎖的使用
   void move(double deltaX, double deltaY) {
     long stamp = sl.writeLock(); //涉及對共享資源的修改,使用寫鎖-獨佔操作
     try {
       x += deltaX;
       y += deltaY;
     } finally {
       sl.unlockWrite(stamp); // 釋放寫鎖
     }
   }

  	/**
     * 使用樂觀讀鎖訪問共享資源
     * 注意:樂觀讀鎖在保證資料一致性上需要複製一份要操作的變數到方法棧,並且在運算元據時候					可能其他寫執行緒已經修改了資料,
     * 而我們操作的是方法棧裡面的資料,也就是一個快照,所以最多返回的不是最新的資料,但是一致性還是得到保障的。
     *
     * @return
     */
   double distanceFromOrigin() {
     long stamp = sl.tryOptimisticRead(); // 獲取樂觀讀鎖
     double currentX = x, currentY = y;	// 複製共享資源到本地方法棧中
     if (!sl.validate(stamp)) { // //檢查樂觀讀鎖後是否有其他寫鎖發生,有則返回false
        stamp = sl.readLock(); // 獲取一個悲觀讀鎖
        try {
          currentX = x;
          currentY = y;
        } finally {
           sl.unlockRead(stamp); // 釋放悲觀讀鎖
        }
     }
     return Math.sqrt(currentX * currentX + currentY * currentY);
   }

   // 悲觀讀鎖以及讀鎖升級寫鎖的使用
   void moveIfAtOrigin(double newX, double newY) {
     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 的底層提供了三種鎖

  1. 寫鎖: 獨佔鎖,一把鎖只能被一個執行緒獲得。當一個執行緒獲取寫鎖後,其他請求讀鎖和寫鎖的執行緒必須等待。類似於 ReentrantReadWriteLock 的寫鎖,不過這裡的寫鎖是不可重入的。
  2. 讀鎖 (悲觀讀):共享鎖,沒有執行緒獲取寫鎖的情況下,多個執行緒可以同時持有讀鎖。如果己經有執行緒持有寫鎖,則其他執行緒請求獲取該讀鎖會被阻塞。類似於 ReentrantReadWriteLock 的讀鎖,不過這裡的讀鎖是不可重入的。
  3. 樂觀讀 :允許多個執行緒獲取樂觀讀以及讀鎖。同時允許一個寫執行緒獲取寫鎖。

【原始碼示例】

// 寫鎖
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));
}
// 讀鎖
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));
}
// 樂觀讀
public long tryOptimisticRead() {
    long s;
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

StampedLock 在獲取鎖的時候會返回一個 long 型的資料戳,該資料戳用於稍後的鎖釋放引數,如果返回的資料戳為 0 則表示鎖獲取失敗。當前執行緒持有了鎖再次獲取鎖還是會返回一個新的資料戳,這也是StampedLock不可重入的原因。此外,在官網給的示例中我們也看到了,StampedLock 還支援這3種鎖的轉換:

long tryConvertToWriteLock(long stamp){}
long tryConvertToReadLock(long stamp){}
long tryConvertToOptimisticRead(long stamp){}

內部常量說明

在原始碼中我們看到,無論哪種鎖,在獲取的時候都會返回一個long型別的時間戳,這其實就是StampedLock命名的由來,而這個時間戳的第8位用來標識寫鎖,前 7 位(LG_READERS)來表示讀鎖,每獲取一個悲觀讀鎖,就加 1(RUNIT),每釋放一個悲觀讀鎖,就減 1。而悲觀讀鎖最多隻能裝 128 個(7 位限制),很容易溢位,所以用一個 int 型別的變數來儲存溢位的悲觀讀鎖。

image


四、StampedLock的使用


結果上面的StampedLock特性和官方的示例,我們寫一個小demo來感受一下它的使用,需要注意的是在獲取樂觀鎖時,如果有寫鎖改變資料時,為保證資料一致性,要切換為普通的讀鎖模式。

【測試示例】

public class Test {

    private final StampedLock sl = new StampedLock();
    private int data = 0;

    public void write(int value) {
        long stamp = sl.writeLock();
        try {
            data = value;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public int read() {
        long stamp = sl.tryOptimisticRead();
        int currentData = data;
        // 如果有寫鎖被佔用,可能造成資料不一致,所以要切換到普通讀鎖模式
        if (!sl.validate(stamp)) {
            stamp = sl.readLock();
            try {
                currentData = data;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return currentData;
    }

    public static void main(String[] args) {
        Test test = new Test();

        Thread writer = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                test.write(i);
                System.out.println("當前執行緒" + Thread.currentThread().getName() + ":Write: " + i);
            }
        });

        Thread reader = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                int value = test.read();
                System.out.println("當前執行緒" + Thread.currentThread().getName() + ":Read: " + value);
            }
        });

        writer.start();
        reader.start();
    }
}

輸出:

當前執行緒Thread-0:Write: 0
當前執行緒Thread-0:Write: 1
當前執行緒Thread-1:Read: 0
當前執行緒Thread-0:Write: 2
當前執行緒Thread-1:Read: 2
當前執行緒Thread-0:Write: 3
當前執行緒Thread-1:Read: 3
當前執行緒Thread-0:Write: 4
當前執行緒Thread-1:Read: 4
當前執行緒Thread-1:Read: 4

五、總結

相比於傳統讀寫鎖多出來的樂觀讀是StampedLock比 ReadWriteLock 效能更好的關鍵原因。StampedLock 的樂觀讀允許一個寫執行緒獲取寫鎖,所以不會導致所有寫執行緒阻塞,也就是當讀多寫少的時候,寫執行緒有機會獲取寫鎖,減少了執行緒飢餓的問題,吞吐量大大提高。

不過,需要注意的是StampedLock不可重入,不支援條件變數 Condition,對中斷操作支援也不友好(使用不當容易導致 CPU 飆升)。如果你需要用到 ReentrantLock 的一些高階效能,就不太建議使用 StampedLock 了。

六、結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!
image

相關文章