京東一面掛在了CAS演算法的三大問題上,痛定思痛不做同一個知識點的小丑

JavaBuild發表於2024-03-30

寫在開頭

在介紹synchronized關鍵字時,我們提到了鎖升級時所用到的CAS演算法,那麼今天我們就來好好學一學這個CAS演算法

CAS演算法對build哥來說,可謂是刻骨銘心,記得是研二去找實習的時候,當時對很多八股文的內容淺嘗輒止,很多深奧的知識點只是知道個概念,原始碼看的也不深,程式碼量也不夠,京東一面,面試官問了CAS演算法,大概的介紹了之後,他緊接著追問CAS的三大問題,在很多面試類書籍中背過ABA問題,然後就囫圇吞棗的答了這個,即便後面在面試官的引導下,也沒有說清楚其他兩個,最終遺憾敗北。

面試官當時給的面試表現是:只注重死記硬背,程式設計師是一個需要創造性的工作,而不是做一個筆者。回來難過了很久,從那時候起,就痛定思痛,大量的看原始碼,寫demo,爭取不做同一個知識點上的小丑!現在回想起來,仍然是一份激勵,不知道大家在面試時有沒有過窘迫,希望諸君能銘記於心,勉而勵之!

原子性問題

好了,廢話說太多了,現在進入正題!在之前的文章中調到了併發多執行緒的三大問題,其中之一就是原子性,講volatile關鍵字時,說到它可以保證有序性和可見性但無法保證原子性,啥是原子性呢?

原子性: 一個或者多個操作在 CPU 執行的過程中不被中斷的特性;

原子操作: 即最小不可拆分的操作,也就是說操作一旦開始,就不能被打斷,直到操作完成。

什麼時候原子性問題呢?引用我們之前寫過的案例。

【程式碼示例1】

public class Test {
    //計數變數
    static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        //執行緒 1 給 count 加 10000
        Thread t1 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t1 count 加 10000 結束");
        });
        //執行緒 2 給 count 加 10000
        Thread t2 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t2 count 加 10000 結束");
        });
        //啟動執行緒 1
        t1.start();
        //啟動執行緒 2
        t2.start();
        //等待執行緒 1 執行完成
        t1.join();
        //等待執行緒 2 執行完成
        t2.join();
        //列印 count 變數
        System.out.println(count);
    }
}

我們建立了2個執行緒,分別對count進行加10000操作,理論上最終輸出的結果應該是20000萬對吧,但實際並不是,我們看一下真實輸出。

輸出:

thread t1 count 加 10000 結束
thread t2 count 加 10000 結束
14281

原因:
Java 程式碼中 的 count++ ,至少需要三條CPU指令:

  • 指令 1:把變數 count 從記憶體載入到CPU的暫存器
  • 指令 2:在暫存器中執行 count + 1 操作
  • 指令 3:+1 後的結果寫入CPU快取或記憶體

即使是單核的 CPU,當執行緒 1 執行到指令 1 時發生執行緒切換,執行緒 2 從記憶體中讀取 count 變數,此時執行緒 1 和執行緒 2 中的 count 變數值是相等,都執行完指令 2 和指令 3,寫入的 count 的值是相同的。從結果上看,兩個執行緒都進行了 count++,但是 count 的值只增加了 1。這種情況多發生在cpu佔用時間較長的執行緒中,若單執行緒對count僅增加100,那我們就很難遇到執行緒的切換,得出的結果也就是200啦。
image

解決辦法:
可以透過JDK Atomic開頭的原子類、synchronized、LOCK解決多執行緒原子性問題,這其中Atomic開頭的原子類就是使用樂觀鎖的一種實現方式CAS演算法實現的,那麼在瞭解CAS演算法之前,我們還是要先來聊一聊樂觀鎖。

樂觀鎖與悲觀鎖

樂觀鎖與悲觀鎖是一組互反鎖。

悲觀鎖(Pessimistic Lock): 執行緒每次在處理共享資料時都會上鎖,其他執行緒想處理資料就會阻塞直到獲得鎖。如 synchronized、java.util.concurrent.locks.ReentrantLock;

【程式碼示例2】

