CAS原理

zsh發表於2020-08-05

CAS的定義

  • JDK 1.5的時候,Java支援了Atomic類,這些類的操作都屬於原子操作;
  • 幫助最大限度地減少在多執行緒場景中對於一些基本操作的複雜性;
  • 而Atomic類的實現都依賴與 CAS(compare and swap) 演算法

樂觀鎖和悲觀鎖

悲觀鎖

常見的悲觀鎖

  • 獨佔鎖:synchronized

悲觀鎖的實現

  • 在多執行緒的場景下,當執行緒數量大於CPU數的時候,就需要進行分片處理,即把CPU的時間片,分配給不同的執行緒輪流執行,也就是我們常說的多執行緒的併發執行(間隔性執行);
  • 在某個執行緒執行完時間片之後,就會進行CPU切換;切換過程:清空暫存器和快取資料,重新載入新的執行緒所需要的資料(執行緒私有的:JVM執行時所需的資料:程式計數器、虛擬機器棧和本地方法棧);
  • 此時之前的執行緒就被掛起,加入到阻塞佇列中,在一定的時間和條件下,通過呼叫 notify() 或 notifyAll() 喚醒,進入就緒狀態(runnable),等待CPU排程。

總結:

  • 總是假設最壞的情況,並且只有在確保其他執行緒不會造成干擾的情況下才會執行;
  • 導致其他所有需要鎖的執行緒掛起,等待持有鎖的執行緒釋放鎖,再次進行鎖的競爭(公平鎖 / 非公平鎖)。
缺點
  • 執行緒會被頻繁的掛起,喚醒之後需要重新搶佔鎖
  • 場景:如果一個執行緒需要某個資源,但是這個資源的佔用時間很短,當執行緒第一次搶佔這個資源時,可能這個資源被佔用,如果此時掛起這個執行緒,但是可能立刻就發現資源已被剛才的執行緒的釋放,然後又需要花費很長的時間重新搶佔鎖,時間代價就會非常的高。

樂觀鎖

思路

  • 每次不加鎖而是假設 沒有衝突 而去完成某種 操作,如果因為衝突失敗就重試,直到成功為止;
  • 當某個執行緒不讓出CPU時,當前執行緒就一直 while 迴圈去嘗試獲取鎖,如果失敗就重試,直到成功為止;
  • 適用場景:當資料爭用不嚴重時,樂觀鎖效果更好。

樂觀鎖應用

CAS

CAS(Compare and Swap)演算法

非阻塞演算法

核心引數

  • 主記憶體中存放的 V 值:執行緒共享(對應JVM的共享記憶體區:堆和方法區)
  • 執行緒上次從記憶體中讀取的 V值 A:執行緒私有,存放線上程的棧幀中
  • 需要寫入記憶體中並改寫V值的B:執行緒對A值進行操作之後的值,想要存入主記憶體V中

  • V值在主記憶體中儲存,所有執行緒在對V值操作時,需要先從主存中讀取V值到執行緒到工作記憶體A中;
  • 然後對A值操作之後,把結果儲存在B中,最後再把B值賦給主記憶體的V;
  • 多執行緒共用V值都是這樣的過程;
  • 核心點:將B值賦給V之前,先比較A值和V值是否相等(判斷,當前執行緒在計算過程中,是否有其他執行緒已經修改了V值),不相等表示此時的V值已經被其他執行緒改變,需要重新執行上面的過程;相等就將B值賦給V。

如果沒有CAS這樣的機制,那在多執行緒的場景下,兩個執行緒同時對共享資料進行修改,很容易就出錯,導致某一個執行緒的修改被忽略。

核心程式碼

//原子操作
if (A == V) {
	V = B;
	return B;
} else {
	return V;
}

變數 V

如何保證執行緒每次操作V時,都去主記憶體中獲取 V 值?

  • 自然是在CAS的實現程式碼中,變數 V是用 volatile原語 修飾的:保證其可見性;

