Java CAS 原理詳解

huansky發表於2022-01-09

1. 背景

在JDK 5之前Java語言是靠 synchronized 關鍵字保證同步的,這會導致有鎖。鎖機制存在以下問題:

  • 在多執行緒競爭下,加鎖、釋放鎖會導致比較多的上下文切換和排程延時,引起效能問題。

  • 一個執行緒持有鎖會導致其它所有需要此鎖的執行緒掛起。

  • 如果一個優先順序高的執行緒等待一個優先順序低的執行緒釋放鎖會導致優先順序倒置,引起效能風險。

Volatile關鍵字能夠在併發條件下,強制將修改後的值重新整理到主記憶體中來保持記憶體的可見性。通過 CPU記憶體屏障禁止編譯器指令性重排來保證併發操作的有序性

如果多個執行緒同時操作 Volatile 修飾的變數,也會造成資料的不一致。

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }    
        while(Thread.activeCount()>1)
            Thread.yield();
        System.out.println(test.inc);
    }
}

事實上執行它會發現每次執行結果都不一致,都是一個小於10000的數字。

假如某個時刻變數 inc 的值為10:

  • 執行緒1對變數進行自增操作,執行緒1先讀取了變數inc的原始值,然後執行緒1被阻塞了;

  • 然後執行緒2對變數進行自增操作,執行緒2也去讀取變數inc的原始值,由於執行緒1只是對變數inc進行讀取操作,而沒有對變數進行修改操作,所以不會導致執行緒2的工作記憶體中快取變數inc的快取行無效,所以執行緒2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作記憶體,最後寫入主存。

  • 然後執行緒1接著進行加1操作,由於已經讀取了inc的值,注意此時線上程1的工作記憶體中inc的值仍然為10,所以執行緒1對inc進行加1操作後inc的值為11,然後將11寫入工作記憶體,最後寫入主存。

  • 那麼兩個執行緒分別進行了一次自增操作後,inc只增加了1。

之所以出現還是 volatile 只是保證讀寫具有原子性,但是對於 ++ 操作的複合操作是不存在原子操作的。只能在有限的一些情形下使用 volatile 變數替代鎖。要使 volatile 變數提供理想的執行緒安全,比如:對變數的寫操作不依賴於當前值。

volatile 是不錯的機制,但是 volatile 不能保證原子性。因此對於同步最終還是要回到鎖機制上來。

獨佔鎖是一種悲觀鎖,synchronized 就是一種獨佔鎖,會導致其它所有需要鎖的執行緒掛起,等待持有鎖的執行緒釋放鎖。而另一個更加有效的鎖就是樂觀鎖。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止。樂觀鎖用到的機制就是 CAS,Compare and Swap。

2. CAS 原理

CAS 全稱是 compare and swap,是一種用於在多執行緒環境下實現同步功能的機制。CAS 操作包含三個運算元 -- 記憶體位置、預期數值和新值。CAS 的實現邏輯是將記憶體位置處的數值與預期數值想比較,若相等,則將記憶體位置處的值替換為新值。若不相等,則不做任何操作。

在 Java 中,Java 並沒有直接實現 CAS,CAS 相關的實現是通過 C++ 內聯彙編的形式實現的。Java 程式碼需通過 JNI 才能呼叫。

CAS 是一條 CPU 的原子指令(cmpxchg指令),不會造成所謂的資料不一致問題,Unsafe 提供的 CAS 方法(如compareAndSwapXXX)底層實現即為 CPU 指令 cmpxchg

對 java.util.concurrent.atomic 包下的原子類 AtomicInteger 中的 compareAndSet 方法進行分析,相關分析如下:

public class AtomicInteger extends Number implements java.io.Serializable {    
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();    
    private static final long valueOffset;   
    static {       
        try {            
            // 計算變數 value 在類物件中的偏移
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }    

private volatile int value; public final boolean compareAndSet(int expect, int update) {

/** * compareAndSet 實際上只是一個殼子,主要的邏輯封裝在 Unsafe 的 * compareAndSwapInt 方法中 */ return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } // ...... }   public final class Unsafe { // compareAndSwapInt 是 native 型別的方法,繼續往下看 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); // ...... }

 


// unsafe.cpp
/*
 * 這個看起來好像不像一個函式,不過不用擔心,不是重點。UNSAFE_ENTRY 和 UNSAFE_END 都是巨集,
 * 在預編譯期間會被替換成真正的程式碼。下面的 jboolean、jlong 和 jint 等是一些型別定義(typedef):
 * 
 * jni.h
 *     typedef unsigned char   jboolean;
 *     typedef unsigned short  jchar;
 *     typedef short           jshort;
 *     typedef float           jfloat;
 *     typedef double          jdouble;
 * 
 * jni_md.h
 *     typedef int jint;
 *     #ifdef _LP64 // 64-bit
 *     typedef long jlong;
 *     #else
 *     typedef long long jlong;
 *     #endif
 *     typedef signed char jbyte;
 */
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);  

