1. 程式人生 > 實用技巧 >10 CAS與原子操作

10 CAS與原子操作

10 CAS與原子操作

第十章 樂觀鎖和悲觀鎖

10.1 樂觀鎖與悲觀鎖的概念

鎖可以從不同的角度分類。其中,樂觀鎖和悲觀鎖是一種分類方式。

悲觀鎖:

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

樂觀鎖:

樂觀鎖又稱為“無鎖”,顧名思義,它是樂觀派。樂觀鎖總是假設對共享資源的訪問沒有衝突,執行緒可以不停地執行,無需加鎖也無需等待。而一旦多個執行緒發生衝突,樂觀鎖通常是使用一種稱為CAS的技術來保證執行緒執行的安全性。

由於無鎖操作中沒有鎖的存在,因此不可能出現死鎖的情況,也就是說樂觀鎖天生免疫死鎖

樂觀鎖多用於“讀多寫少“的環境,避免頻繁加鎖影響效能;而悲觀鎖多用於”寫多讀少“的環境,避免頻繁失敗和重試影響效能。

10.2 CAS的概念

CAS的全稱是:比較並交換(Compare And Swap)。在CAS中,有這樣三個值:

  • V:要更新的變數(var)
  • E:預期值(expected)
  • N:新值(new)

比較並交換的過程如下:

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

所以這裡的預期值E本質上指的是“舊值”

我們以一個簡單的例子來解釋這個過程:

  1. 如果有一個多個執行緒共享的變數i原本等於5,我現在線上程A中,想把它設定為新的值6;
  2. 我們使用CAS來做這個事情;
  3. 首先我們用i去與5對比,發現它等於5,說明沒有被其它執行緒改過,那我就把它設定為新的值6,此次CAS成功,i的值被設定成了6;
  4. 如果不等於5,說明i被其它執行緒改過了(比如現在i的值為2),那麼我就什麼也不做,此次CAS失敗,i的值仍然為2。

在這個例子中,i就是V,5就是E,6就是N。

那有沒有可能我在判斷了i為5之後,正準備更新它的新值的時候,被其它執行緒更改了i的值呢?

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

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

10.3 Java實現CAS的原理 - Unsafe類

前面提到,CAS是一種原子操作。那麼Java是怎樣來使用CAS的呢?我們知道,在Java中,如果一個方法是native的,那Java就不負責具體實現它,而是交給底層的JVM使用c或者c++去實現。

在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);

當然,他們都是public native的。

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

Linux的X86下主要是通過cmpxchgl這個指令在CPU級完成CAS操作的,但在多處理器情況下必須使用lock指令加鎖來完成。當然不同的作業系統和處理器的實現會有所不同,大家可以自行了解。

當然,Unsafe類裡面還有其它方法用於不同的用途。比如支援執行緒掛起和恢復的parkunpark, LockSupport類底層就是呼叫了這兩個方法。還有支援反射操作的allocateInstance()方法。

10.4 原子操作-AtomicInteger類原始碼簡析

上面介紹了Unsafe類的幾個支援CAS的方法。那Java具體是如何使用這幾個方法來實現原子操作的呢?

JDK提供了一些用於原子操作的類,在java.util.concurrent.atomic包下面。在JDK 11中,有如下17個類:

原子類

從名字就可以看得出來這些類大概的用途:

  • 原子更新基本型別
  • 原子更新陣列
  • 原子更新引用
  • 原子更新欄位(屬性)

這裡我們以AtomicInteger類的getAndAdd(int delta)方法為例,來看看Java是如何實現原子操作的。

先看看這個方法的原始碼:

public final int getAndAdd(int delta) {
    return U.getAndAddInt(this, VALUE, delta);
}

這裡的U其實就是一個Unsafe物件:

private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();

所以其實AtomicInteger類的getAndAdd(int delta)方法是呼叫Unsafe類的方法來實現的:

@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;
}

注:這個方法是在JDK 1.8才新增的。在JDK1.8之前,AtomicInteger原始碼實現有所不同,是基於for死迴圈的,有興趣的讀者可以自行了解一下。

我們來一步步解析這段原始碼。首先,物件othis,也就是一個AtomicInteger物件。然後offset是一個常量VALUE。這個常量是在AtomicInteger類中宣告的:

private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");

同樣是呼叫的Unsafe的方法。從方法名字上來看,是得到了一個物件欄位偏移量。

用於獲取某個欄位相對Java物件的“起始地址”的偏移量。

一個java物件可以看成是一段記憶體,各個欄位都得按照一定的順序放在這段記憶體裡,同時考慮到對齊要求,可能這些欄位不是連續放置的,

用這個方法能準確地告訴你某個欄位相對於物件的起始記憶體地址的位元組偏移量,因為是相對偏移量,所以它其實跟某個具體物件又沒什麼太大關係,跟class的定義和虛擬機器的記憶體模型的實現細節更相關。

繼續看原始碼。前面我們講到,CAS是“無鎖”的基礎,它允許更新失敗。所以經常會與while迴圈搭配,在失敗後不斷去重試。

這裡聲明瞭一個v,也就是要返回的值。從getAndAddInt來看,它返回的應該是原來的值,而新的值的v + delta

這裡使用的是do-while迴圈。這種迴圈不多見,它的目的是保證迴圈體內的語句至少會被執行一遍。這樣才能保證return 的值v是我們期望的值。

迴圈體的條件是一個CAS方法:

public final boolean weakCompareAndSetInt(Object o, long offset,
                                          int expected,
                                          int x) {
    return compareAndSetInt(o, offset, expected, x);
}

public final native boolean compareAndSetInt(Object o, long offset,
                                             int expected,
                                             int x);

可以看到,最終其實是呼叫的我們之前說到了CAS native方法。那為什麼要經過一層weakCompareAndSetInt呢?從JDK原始碼上看不出來什麼。在JDK 8及之前的版本,這兩個方法是一樣的。

而在JDK 9開始,這兩個方法上面增加了@HotSpotIntrinsicCandidate註解。這個註解允許HotSpot VM自己來寫彙編或IR編譯器來實現該方法以提供效能。也就是說雖然外面看到的在JDK9中weakCompareAndSet和compareAndSet底層依舊是呼叫了一樣的程式碼,但是不排除HotSpot VM會手動來實現weakCompareAndSet真正含義的功能的可能性。

根據本文第一篇參考文章(文末連結),它跟volitile有關。

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

再回到迴圈條件上來,可以看到它是在不斷嘗試去用CAS更新。如果更新失敗,就繼續重試。那為什麼要把獲取“舊值”v的操作放到迴圈體內呢?其實這也很好理解。前面我們說了,CAS如果舊值V不等於預期值E,它就會更新失敗。說明舊的值發生了變化。那我們當然需要返回的是被其他執行緒改變之後的舊值了,因此放在了do迴圈體內。

10.5 CAS實現原子操作的三大問題

這裡介紹一下CAS實現原子操作的三大問題及其解決方案。

10.5.1 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)));
}

10.5.2 迴圈時間長開銷大

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

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

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

10.5.3 只能保證一個共享變數的原子操作

這個問題你可能已經知道怎麼解決了。有兩種解決方案:

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