[併發編程]-關於 CAS 的幾個問題

CAS 相關基礎知識

CAS的全稱是Compare And Swap ,即比較交換。CAS 中通常會設計到3個參數:java

  • 內存值 V
  • 舊的預期值A
  • 要修改的新值B

當且僅當預期值 A 和內存值 V 相同時,將內存值V修改成 B,不然什麼都不作。git

這裏關於 CPU 指令對於 CAS 的支持不深刻研究,有興趣的能夠自行了解。github

CAS 幾個問題

不少書籍和文章中都有提出它存在的幾個問題:面試

  • 一、循環時間長開銷很大
  • 二、只能保證一個共享變量的原子操做
  • 三、ABA 問題

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

一、關於「循環時間長開銷很大」的疑惑與驗證

自旋 CAS 若是長時間不成功,會給 CPU 帶來很是大的開銷。可是真的是這樣嗎?到底多大的併發量才形成 CAS 的自旋次數會增長呢?另外,對於當前的機器及JDK,在無鎖,無CAS 的狀況下,是否對於結果的影響是真的那麼明顯呢?對於這個問題,下面作了一個簡單的測試,可是測試結果也只是針對在我本地環境下,各位看官能夠拉一下代碼,在本身電腦上 run 一下,把機器信息、JDK版本以及測試結果留言到評論區。bash

本文案例能夠這裏獲取:glmapper-blog-sample-cas多線程

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

計數器類

計數器裏面有兩個方法,一種是CAS 自旋方式,一種是直接自增。代碼以下:app

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 類

二、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
複製代碼

三、只能保證一個共享變量的原子操做

當對一個共享變量執行操做時,咱們可使用 CAS 的方式來保證原子操做,可是對於對多個變量操做時,循環 CAS 就沒法保證操做的原子性了,那麼這種場景下,咱們就須要使用加鎖的方式來解決。

相關文章
相關標籤/搜索