樂觀鎖CAS

N1ce2cu發表於2024-07-19

在 Java 中,我們可以使用 synchronized

關鍵字和 CAS 來實現加鎖效果。

悲觀鎖:

  • 對於悲觀鎖來說,它總是認為每次訪問共享資源時會發生衝突,所以必須對每次資料操作加上鎖,以保證臨界區的程式同一時間只能有一個執行緒在執行。

  • synchronized 是悲觀鎖,儘管隨著 JDK 版本的升級,synchronized 關鍵字已經“輕量級”了很多,但依然是悲觀鎖,執行緒開始執行第一步就要獲取鎖,一旦獲得鎖,其他的執行緒進入後就會阻塞並等待鎖。

  • 悲觀鎖多用於寫多讀少的環境,避免頻繁失敗和重試影響效能。

樂觀鎖:

  • 樂觀鎖總是假設對共享資源的訪問沒有衝突,執行緒可以不停地執行,無需加鎖也無需等待。一旦多個執行緒發生衝突,樂觀鎖通常使用一種稱為 CAS 的技術來保證執行緒執行的安全性。
  • CAS 是樂觀鎖,執行緒執行的時候不會加鎖,它會假設此時沒有衝突,然後完成某項操作;如果因為衝突失敗了就重試,直到成功為止。
  • 由於樂觀鎖假想操作中沒有鎖的存在,因此不太可能出現死鎖的情況,換句話說,樂觀鎖天生免疫死鎖
  • 樂觀鎖多用於讀多寫少的環境,避免頻繁加鎖影響效能。

什麼是 CAS


在 CAS 中,有這樣三個值:

  • V:要更新的變數(var)
  • E:預期值(expected),本質上指的是“舊值”
  • N:新值(new)

比較並交換的過程如下:

判斷 V 是否等於 E,如果等於,將 V 的值設定為 N;如果不等,說明已經有其它執行緒更新了 V,於是當前執行緒放棄更新,什麼都不做。

CAS 是一種原子操作,它是一種系統原語,是一條 CPU 的原子指令,從 CPU 層面已經保證它的原子性。

當多個執行緒同時使用 CAS 操作一個變數時,只有一個會勝出,併成功更新,其餘均會失敗,但失敗的執行緒並不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的執行緒放棄操作。

CAS 的原理


在 Java 中,有一個Unsafe類,它在sun.misc包中。它裡面都是一些native方法,其中就有幾個是關於 CAS 的:

boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);

Unsafe 對 CAS 的實現是透過 C++ 實現的,它的具體實現和作業系統、CPU 都有關係。

Linux 的 X86 下主要是透過cmpxchgl這個指令在 CPU 上完成 CAS 操作的,但在多處理器情況下,必須使用lock指令加鎖來完成。當然,不同的作業系統和處理器在實現方式上肯定會有所不同。

CMPXCHG是“Compare and Exchange”的縮寫,它是一種原子指令,用於在多核/多執行緒環境中安全地修改共享資料。CMPXCHG在很多現代微處理器體系結構中都有,例如Intel x86/x64體系。對於32位運算元,這個指令通常寫作CMPXCHG,而在64位運算元中,它被稱為CMPXCHG8B或CMPXCHG16B。

除了上面提到的方法,Unsafe 裡面還有其它的方法。比如支援執行緒掛起和恢復的parkunpark 方法,LockSupport底層就呼叫了這兩個方法。還有支援反射操作的allocateInstance()方法。

CAS 如何實現原子操作


JDK 提供了一些用於原子操作的類,在java.util.concurrent.atomic包下面。

AtomicInteger類的getAndAdd(int delta)方法為例:

private static final Unsafe unsafe = Unsafe.getUnsafe();

public final int getAndAdd(int delta) {
    // 呼叫的 Unsafe 類的方法
    return unsafe.getAndAddInt(this, valueOffset, delta);
}
// var1:想要進行操作的物件。
// var2:要操作的 var1 物件中的某個欄位的偏移量。這個偏移量可以透過 Unsafe 類的 objectFieldOffset 方法獲得。
// var4:要增加的值。
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        // 獲取當前物件指定欄位的值
        // getIntVolatile 方法能保證讀操作的可見性,即讀取的結果是最新的寫入結果,不會因為 JVM 的最佳化策略(如指令重排序)或者 CPU 的快取導致讀取到過期的資料
        var5 = this.getIntVolatile(var1, var2);
        // 如果物件 var1 在記憶體地址 var2 處的值等於預期值 var5,則將該位置的值更新為 var5 + var4,並返回 true;否則,不做任何操作並返回 false。
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
  • Object var1:想要進行操作的物件。
  • long var2:要操作的 var1 物件中的某個欄位的偏移量。這個偏移量可以透過 Unsafe 類的 objectFieldOffset 方法獲得。
  • int var4:要增加的值。