  // 根據偏移量,計算 value 的地址。這裡的 offset 就是 AtomaicInteger 中的 valueOffset
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);  

  // 呼叫 Atomic 中的函式 cmpxchg,該函式宣告於 Atomic.hpp 中
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

 atomic.cppunsigned Atomic::cmpxchg(unsigned int exchange_value,  volatile unsigned int* dest, unsigned int compare_value) {
  assert(sizeof(unsigned int) == sizeof(jint), "more work to do");  

  /*
   * 根據作業系統型別呼叫不同平臺下的過載函式,這個在預編譯期間編譯器會決定呼叫哪個平臺下的過載
   * 函式。相關的預編譯邏輯如下:
   * 
   * atomic.inline.hpp:
   *    #include "runtime/atomic.hpp"
   *    
   *    // Linux
   *    #ifdef TARGET_OS_ARCH_linux_x86
   *    # include "atomic_linux_x86.inline.hpp"
   *    #endif
   *   
   *    // 省略部分程式碼
   *    
   *    // Windows
   *    #ifdef TARGET_OS_ARCH_windows_x86
   *    # include "atomic_windows_x86.inline.hpp"
   *    #endif
   *    
   *    // BSD
   *    #ifdef TARGET_OS_ARCH_bsd_x86
   *    # include "atomic_bsd_x86.inline.hpp"
   *    #endif
   * 
   * 接下來分析 atomic_windows_x86.inline.hpp 中的 cmpxchg 函式實現
   */
  return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,  (jint)compare_value);
}

上面的分析看起來比較多,不過主流程並不複雜。如果不糾結於程式碼細節,還是比較容易看懂的。接下來,我會分析 Windows 平臺下的 Atomic::cmpxchg 函式。繼續往下看吧。

// atomic_windows_x86.inline.hpp
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
              inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

上面的程式碼由 LOCK_IF_MP 預編譯識別符號和 cmpxchg 函式組成。為了看到更清楚一些,我們將 cmpxchg 函式中的 LOCK_IF_MP 替換為實際內容。如下:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {  
  // 判斷是否是多核 CPU
  int mp = os::is_MP();
  __asm {    // 將引數值放入暫存器中
    mov edx, dest    // 注意: dest 是指標型別,這裡是把記憶體地址存入 edx 暫存器中
    mov ecx, exchange_value
    mov eax, compare_value    
    // LOCK_IF_MP
    cmp mp, 0
    /*
     * 如果 mp = 0,表明是執行緒執行在單核 CPU 環境下。此時 je 會跳轉到 L0 標記處,
     * 也就是越過 _emit 0xF0 指令,直接執行 cmpxchg 指令。也就是不在下面的 cmpxchg 指令
     * 前加 lock 字首。
     */
    je L0    /*
     * 0xF0 是 lock 字首的機器碼,這裡沒有使用 lock,而是直接使用了機器碼的形式。至於這樣做的
     * 原因可以參考知乎的一個回答:
     *     https://www.zhihu.com/question/50878124/answer/123099923
     */ 
    _emit 0xF0L0:    /*
     * 比較並交換。簡單解釋一下下面這條指令,熟悉彙編的朋友可以略過下面的解釋:
     *   cmpxchg: 即“比較並交換”指令
     *   dword: 全稱是 double word,在 x86/x64 體系中,一個 
     *          word = 2 byte,dword = 4 byte = 32 bit
     *   ptr: 全稱是 pointer,與前面的 dword 連起來使用,表明訪問的記憶體單元是一個雙字單元
     *   [edx]: [...] 表示一個記憶體單元,edx 是暫存器,dest 指標值存放在 edx 中。
     *          那麼 [edx] 表示記憶體地址為 dest 的記憶體單元
     *          
     * 這一條指令的意思就是,將 eax 暫存器中的值(compare_value)與 [edx] 雙字記憶體單元中的值
     * 進行對比,如果相同,則將 ecx 暫存器中的值(exchange_value)存入 [edx] 記憶體單元中。
     */
    cmpxchg dword ptr [edx], ecx
  }
}

 

到這裡 CAS 的實現過程就講完了,CAS 的實現離不開處理器的支援。如上面原始碼所示,程式會根據當前處理器的型別來決定是否為 cmpxchg 指令新增 lock 字首。如果程式是在多處理器上執行,就為 cmpxchg 指令加上 lock 字首(lock cmpxchg)。反之,如果程式是在單處理器上執行,就省略 lock 字首(單處理器自身會維護單處理器內的順序一致性,不需要 lock 字首提供的記憶體屏障效果)。