如何保證在進行 V 和 A 值比較相等之後,而在 B值賦給 V 之前,V值不會被其他執行緒所修改?

也就是說如何保證比較和替換這兩個步驟的原子性?

看一下CAS原理:

CAS原理

  • 底層的實現其實時通過呼叫 JNI(Java Native Interface 本地呼叫)的程式碼實現的;(其實 JNI 就是為了支援 Java 去呼叫其他語言)

具體看一下:AtomicInteger類的 compareAndSet() 方法:

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

呼叫的時 sun.misc 包下 Unsafe 類的 compareAndSwapInt()方法:(本地方法)

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

本地方法會呼叫 JDK中的幾個cpp檔案(這是在widowsX86中的,不同作業系統呼叫的檔案不同,檔案中的實現方式也不同);

具體的C++程式碼我就不貼了,知道上層的實現思路就行;

底層大致思路:通過對 cmpxchg 指令新增 lock 字首(windowsX86中的實現),確保對主記憶體中 V 值的 讀-改-寫操作原子執行;

不同的作業系統有不同的實現方式,但大致的思路是類似的,目的也都是保證比較賦值操作是原子操作。

更詳細的底層實現,以及相關的一些關於CPU鎖的內容,可以去看一下《Java 併發程式設計藝術》。

最終的實現作用:

  • 確保對主記憶體中 V 值的 讀-改-寫操作原子執行;
  • 禁止該指令與之前和之後的讀(比較)和寫(賦值)指令重排序。

CAS缺點

ABA問題

問題描述

CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。

解決方案

版本號
  • ABA問題的解決思路就是使用版本號;
  • 在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A
AtomicStampedReference

compareAndSet() 方法原始碼:

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)));
}
  • 跟其他的Atomic類不同的是, 這個多了一個引用的比較;
  • 這個會先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的設定為給定的更新值;
  • 所以如果有其他執行緒修改了, 那麼引用就會改變, 當前執行緒就執行失敗重試操作;

迴圈時間長

問題描述

因為CAS機制是: 一直進行失敗重試, 直到成功為止, 那麼自旋CAS 如果長時間不成功, 就會給 CPU 帶來非常大的執行開銷;

解決方案

  • 在 JVM 中去支援處理器提供的 pause 指令那麼效率會有一定的提升;
  • pause指令的作用:
    • 延遲流水線執行指令(de-pipeline), 使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零;
    • 避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

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

問題描述

當對一個共享變數執行操作時,我們可以使用迴圈 CAS 的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性了

解決方案

  • 加鎖
  • 把多個共享變數合併成一個共享變數或者把多個變數放在一個物件中, 用 AtomicReference 類來進行CAS操作

總結

  • Java中的CAS操作: 結合 volatile 實現 (volatile 讀和 volatile 寫)

  • 而Java中的 CAS 最終呼叫的是 處理器提供的高效機器級別的原子指令, 這些原子指令 保證 以原子的方式 對主記憶體中的值 執行 讀-寫-改等一系列操作;

  • 所以最本質的就是支援原子性 讀-寫-改指令的計算機

Java 中的 JUC

  • volatile 變數的 讀/寫 + CAS 實現執行緒之間的通訊
  • 這其實就是 concurrent 包可以實現的基礎, JUC包下原始碼實現的通用模式:
    • 宣告共享變數為 volatile
    • 使用CAS的原子條件更新來實現執行緒之間的同步;
    • 配合以 volatile 的 讀/寫 和 CAS 所具有的 volatile 讀和寫的記憶體語義來實現執行緒之間的通訊。
  • AQS,非阻塞資料結構 和 原子變數類(java.util.concurrent.atomic包中的類),這些 concurrent 包中的基礎類都是使用這種模式來實現的,而 concurrent 包中的高層類又是依賴於這些基礎類來實現的.

JUC包實現的示意圖:

img

相關文章