JDK 9 及其以後版本中,getAndAddInt 方法和 JDK 8 中的實現有所不同,我們就拿 JDK 11 的原始碼來做一個對比吧:

@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}

這個方法上面增加了 @HotSpotIntrinsicCandidate 註解。這個註解允許 HotSpot VM 自己來寫彙編或 IR 編譯器來實現該方法以提供更加的效能。

IR(Intermediate Representation)是一種用於幫助最佳化編譯器的中間程式碼表示方法。編譯器通常將原始碼首先轉化為 IR,然後對 IR 進行各種最佳化,最後將最佳化後的 IR 轉化為目的碼。在 JVM(Java Virtual Machine)中,JIT(Just-In-Time)編譯器將 Java 位元組碼(即.class 檔案的內容)轉化為 IR,然後對 IR 進行最佳化,最後將 IR 編譯為機器碼。這個過程在 Java 程式執行時進行,因此被稱為“即時編譯”。JVM 中的 C1 和 C2 編譯器就是 IR 編譯器。C1 編譯器在編譯時進行一些簡單的最佳化,然後快速地將 IR 編譯為機器碼。C2 編譯器在編譯時進行更深入的最佳化,以獲得更高的執行效率,但編譯的時間也相對更長。

也就是說,雖然表面上看到的是 weakCompareAndSet 和 compareAndSet,但是不排除 HotSpot VM 會手動來實現 weakCompareAndSet 真正功能的可能性。

簡單來說,weakCompareAndSet 操作僅保留了volatile 自身變數的特性,而除去了 happens-before 規則帶來的記憶體語義。換句話說,weakCompareAndSet無法保證處理操作目標的 volatile 變數外的其他變數的執行順序(編譯器和處理器為了最佳化程式效能而對指令序列進行重新排序),同時也無法保證這些變數的可見性。 但這在一定程度上可以提高效能。

CAS 的三大問題


ABA 問題

所謂的 ABA 問題,就是一個值原來是 A,變成了 B,又變回了 A。這個時候使用 CAS 是檢查不出變化的,但實際上卻被更新了兩次。

ABA 問題的解決思路是在變數前面追加上版本號或者時間戳。從 JDK 1.5 開始,JDK 的 atomic 包裡提供了一個類AtomicStampedReference類來解決 ABA 問題。

// 這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果二者都相等,才使用 CAS 設定為新的值和標誌。
public boolean compareAndSet(V   expectedReference,// 預期引用,也就是認為原本應該在那個位置的引用
                              V   newReference,// 新引用,如果預期引用正確,將被設定到該位置的新引用。
                              int expectedStamp,// 預期標記
                              int newStamp) {// 新標記
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
          casPair(current, Pair.of(newReference, newStamp)));
}

執行流程:

①、Pair<V> current = pair; 這行程式碼獲取當前的 pair 物件,其中包含了引用和標記。

②、接下來的 return 語句做了幾個檢查:

  • expectedReference == current.reference && expectedStamp == current.stamp:首先檢查當前的引用和標記是否和預期的引用和標記相同。如果二者中有任何一個不同,這個方法就會返回 false。
  • 如果上述檢查透過,也就是說當前的引用和標記與預期的相同,那麼接下來就會檢查新的引用和標記是否也與當前的相同。如果相同,那麼實際上沒有必要做任何改變,這個方法就會返回 true。
  • 如果新的引用或者標記與當前的不同,那麼就會呼叫 casPair 方法來嘗試更新 pair 物件。casPair 方法會嘗試用 newReference 和 newStamp 建立的新的 Pair 物件替換當前的 pair 物件。如果替換成功,casPair 方法會返回 true;如果替換失敗(也就是說在嘗試替換的過程中,pair 物件已經被其他執行緒改變了),casPair 方法會返回 false。

長時間自旋

CAS 多與自旋結合。如果自旋 CAS 長時間不成功,會佔用大量的 CPU 資源。

解決思路是讓 JVM 支援處理器提供的 pause 指令。

pause 指令能讓自旋失敗時 cpu 睡眠一小段時間再繼續自旋,從而使得讀操作的頻率降低很多,為解決記憶體順序衝突而導致的 CPU 流水線重排的代價也會小很多。

多個共享變數的原子操作

當對一個共享變數執行操作時,CAS 能夠保證該變數的原子性。但是對於多個共享變數,CAS 就無法保證操作的原子性,這時通常有兩種做法:

  1. 使用AtomicReference類保證物件之間的原子性,把多個變數放到一個物件裡面進行 CAS 操作;
  2. 使用鎖。鎖內的臨界區程式碼可以保證只有當前執行緒能操作。

相關文章