[併發程式設計]-關於 CAS 的幾個問題

glmapper發表於2019-04-20

CAS 相關基礎知識

CAS的全稱是Compare And Swap ,即比較交換。CAS 中一般會設計到3個引數:

  • 記憶體值 V
  • 舊的預期值A
  • 要修改的新值B

當且僅當預期值 A 和記憶體值 V 相同時,將記憶體值V修改為 B,否則什麼都不做。

這裡關於 CPU 指令對於 CAS 的支援不深入研究,有興趣的可以自行了解。

CAS 幾個問題

很多書籍和文章中都有提出它存在的幾個問題:

  • 1、迴圈時間長開銷很大
  • 2、只能保證一個共享變數的原子操作
  • 3、ABA 問題

下面就這三個問題展開來聊一下。

1、關於“迴圈時間長開銷很大”的疑惑與驗證

自旋 CAS 如果長時間不成功,會給 CPU 帶來非常大的開銷。但是真的是這樣嗎?到底多大的併發量才造成 CAS 的自旋次數會增加呢?另外,對於當前的機器及JDK,在無鎖,無CAS 的情況下,是否對於結果的影響是真的那麼明顯呢?對於這個問題,下面做了一個簡單的測試,但是測試結果也只是針對在我本地環境下,各位看官可以拉一下程式碼,在自己電腦上 run 一下,把機器資訊、JDK版本以及測試結果留言到評論區。

本文案例可以這裡獲取:glmapper-blog-sample-cas

這裡我是用了一個很簡單的案例,就是整數自增。使用了兩種方式去測試的,一種是無鎖,也不用 CAS 操作,另外一種是基於 CAS 的方式。(關於加鎖的方式沒有驗證,有時間再補充吧~)

計數器類

計數器裡面有兩個方法,一種是CAS 自旋方式,一種是直接自增。程式碼如下:

public class Counter {
    public AtomicInteger safeCount = new AtomicInteger(0);
    public int unsafe = 0;
    // 使用自旋的方式
    public void safeCount(){
        for (;;){
            int i = safeCount.get();
            boolean success = safeCount.compareAndSet(i,++i);
            if (success){
                break;
            }
        }
    }
    // 普通方式自增
    public void unsafeCount(){
        unsafe++;
    }
}
複製程式碼

模擬併發

這裡我們模擬使用 1000 個執行緒,執行 30 次來看下結果,包括總耗時和結果的正確性。

  • CAS 方式
public static int testSafe() throws InterruptedException {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    // 例項化一個 Counter 計數器物件
    Counter counter = new Counter();
    CountDownLatch countDownLatch = new CountDownLatch(testCounts);
    for (int i =0 ;i < testCounts;i++){
        new Thread(()->{
                // 呼叫 safeCount 方法
                counter. safeCount();
                countDownLatch.countDown();
        }).start();
    }
    countDownLatch.await();
    // 結束時間
    long end = System.currentTimeMillis();
    safeTotalCostTime += (end-start);
    return counter.safeCount.get();
}
複製程式碼
  • 普通方式
public static int testUnSafe() throws InterruptedException {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    // 例項化一個 Counter 計數器物件
    Counter counter = new Counter();
    CountDownLatch countDownLatch = new CountDownLatch(testCounts);
    for (int i =0 ;i< testCounts;i++){
        new Thread(()->{
            // 呼叫 unsafeCount 方法
            counter.unsafeCount();
            countDownLatch.countDown();
        }).start();
    }
    countDownLatch.await();
    // 結束時間
    long end = System.currentTimeMillis();
    unsafeTotalCostTime += (end-start);
    return counter.unsafe;
}
複製程式碼
  • main 方法
public static void main(String[] args) throws InterruptedException {
    // 執行 300 次
    for (int i =0 ;i< 300;i++){
        // 普通方式
        int unSafeResult = testUnSafe();
        // cas 方式
        int safeResult = testSafe();
        // 結果驗證,若果正確就將成功次數增加
        if (unSafeResult == testCounts){
            totalUnSafeCount++;
        }
        // 同上
        if (safeResult == testCounts){
            totalSafeCount++;
        }
    }
    System.out.println("test count = " + testCounts);
    System.out.println("非安全計數器正確個數 = " + totalUnSafeCount);
    System.out.println("非安全計數器耗時 = " + unsafeTotalCostTime);
    System.out.println("安全計數器正確個數 = " + totalSafeCount);
    System.out.println("安全計數器耗時 = " + safeTotalCostTime);
}
複製程式碼

我的機器資訊如下:

  • MacBook Pro (Retina, 15-inch, Mid 2015)
  • 處理器:2.2 GHz Intel Core i7
  • 記憶體:16 GB 1600 MHz DDR3

下面是一些測試資料。

1000(執行緒數) * 300(次數)

測試結果如下:

test count = 1000
非安全計數器正確個數 = 300
非安全計數器耗時 = 27193
安全計數器正確個數 = 300
安全計數器耗時 = 26337
複製程式碼

居然發現不使用 CAS 的方式居然比使用自旋 CAS 的耗時要高出將近 1s。另外一個意外的點,我嘗試了好幾次,不使用 CAS 的情況得到的結果正確率基本也是 4 個 9 以上的比率,極少數會出現計算結果錯誤的情況。

3000(執行緒數) * 30(次數)

測試結果如下:

test count = 3000
非安全計數器正確個數 = 30
非安全計數器耗時 = 7816
安全計數器正確個數 = 30
安全計數器耗時 = 8073
複製程式碼

