【多線程 六】徹徹底底搞懂CAS,解決ABA問題

前言:

如果不知道JMM(java 內存模型),還請看博主的上一篇博客: volatile關鍵字與內存可見性詳解 ,因爲本博客還需要JMM的理論基礎。

博客內容導圖

在這裏插入圖片描述

1、CAS是什麼

CAS(Compare-And-Swap)是一種硬件對併發的支持,針對多處理器操作而設計的處理器中的一種特殊指令,用於管理對共享數據的併發訪問. CAS 是一種無鎖的非阻塞算法的實現。
以上是一本書給出的定義。其實我們在上篇博客已經提過CAS了,AtomicInteger 實現num++,AtomicInteger的底層就是用了CAS的思想。CAS是一條CPU併發原語.並且原語的執行必須是連續的,在執行過程中不允許中斷,也即是說CAS是一條原子指令,不會造成所謂的數據不一致(線程安全)的問題.

2、CAS解決了什麼問題

對於多線程而言,實現線程安全是最要的也是最基本的,我們之前通過同步鎖synchronized()實現,它保證了線程安全,也就是數據的一致性,但是它的併發性下降,因爲每次在都要對代碼塊進行加鎖,線程得到鎖纔可以運行。但是CAS是一種無鎖的非阻塞的算法實現,如果線程數不多(併發量小),他的性能要比synchronized()高的多,但是線程數過多,就會過分的消耗cpu資源(具體爲啥後面會解釋)。

3、CAS的原理

CAS 包含了 3 個操作數:

需要讀寫的內存值 V
進行比較的值 A
擬寫入的新值 B

當且僅當 V 的值等於 A 時,CAS 通過原子方式用新值 B 來更新 V 的 值,否則不會執行任何操作。接下來用源碼來驗證以上理論。

3.1 atomic包中的類

且看AtomicInteger,AtomicInteger位於java.util.concurrent.atomic包下,如下圖:(本圖截取自JDK API 1.6中文版)
在這裏插入圖片描述
從上圖可知道,atomic包下都是原子性的操作,原子性就是說無論是幾行代碼,要麼這些代碼一塊執行,要麼就不執行,中間不可以 加塞(比如還沒執行完,發生線程調度),這就完美的解決num++線程不安全的問題。

3.2 根據atomicInteger.getAndIncrement()源碼驗證CAS理論

(1)從下面的代碼可以看出,用到了Unsafe這個類,UnSafe類在sun.misc包中,UnSafe是CAS的核心類 ,由於Java 方法無法直接訪問底層 ,需要通過本地(native)方法來訪問,
基於該類可以直接操作特額定的內存數據,其內部方法操作可以像C的指針一樣直接操作內存。
UnSafe類中所有的方法都是native修飾的,也就是說UnSafe類中的方法都是直接調用操作底層資源執行響應的任務

看到這裏的value是用關鍵字volatile修飾的,保證了內存可見性

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    private volatile int value;

(2)如下面的代碼:
unsafe.getAndAddInt(this, valueOffset, 1),傳了三個參數,分別是當前對象this、該對象在內存中的偏移地址,還有表示每次都加1的數字1。其中UnSafe就是根據內存偏移地址獲取數據的偏移地址

    /**
     * Atomically increments by one the current value.
     *
     * @return the previous value
     */
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

(3)如下代碼,進入到方法getAndAddInt,發現是一個do while 循環,接下來進行說明:

var1 AtomicInteger對象本身
var2 該對象值的引用地址
var4 需要變動的數量
var5 是通過var1 var2找出內存中真實的值

this.compareAndSwapInt(var1, var2, var5, var5 + var4),表示的是該對象當前的值與var5進行比較,如果相同,更新var5+var4的值並且返回true,如果不同,繼續取值然後比較,直到更新完成

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

(4)CAS原理分析
1.假設線程A和線程B兩個線程同時執行getAndAddInt操作(分別在不同的CPU上)且AtomicInteger裏面的value原始值爲3,即主內存中AtomicInteger的value爲3,根據JMM模型,線程A和線程B各自持有一份值爲3的value的副本分別到各自的工作內存.

2.線程A通過getIntVolatile(var1,var2) 拿到value值3,這時線程A被掛起.線程B也通過getIntVolatile(var1,var2) 拿到value值3,
在這裏插入圖片描述

3.此時剛好線程B沒有被掛起並執行compareAndSwapInt方法比較內存中的值也是3 ,成功修改內存的值爲4 線程B走完收工 一切OK。
在這裏插入圖片描述

4.這時線程A恢復,執行compareAndSwapInt方法比較,發現自己手裏的數值和內存中的數值4不一致,說明該值已經被其他線程搶先一步修改了,那A線程修改失敗,只能重新來一遍了.

在這裏插入圖片描述
5.線程A重新獲取value值,因爲變量value是volatile修飾,所以其他線程對他的修改,線程A總是能夠看到,線程A繼續執行compareAndSwapInt方法進行比較替換,直到成功.

4、CAS的缺點

(1)根據上面所說的原理,如果併發量特別大或者說開啓的線程數過多,就可能會存在內存中的共享的值經常被修改,導致unsafe底層匹配不成功,會一直do-while下去,加大了cpu 的壓力
(2)只能保證一個共享變量的原子操作。(比如聲明兩個原子變量a和b 具體操作是a+b,此時只能枷鎖了)