public void testSynchronised() {
    synchronized (this) {
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

樂觀鎖(Optimistic Lock): 相對樂觀,執行緒每次在處理共享資料時都不會上鎖,在更新時會透過資料的版本號機制判斷其他執行緒有沒有更新資料,或透過CAS演算法實現,樂觀鎖適合讀多寫少的應用場景。

版本號機制:
所謂版本號機制,一般是在資料表中加上一個資料版本號 version 欄位,來記錄資料被修改的次數,執行緒讀取資料時,會把對應的version值也讀取下來,當發生更新時,會先將自己讀取的version值與資料表中的version值進行比較,如果相同才會更新,不同則表示有其他執行緒已經搶先一步更新成功,自己繼續嘗試。

CAS演算法:
CAS全稱為Compare And Swap(比較與交換),是一種演算法,更是一種思想,常用來實現樂觀鎖,通俗理解就是在更新資料前,先比較一下原資料與期待值是否一致,若一致說明過程中沒有其他執行緒更新過,則進行新值替換,否則更新失敗,但失敗的執行緒並不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的執行緒放棄操作。

兩種鎖的優缺點:

  • 樂觀鎖適用於讀多寫少的場景,可以省去頻繁加鎖、釋放鎖的開銷,提高吞吐量;
  • 在寫比較多的場景下,樂觀鎖會因為版本號不一致,不斷重試更新,產生大量自旋,消耗 CPU,影響效能。這種情況下,適合悲觀鎖。

CAS演算法

那麼CAS演算法是如何實現的呢?其實在Java中並沒有直接給與實現,而是透過JVM底層實現,底層依賴於一條 CPU 的原子指令。那我們在Java中怎麼使用,或者說哪裡準尋CAS的痕跡呢? 別急,跟著build哥繼續向下看!

我們在上面提到了JDK Atomic開頭的原子類可以解決原子性問題,那我們就跟進去一探究竟,首先,進入到 java.util.concurrent.atomic 中,裡面支援原子更新陣列、基本資料型別、引用、欄位等,如下圖:

image

現在,我們以比較常用的AtomicInteger為例,選取其getAndAdd(int delta)方法,看一下它的底層實現。

【原始碼解析1】

public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

這裡返回的時Unsafe類的getAndAddInt()方法,Unsafe類在sun.misc包中。我們繼續根據方法中看原始碼:

【原始碼解析2】

 public final int getAndAddInt(Object var1, long var2, int var4) {
     int var5;
     do {
         var5 = this.getIntVolatile(var1, var2);
     } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

     return var5;
 }

我們看一下方法中的引數含義

  • Object var1 :這個引數代表你想要進行操作的物件的起始地址,如:0x00000111。
  • long var2:這個引數是你想要操作的 var1物件中的偏移量。這個偏移量可以透過 Unsafe 類的 objectFieldOffset 方法獲得。通俗理解就是需要修改的具體記憶體地址:如100 ,0x0000011+100 = 0x0000111就是要修改的值的最終記憶體地址。
  • int var4 :這個引數是你想要增加的值。

首先,在這個方法中採用了do-while迴圈,透過getIntVolatile(var1, var2)獲取當前物件指定的欄位值,並將其存入var5中作為預期值,這裡的getIntVolatile方法可以保證讀取的可見性(禁止指令重拍和CPU快取,這個之前的文章裡解釋過,不然冗述);

然後,在while中呼叫了Unsafe類的compareAndSwapInt()方法,進行資料的CAS操作。其實在這個類中有好幾個CAS操作的實現方法

【原始碼解析3】

/**
  *  CAS
  * @param o         包含要修改field的物件
  * @param offset    物件中某field的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

這幾個方法都是native方法,相關的實現是透過 C++ 內聯彙編的形式實現的(JNI 呼叫),因此,和cpu與作業系統都有關係,這也是我們在上文中提到CAS失敗後,大量自旋帶來CPU消耗嚴重的原因。

繼續,我們回到compareAndSwapInt(var1, var2, var5, var5 + var4)方法中來,我們透過var1物件在var2記憶體地址上的值與先查到的預期值比較一致性,若相等,則將var5 + var4更新到對應地址上,返回true,否則不做任何操作返回false。

如果 CAS 操作成功,說明我們成功地將 var1 物件的 var2 偏移量處的欄位的值更新為 var5 + var4,並且這個更新操作是原子性的,因此我們跳出迴圈並返回原來的值 var5。

如果 CAS 操作失敗,說明在我們嘗試更新值的時候,有其他執行緒修改了該欄位的值,所以我們繼續迴圈,重新獲取該欄位的值,然後再次嘗試進行 CAS 操作。

注意: 以上是JDK1.8的原始碼,在JDK1.9後底層實現邏輯略有改動,增加了@HotSpotIntrinsicCandidate 註解,這個註解允許 HotSpot VM 自己來寫彙編或 IR 編譯器來實現該方法以提供更加的效能。

CAS帶來的三大問題

文章寫到這裡,終於進入了關鍵,CAS雖然作為一種不加鎖就可以實現高效同步的手段,但它並非完美,仍然存在著很多問題,主要分為三個,分別是:ABA問題長時間自旋多個共享變數的原子操作,這三個問題也是面試官提及CAS時常問的,希望大家能夠理解記住,避免像build哥初入職場時的尷尬!

ABA問題

這是CAS非常經典的問題,由於CAS是否執行成功,是需要將當前記憶體中的值與期望值做判斷,根據是否相等,來決定是否修改原值的,若一個變數V在初始時的值為A,在賦值前去記憶體中檢查它的值依舊是A,這時候我們就想當然認為它沒有變過,然後就繼續進行賦值操作了,很明顯這裡是有漏洞的,雖然賦值的操作用時可能很短,但在高併發時,這個A值仍然有可能被其他執行緒改為了B之後,又被改回了A,那對於我們最初的執行緒來說,是無法感知的。

很多人可能會問,既然這個變數從A->B->A,這個過程中,它不還是原來的值嗎,過程不同但結果依舊沒變呀,會導致什麼問題呢?我們看下面這個例子:

小明在提款機,提取了50元,因為提款機卡住了,小明點選後,又點選了一次,產生了兩個修改賬戶餘額的執行緒(可以看做是執行緒1和執行緒2),假設小明賬戶原本有100元,因此兩個執行緒同時執行把餘額從100變為50的操作。
執行緒1(提款機):獲取當前值100,期望更新為50。
執行緒2(提款機):獲取當前值100,期望更新為50。
執行緒1成功執行,CPU並沒有排程執行緒2執行,
這時,小華給小明轉賬50,這一操作產生了執行緒3,CPU排程執行緒3執行,這時候執行緒3成功執行,餘額變為100。之後,執行緒2被CPU排程執行,此時,獲取到的賬戶餘額是100,CAS操作成功執行,更新餘額為50!此時可以看到,實際餘額應該為100(100-50+50),但是實際上變為了50(100-50+50-50)。

這就是ABA問題帶來的錯誤,而對於一個銀行的提款機來說,發生這種問題可以說是災難性的,會大大降低客戶對於這家銀行的信任程度!

那有沒有什麼解決方案呢,答案是肯定的!在JDK 1.5 以後的 AtomicStampedReference 類就是用來解決 ABA 問題的,其中的 compareAndSet() 方法就是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

【原始碼解析4】

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

長時間自旋

我們在前面說過CAS適用於讀多寫少的場景,若被使用在寫多的場景,必然會產品大量的版本號不一致情況,從而導致很多執行緒自旋等待,這對CPU來說很糟糕,可以透過讓JVM 能支援處理器提供的 pause 指令,這樣對效率會有一定的提升。

PAUSE指令提升了自旋等待迴圈(spin-wait loop)的效能。當執行一個迴圈等待時,Intel P4或Intel Xeon處理器會因為檢測到一個可能的記憶體順序違規(memory order violation)而在退出迴圈時使效能大幅下降。PAUSE指令給處理器提了個醒:這段程式碼序列是個迴圈等待。處理器利用這個提示可以避免在大多數情況下的記憶體順序違規,這將大幅提升效能。因為這個原因,所以推薦在迴圈等待中使用PAUSE指令。

多個共享變數的原子操作

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

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

總結

關於CAS演算法以及其存在的三大問題到這裡就說完了,現在再回頭來看,京東這道面試題很簡單,然而由於當年的不努力變成了一種遺憾說出,希望小夥伴們能夠引以為戒!

結尾彩蛋

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

image

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

image

相關文章