intel 的手冊對 lock 字首的說明如下:

  • 確保對記憶體的讀 - 改 - 寫操作原子執行。在 Pentium 及 Pentium 之前的處理器中,帶有 lock 字首的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從 Pentium 4,Intel Xeon 及 P6 處理器開始,intel 在原有匯流排鎖的基礎上做了一個很有意義的優化:如果要訪問的記憶體區域(area of memory)在 lock 字首指令執行期間已經在處理器內部的快取中被鎖定(即包含該記憶體區域的快取行當前處於獨佔或以修改狀態),並且該記憶體區域被完全包含在單個快取行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該快取行會一直被鎖定,其它處理器無法讀 / 寫該指令要訪問的記憶體區域,因此能保證指令執行的原子性。這個操作過程叫做快取鎖定(cache locking),快取鎖定將大大降低 lock 字首指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的記憶體地址未對齊時,仍然會鎖住匯流排。

  • 禁止該指令與之前和之後的讀和寫指令重排序。

  • 把寫緩衝區中的所有資料重新整理到記憶體中。

上面的第 2 點和第 3 點所具有的記憶體屏障效果,足以同時實現 volatile 讀和 volatile 寫的記憶體語義。

經過上面的這些分析,現在我們終於能明白為什麼 JDK 文件說 CAS 同時具有 volatile 讀和 volatile 寫的記憶體語義了。

Java 的 CAS 會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對記憶體執行讀 - 改 - 寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支援原子性讀 - 改 - 寫指令的計算機器,是順序計算圖靈機的非同步等價機器,因此任何現代的多處理器都會去支援某種能對記憶體執行原子性讀 - 改 - 寫操作的原子指令)。同時,volatile 變數的讀 / 寫和 CAS 可以實現執行緒之間的通訊。把這些特性整合在一起,就形成了整個 concurrent 包得以實現的基石。如果我們仔細分析 concurrent 包的原始碼實現,會發現一個通用化的實現模式:

  • 首先,宣告共享變數為 volatile;

  • 然後,使用 CAS 的原子條件更新來實現執行緒之間的同步;

  • 同時,配合以 volatile 的讀 / 寫和 CAS 所具有的 volatile 讀和寫的記憶體語義來實現執行緒之間的通訊。

AQS,非阻塞資料結構和原子變數類(java.util.concurrent.atomic 包中的類),這些 concurrent 包中的基礎類都是使用這種模式來實現的,而 concurrent 包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent 包的實現示意圖如下:

JVM中的CAS(堆中物件的分配): 

Java 呼叫 new object() 會建立一個物件,這個物件會被分配到 JVM 的堆中。那麼這個物件到底是怎麼在堆中儲存的呢?

首先,new object() 執行的時候,這個物件需要多大的空間,其實是已經確定的,因為 java 中的各種資料型別,佔用多大的空間都是固定的(對其原理不清楚的請自行Google)。那麼接下來的工作就是在堆中找出那麼一塊空間用於存放這個物件。

在單執行緒的情況下,一般有兩種分配策略:

  1. 指標碰撞:這種一般適用於記憶體是絕對規整的(記憶體是否規整取決於記憶體回收策略),分配空間的工作只是將指標像空閒記憶體一側移動物件大小的距離即可。

  2. 空閒列表:這種適用於記憶體非規整的情況,這種情況下JVM會維護一個記憶體列表,記錄哪些記憶體區域是空閒的,大小是多少。給物件分配空間的時候去空閒列表裡查詢到合適的區域然後進行分配即可。

但是JVM不可能一直在單執行緒狀態下執行,那樣效率太差了。由於再給一個物件分配記憶體的時候不是原子性的操作,至少需要以下幾步:查詢空閒列表、分配記憶體、修改空閒列表等等,這是不安全的。解決併發時的安全問題也有兩種策略:

  1. CAS:實際上虛擬機器採用CAS配合上失敗重試的方式保證更新操作的原子性,原理和上面講的一樣。

  2. TLAB:如果使用CAS其實對效能還是會有影響的,所以 JVM 又提出了一種更高階的優化策略:每個執行緒在 Java 堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝區(TLAB),執行緒內部需要分配記憶體時直接在 TLAB 上分配就行,避免了執行緒衝突。只有當緩衝區的記憶體用光需要重新分配記憶體的時候才會進行CAS操作分配更大的記憶體空間。

虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來進行配置(jdk5及以後的版本預設是啟用TLAB的)。