(3)引發ABA問題(狸貓換太子)
ABA問題舉例:
場景1:
一個線程1從內存中取出A,這個時候另一個線程2也從內存中取出A,並且線程2進行了一些操作將值變成了B,線程1此時還被阻塞,線程2又進行了一些操作,然後將B又變成了A,此時線程1獲得資源,開始執行,但是在進行cas操作的時候發現內存中還是A,然後線程1執行成功。

場景2:

ABA問題:內存值V=100;
threadA 將100,改爲50;
threadB 將100,改爲50;
threadC 將50,改爲100

小牛取款,由於機器不太好使,多點了幾次取款操作。後臺threadA和threadB工作,此時threadA操作成功(100->50),threadB,取完值100,然後阻塞。正好牛媽打款50元給小牛(50->100),threadC執行成功,之後threadB運行了,又改爲(100->50)。
牛氣沖天,lz錢哪去了???

5、如何解決ABA問題:

對內存中的值加個版本號,在比較的時候除了比較值還的比較版本號。
AtomicStampedReference就是用版本號實現cas機制,再講AtomicStampedReference之前,先講解AtomicReference原子引用。

5.1 AtomicReference原子引用

AtomicReference和AtomicInteger非常類似,不同之處就在於AtomicInteger是對整數的封裝,底層採用的是compareAndSwapInt實現CAS,比較的是數值是否相等,而AtomicReference則對應普通的對象引用,底層使用的是compareAndSwapObject實現CAS,比較的是兩個對象的地址是否相等。也就是它可以保證你在修改對象引用時的線程安全性。我們可以自定義對象。

代碼如下:

public class TestAtomic {
    public static void main(String[] args) {
        AtomicReference<User> atomicUser=new AtomicReference<>();
        User zhangsan=new User("張三",21);
        User lisi =new User("李四",25);
        atomicUser.set(zhangsan);
        System.out.println(atomicUser.compareAndSet(zhangsan,lisi)+"\t"+atomicUser.get().toString());
    }
}

class User{
    private String username;
    private int age;

    public User(String username,int age){
        this.username=username;
        this.age=age;
    }
    @Override
    public String toString() {
        return "User{" +
                "username='" + username + '\'' +
                ", age=" + age +
                '}';
    }
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

執行結果:
在這裏插入圖片描述
// 分析:
定義了對象User,利用方法compareAndSet進行比較和判斷,內存中存入了張三,然後和compareAndset的第一個參數進行比較,發現都是張三,然後修改爲lisi。

5.2 版本號原子引用 AtomicStampedReference解決ABA 問題

AtomicStampedReference 增加了版本號,解決了ABA問題。
思想:
內存值V=100;
threadA 獲取值100 版本號1,改爲50 版本號2;
threadB 獲取值100 版本號1
threadC 將50 版本號2 改爲100 版本號3

場景:小牛取款,由於機器不太好使,多點了幾次取款操作。後臺threadA和threadB工作,此時threadA操作成功(100->50) 版本號1->版本號2,threadB取完值100,版本號是1,然後阻塞。正好牛媽打款50元給小牛(50->100) 版本號2->版本號3,threadC執行成功,之後threadB獲取資源,開始執行,發現內存的值爲100, 但是版本號不對應, 所以重新從內存中讀取值,然後再進行判斷。這次牛就不會沖天了,哈哈哈哈哈!

以上場景對應代碼:

public class ABADemo {
    // 表示內存裏初始值是100,版本好號爲1
  private static AtomicStampedReference atomicStampedReference=new AtomicStampedReference(100,1);
    public static void main(String[] args) {
        new Thread(()->{
            // 獲得版本號
            int stamp = atomicStampedReference.getStamp();
            // 這裏延遲一秒不是爲了說爲了線程阻塞,而是爲了讓線程B得到版本號
            System.out.println(Thread.currentThread().getName()+"\t第一次版本號:"+stamp);
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
            // 期望值爲100 和內存中的值進行比較,如果一樣,且版本號stamp也和內存中一樣,則改爲50
            atomicStampedReference.compareAndSet(100,50,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t第二次版本號:"+atomicStampedReference.getStamp());
//            atomicStampedReference.compareAndSet(100,50,stamp,stamp+1);
        },"A").start();

        new Thread(()->{
            // 獲得版本號
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t第一次版本號:"+stamp);
            // 線程阻塞
            try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
            // 期望值爲100 和內存中的值進行比較,如果一樣,且版本號stamp也和內存中一樣,則改爲50
            boolean result = atomicStampedReference.compareAndSet(100, 50, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName()+"\t修改是否成功"+result+"\t當前實際版本號:"+atomicStampedReference.getStamp());
            System.out.println("當前實際最新值:"+atomicStampedReference.getReference());
        },"B").start();

        new Thread(()->{
            // 這裏延遲兩秒不是爲了說爲了線程阻塞,而是爲了讓線程A 執行完畢
            try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
            // 獲得版本號
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t第一次版本號:"+stamp);
            // 期望值爲100 和內存中的值進行比較,如果一樣,且版本號stamp也和內存中一樣,則改爲50
            atomicStampedReference.compareAndSet(50,100,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t第二次版本號:"+atomicStampedReference.getStamp());
        },"C").start();
    }
}

運行結果:
在這裏插入圖片描述

後記:

總結不易,如果對你有幫助,請點個贊歐!

·

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章