這裡看到在耗時上已經很接近了。這裡需要考慮另外一個可能影響的點是,因為 testUnSafe 是 testSafe 之前執行的,“JVM 和 機器本身熱身” 影響耗時雖然很小,但是也存在一定的影響。

5000(執行緒數) * 30(次數)

測試結果如下:

test count = 5000
非安全計數器正確個數 = 30
非安全計數器耗時 = 23213
安全計數器正確個數 = 30
安全計數器耗時 = 14161
複製程式碼

隨著併發量的增加,這裡奇怪的是,普通自增方式所消耗的時間要高於CAS方式消耗的時間將近 8-9s 。

當嘗試 10000 次時,是的你沒猜錯,丟擲了 OOM 。但是從執行的結果來看,並沒有說隨著併發量的增大,普通方式錯誤的概率會增加,也沒有出現預想的 CAS 方式的耗時要比 普通模式耗時多。

由於測試樣本資料比較單一,對於測試結果沒法做結論,歡迎大家將各自機器的結果提供出來,以供參考。另外就是,最近看到很多面試的同學,如果有被問道這個問題,還是需要謹慎考慮下。關於是否“打臉”還是“被打臉”還需要更多的測試結果。

CAS 到底是怎麼操作的

  • CPU 指令
  • Unsafe 類

2、ABA 問題的簡單復現

網上關於 CAS 討論另外一個點就是 CAS 中的 ABA 問題,相信大多數同學在面試時如果被問到 CAS ,那麼 ABA 問題也會被問到,然後接著就是怎麼避免這個問題,是的套路就是這麼一環扣一環的。

我相信 90% 以上的開發人員在實際的工程中是沒有遇到過這個問題的,即使遇到過,在特定的情況下也是不會影響到計算結果。但是既然這個問題會被反覆提到,那就一定有它導致 bug 的場景,找了一個案例供大家參考:CAS下ABA問題及優化方案

這裡先不去考慮怎麼去規避這個問題,我們想怎麼去通過簡單的模擬先來複現這個 ABA 問題。其實這個也很簡單,如果你對執行緒交叉、順序執行了解的話。

如何實現多執行緒的交叉執行

這個點實際上也是一個在面試過程中很常見的一個基礎問題,我在提供的程式碼中給了三種實現方式,有興趣的同學可以拉程式碼看下。

下面以 lock 的方式來模擬下這個場景,程式碼如下:

public class ConditionAlternateTest{
    private static int count = 0;
    // 計數器
    public AtomicInteger safeCount = new AtomicInteger(0);
    // lock
    private Lock lock = new ReentrantLock();
    // condition 1/2/3 用於三個執行緒觸發執行的條件
    Condition c1 = lock.newCondition();
    Condition c2 = lock.newCondition();
    Condition c3 = lock.newCondition();
    // 模擬併發執行
    CountDownLatch countDownLatch = new CountDownLatch(1);
    // 執行緒1 ,A 
    Thread t1 = new Thread(()-> {
        try {
            lock.lock();
            while (count % 3 != 0){
                c1.await();
            }
            safeCount.compareAndSet(0, 1);
            System.out.println("thread1:"+safeCount.get());
            count++;
            // 喚醒條件2
            c2.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    });
     // 執行緒2 ,B 
    Thread t2 = new Thread(()-> {
        try {
            lock.lock();
            while (count % 3 != 1){
                c2.await();
            }
            safeCount.compareAndSet(1, 0);
            System.out.println("thread2:"+safeCount.get());
            count++;
            // 喚醒條件3
            c3.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    });
    // 執行緒2 ,A
    Thread t3 = new Thread(()-> {
        try {
            lock.lock();
            while (count % 3 != 2){
                c3.await();
            }
            safeCount.compareAndSet(0, 1);
            System.out.println("thread3:"+safeCount.get());
            count++;
            // 喚醒條件1
            c1.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    });
    // 啟動啟動執行緒
    public void threadStart() {
        t3.start();
        t1.start();
        t2.start();
        countDownLatch.countDown();
    }

    public static void main(String[] args) throws InterruptedException {
        ConditionAlternateTest test = new ConditionAlternateTest();
        test.threadStart();
        test.countDownLatch.await();
    }
}
複製程式碼

執行結果:

thread1:1
thread2:0
thread3:1
複製程式碼

上面執行緒交叉的案例實際上並不是嚴格意義上的 ABA 問題的復現,這裡僅是模擬下產生的一個最簡單的過程。如果大家有好的案例,也可以分享一下。

ABA 問題解決

常見實踐:“版本號”的比對,一個資料一個版本,版本變化,即使值相同,也不應該修改成功。

java 中提供了 AtomicStampedReference 這個類來解決這個 ABA 問題。 AtomicStampedReference 原子類是一個帶有時間戳的物件引用,在每次修改後,AtomicStampedReference 不僅會設定新值而且還會記錄更改的時間。當 AtomicStampedReference 設定物件值時,物件值以及時間戳都必須滿足期望值才能寫入成功,這也就解決了反覆讀寫時,無法預知值是否已被修改的窘境。

實現程式碼這裡就不貼了,基於前面的程式碼改造,下面貼一下執行結果:

thread1,第一次修改;值為=1
thread2,已經改回為原始值;值為=0
thread3,第二次修改;值為=1
複製程式碼

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

當對一個共享變數執行操作時,我們可以使用 CAS 的方式來保證原子操作,但是對於對多個變數操作時,迴圈 CAS 就無法保證操作的原子性了,那麼這種場景下,我們就需要使用加鎖的方式來解決。

相關文章