CAS導致的ABA問題及解決

永動的圖靈機發表於2019-04-24

Java併發--非阻塞同步

CAS問題引入

在併發問題中,最先想到的無疑是互斥同步,但執行緒阻塞和喚醒帶來了很大的效能問題,同步鎖的核心無非是防止共享變數併發修改帶來的問題,但不是任何時候都有這樣的競爭關係。

什麼是CAS

CAS,比較並交換(Compare-and-Swap,CAS),如果期望值和主記憶體值一樣,則交換要更新的值,也稱樂觀鎖。

如執行緒甲從主記憶體中拷貝了變數A為1,在自己的執行緒中將副本A改為了10,當執行緒甲準備把這個變數更新到主記憶體時,如果主記憶體A的值不改變(期望值),還是1,那麼執行緒甲成功更新主記憶體中A的值。但如果主記憶體A的值已經先被其他執行緒改掉不為1,那麼執行緒甲不斷地重試,直到成功為止(自旋)。

CAS來自哪

CAS屬於J.U.C包,呼叫的Unsafe 類中方法,這是一種硬體支援的原子性操作,不能被打斷或停止,無需互斥同步。

以AtomicInteger下的getAndAddInt方法為例,U即Unsafe類。

/**
  * @param expectedValue 期望值
  * @param newValue 新值
  * @return 比價更新是否成功.
  */
public final int incrementAndGet() {
    return U.getAndAddInt(this, VALUE, 1) + 1;
}
複製程式碼

再往下看,通過 getIntVolatile(o, offset) 得到以前的值v,通過呼叫 weakCompareAndSetInt() 來進行 CAS 比較,如果該欄位記憶體地址中的值等於 v,那麼就更新記憶體地址為 o+offset的變數為 v + delta。getAndAddInt()方法 在一個迴圈中進行,發生衝突的做法是不斷的進行重試

/**
     * @param o 更新欄位/元素的物件/陣列
     * @param offset 欄位/元素偏移量
     * @param delta 要新增的值,步長
     * @return 以前的值
     * @since 1.8
     */
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}
複製程式碼

執行程式碼示例

執行緒t1,t2,同時修改主記憶體的一變數值,人為的讓B快與A

public class TestCAS {
    // 主記憶體atomicInteger初始值為1
    public static AtomicInteger atomicInteger = new AtomicInteger(1);

    public static void main(String[] args) {
        // A執行緒計劃將值改為10,先休眠2s,再比較交換
        new Thread(() -> {
            try { TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("當前執行緒: "+Thread.currentThread().getName()+"比較交換結果:"
                    +atomicInteger.compareAndSet(1, 10)+" 現在值為:"+atomicInteger.get());
        },"t1").start();

        // B執行緒計劃將值改為10,不休眠
        new Thread(() -> {
            System.out.println("當前執行緒: "+Thread.currentThread().getName()+"比較交換結果:"
                    +atomicInteger.compareAndSet(1, 20)+" 現在值為:"+atomicInteger.get());
        },"t2").start();
    }
}
複製程式碼

控制檯

當前執行緒: t2比較交換結果:true 現在值為:20
當前執行緒: t1比較交換結果:false 現在值為:20
複製程式碼

這樣不用加同步鎖,就實現了變數的併發修改帶來的問題。

如果你的好朋友向你借走了10塊,第二天他又還給你了10塊,如果的你的朋友只是為了買包零食,你可能不會在乎,如何他用那10塊中了大獎,你可能會有些著急了...

ABA問題引入

上個程式碼中,存在一個問題。如:t1,t2執行緒都拷貝到變數atomicInteger=1,如果B執行緒優先順序較高或運氣好,第一次,t2先將atomicInteger修改為20併成功寫入主記憶體,接著t2又拷貝到atomicInteger=20,將副本又改為1,併成功寫回主記憶體。第三次,t1拿到主記憶體atomicInteger的值。可這個值已經被t2修改過兩次,會有問題嗎?

CAS導致的ABA問題及解決

ABA問題

如果一個變數初次讀取的時候是 A 值,它的值被改成了 B,後來又被改回為 A,那 CAS 操作就會誤認為它從來沒有被改變過。

ABA解決

  1. 互斥同步鎖synchronized

  2. 如果專案只在乎數值是否正確, 那麼ABA 問題不會影響程式併發的正確性。

  3. J.U.C 包提供了一個帶有時間戳的原子引用類 AtomicStampedReference 來解決該問題,它通過控制變數的版本來保證 CAS 的正確性。

AtomicStampedReference程式碼示例

public class SolveCAS {
    // 主記憶體共享變數,初始值為1,版本號為1
    private static AtomicStampedReference<Integer> atomicStampedReference = new
            AtomicStampedReference<>(1, 1);


    public static void main(String[] args) {
        // t1,期望將1改為10
        new Thread(() -> {
            // 第一次拿到的時間戳
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+" 第1次時間戳:"+stamp+" 值為:"+atomicStampedReference.getReference());
            // 休眠5s,確保t2執行完ABA操作
            try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
            // t2將時間戳改為了3,cas失敗
            boolean b = atomicStampedReference.compareAndSet(1, 10, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName()+" CAS是否成功:"+b);
            System.out.println(Thread.currentThread().getName()+" 當前最新時間戳:"+atomicStampedReference.getStamp()+" 最新值為:"+atomicStampedReference.getReference());
        },"t1").start();

        // t2進行ABA操作
        new Thread(() -> {
            // 第一次拿到的時間戳
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+" 第1次時間戳:"+stamp+" 值為:"+atomicStampedReference.getReference());
            // 休眠,修改前確保t1也拿到同樣的副本,初始值為1
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
            // 將副本改為20,再寫入,緊接著又改為1,寫入,每次提升一個時間戳,中間t1沒介入
            atomicStampedReference.compareAndSet(1, 20, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName()+" 第2次時間戳:"+atomicStampedReference.getStamp()+" 值為:"+atomicStampedReference.getReference());
            atomicStampedReference.compareAndSet(20, 1, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName()+" 第3次時間戳:"+atomicStampedReference.getStamp()+" 值為:"+atomicStampedReference.getReference());

        },"t2").start();
    }
}
複製程式碼

控制檯

t1 第1次時間戳:1 值為:1
t2 第1次時間戳:1 值為:1
t2 第2次時間戳:2 值為:20
t2 第3次時間戳:3 值為:1
t1 CAS是否成功:false
t1 當前最新時間戳:3 最新值為:1
複製程式碼

相關文章