3. CAS存在的問題

3.1 ABA 問題

談到 CAS,基本上都要談一下 CAS 的 ABA 問題。CAS 由三個步驟組成,分別是“讀取-比較-寫回”。考慮這樣一種情況,執行緒1和執行緒2同時執行 CAS 邏輯,兩個執行緒的執行順序如下:

  • 時刻1:執行緒1執行讀取操作,獲取原值 A,然後執行緒被切換走

  • 時刻2:執行緒2執行完成 CAS 操作將原值由 A 修改為 B

  • 時刻3:執行緒2再次執行 CAS 操作,並將原值由 B 修改為 A

  • 時刻4:執行緒1恢復執行,將比較值(compareValue)與原值(oldValue)進行比較,發現兩個值相等。

然後用新值(newValue)寫入記憶體中,完成 CAS 操作

如上流程,執行緒1並不知道原值已經被修改過了,在它看來並沒什麼變化,所以它會繼續往下執行流程。對於 ABA 問題,通常的處理措施是對每一次 CAS 操作設定版本號。

ABA問題的解決思路其實也很簡單,就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A了。

java.util.concurrent.atomic 包下提供了一個可處理 ABA 問題的原子類 AtomicStampedReference,

從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。 

3.2 迴圈時間長開銷大

自旋CAS(不成功,就一直迴圈執行,直到成功) 如果長時間不成功,會給 CPU 帶來非常大的執行開銷。如果JVM能支援處理器提供的 pause 指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使 CPU 不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起 CPU 流水線被清空(CPU pipeline flush),從而提高 CPU 的執行效率。 

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

當對一個共享變數執行操作時,我們可以使用迴圈 CAS 的方式來保證原子操作,但是對多個共享變數操作時,迴圈 CAS 就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數 i=2,j=a,合併一下 ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了 AtomicReference 類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行 CAS 操作。

CAS 與 Synchronized 的使用情景:   

  1. 對於資源競爭較少(執行緒衝突較輕)的情況,使用synchronized同步鎖進行執行緒阻塞和喚醒切換以及使用者態核心態間的切換操作額外浪費消耗cpu資源;而CAS基於硬體實現,不需要進入核心,不需要切換執行緒,操作自旋機率較少,因此可以獲得更高的效能。

  2. 對於資源競爭嚴重(執行緒衝突嚴重)的情況,CAS自旋的概率會比較大,從而浪費更多的CPU資源,效率低於synchronized。

補充: synchronized 在 jdk1.6 之後,已經改進優化。synchronized 的底層實現主要依靠 Lock-Free 的佇列,基本思路是自旋後阻塞,競爭切換後繼續競爭鎖,稍微犧牲了公平性,但獲得了高吞吐量。線上程衝突較少的情況下,可以獲得和 CAS 類似的效能;而執行緒衝突嚴重的情況下,效能遠高於 CAS。

其他

什麼是happen-before

JMM 可以通過 happens-before 關係向程式設計師提供跨執行緒的記憶體可見性保證(如果 A 執行緒的寫操作 a 與 B 執行緒的讀操作 b 之間存在 happens-before 關係,儘管 a 操作和 b 操作在不同的執行緒中執行,但 JMM 向程式設計師保證 a 操作將對 b 操作可見)。

具體的定義為:

  • 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

  • 兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

具體的規則:

  1. 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。

  2. 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

  3. volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

  4. 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

  5. start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。

  6. Join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

  7. 程式中斷規則:對執行緒interrupted()方法的呼叫先行於被中斷執行緒的程式碼檢測到中斷時間的發生。

  8. 物件finalize規則:一個物件的初始化完成(建構函式執行結束)先行於發生它的finalize()方法的開始。

該段描述摘自《happen-before原則》;原文連結:https://blog.csdn.net/ma_chen_qq/article/details/82990603

volatile

volatile修飾的變數變化過程:

  • 第一:使用 volatile 關鍵字會強制將修改的值立即寫入主存;

  • 第二:使用 volatile 關鍵字的話,當執行緒 2 進行修改時,會導致執行緒1的工作記憶體中快取變數的快取行無效;

  • 第三:由於執行緒1的工作記憶體中快取變數的快取行無效,所以執行緒 1 再次讀取變數的值時會去主存讀取。

可見性和原子性:

  • 可見性:對一個 volatile 變數的讀,總是能看到(任意執行緒)對這個 volatile 變數最後的寫入。

  • 原子性:對任意單個 volatile 變數的讀/寫具有原子性,但類似於 volatile++ 這種複合操作不具有原子性。

 

參考文獻

CAS原理分析

volatile不具有原子性的理解之解讀i++疑惑

Java CAS 原理分析

相關文章