【併發編程】java併發編程:CAS(Compare and Swap)

 

目錄

概念

需求:

實現

1.正常累加(既不加鎖,也不使用原子類)。

2.使用synchronized

原子類

爲什麼原子類比互斥鎖的效率低?

CAS的ABA問題


概念

compare and swap,解決多線程並行情況下使用鎖造成性能損耗的一種機制,CAS操作包含三個操作數——內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。否則,處理器不做任何操作。無論哪種情況,它都會在CAS指令之前返回該位置的值。CAS有效地說明了“我認爲位置V應該包含值A;如果包含該值,則將B放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。

簡單點來說就是修改之前先做一下對比,校驗數據是否被其他線程修改過,如果修改過了,那麼將內存中新的值取出在與內存中的進行對比,直到相等,然後再做修改。

假如我們要對變量:num做累加操作,num初始值=0。
1.cpu前往內存取出num;
2.判斷內存中的num是否被修改;
3.做+1操作;
4.將修改後的值寫入內存中;

 

這時候可能會有疑問了,判斷、自加、寫回內存難道不會發生線程安全問題嗎?既然cas能成爲併發編程中安全問題的解決這,那麼這個問題肯定是不會發生的,爲什麼呢?因爲判斷、自加、寫回內存這是一個由硬件保證的原子操作,硬件是如何保證原子性的,請先看下面這個例子

需求:

使用三個線程分別對某個成員變量累加10W,打印累加結果

我們使用兩種方法完成此需求。
1.正常累加(既不加鎖,也不使用原子類)。
1.使用synchronized。
2.使用原子類(Atomic)。

實現

1.正常累加(既不加鎖,也不使用原子類)。

這種方式沒有什麼說的,直接上代碼

package com.ymy.test;


public class CASTest {
    
    private static long count = 0;

    /**
     * 累加10w
     */
    private static  void add(){

        for (int i = 0; i< 100000; ++i){
            count+=1;
        }

    }

    public static void main(String[] args) throws InterruptedException {
        //開啓三個線程   t1   t2    t3
        Thread t1 = new Thread(() ->{
            add();
        });

        Thread t2 = new Thread(() ->{
            add();
        });

        Thread t3 = new Thread(() ->{
            add();
        });
        long starTime = System.currentTimeMillis();
        //啓動三個線程
        t1.start();
        t2.start();
        t3.start();
        //讓線程同步
        t1.join();
        t2.join();
        t3.join();
        long endTime = System.currentTimeMillis();
        System.out.println("累加完成,count:"+count);
        System.out.println("耗時:"+(endTime - starTime)+" ms");
    }
}

執行結果

很明顯,三個線程累加,由於cpu緩存的存在,導致結果遠遠小於30w,這個也是我們預期到的,所以纔會出現後面兩種解決方案。

2.使用synchronized

使用synchronized時需要注意,需求要求我們三個線程分別累加10W,所以synchronized鎖定的內容就非常重要了,要麼直接鎖定類,要麼三個線程使用同一把鎖,關於synchronized的介紹以及鎖定內容請參考:java併發編程之synchronized

第一種,直接鎖定類,我這裏採用鎖定靜態方法。

我們來改動一下代碼,將add方法加上synchronized關鍵字即可,由於add方法已經時靜態方法了,所以現在鎖定的時整個CASTest類。

 /**
     * 累加10w
     */
    private static synchronized   void add(){

        for (int i = 0; i< 100000; ++i){
            count+=1;
        }

    }

運行結果
第一次:


第二次:


第三次:

這裏就有意思了,加了鎖的運行時間居然比不加鎖的運行時間還少?是不是覺得有點不可思議了?其實這個也不難理解,這裏就要牽扯到cpu緩存以及緩存與內存的回寫機制了,感興趣的小夥伴可以自行百度,今天的重點不在這裏。

第二種:三個線程使用同一把鎖

改造代碼,去掉add方法的synchronized關鍵字,將synchronized寫在add方法內,新建一把鑰匙(成員變量:lock),讓三個累加都累加操作使用這把鑰匙,代碼如下:

package com.ymy.test;


public class CASTest {

    private static long count = 0;


    private static final String lock = "lock";

    /**
     * 累加10w
     */
    private static  void add() {
        synchronized(lock){
            for (int i = 0; i < 100000; ++i) {
                count += 1;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //開啓三個線程   t1   t2    t3
        Thread t1 = new Thread(() -> {
            add();
        });

        Thread t2 = new Thread(() -> {
            add();
        });

        Thread t3 = new Thread(() -> {
            add();
        });
        long starTime = System.currentTimeMillis();
        //啓動三個線程
        t1.start();
        t2.start();
        t3.start();
        //讓線程同步
        t1.join();
        t2.join();
        t3.join();
        long endTime = System.currentTimeMillis();
        System.out.println("累加完成,count:" + count);
        System.out.println("耗時:" + (endTime - starTime) + " ms");
    }
}

 結果如下:

這兩種加鎖方式都能保證線程的安全,但是這裏你需要注意一點,如果是在方法上加synchronized而不加static關鍵字的話,必須要保證多個線程共用這一個對象,否者加鎖無效。

原子類

原子類工具有很多,我們舉例的累加操作只用到其中的一種,我們一起看看java提供的原子工具有哪些:

工具類還是很豐富的,我們結合需求來講解一下其中的一種,我們使用:AtomicLong。

AtomicLong提供了兩個構造函數:

value:原子操作的初始值,調用無參構造value=0;調用有參構造value=指定值

其中value還是被volatile 關鍵字修飾,volatile可以保證變量的可見性,什麼叫可見性?可見性有一條很重要的規則:Happens-Before 規則,意思:前面一個操作的結果對後續操作是可見的,線程1對變量A的修改其他線程立馬可以看到,具體請自行百度。

我們接着來看累加的需求,AtomicLong提供了一個incrementAndGet(),源碼如下:
 

/**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final long incrementAndGet() {
        return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
    }

 Atomically increments by one the current value :原子的增加一個當前值。好了,我們現在試着將互斥鎖修改成原子類工具,改造代碼:
1.實例化一個Long類型的原子類工具;
2.再for循環中使用incrementAndGet()方法進行累加操作。

改造後的代碼:

package com.ymy.test;


import java.util.concurrent.atomic.AtomicLong;

public class CASTest {

//    private static long count = 0;
//
//    private static final String lock = "lock";

    private static AtomicLong atomicLong = new AtomicLong();

    /**
     * 累加10w
     */
    private static void add() {
        for (int i = 0; i < 100000; ++i) {
            atomicLong.incrementAndGet();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //開啓三個線程   t1   t2    t3
        Thread t1 = new Thread(() -> {
            add();
        });

        Thread t2 = new Thread(() -> {
            add();
        });

        Thread t3 = new Thread(() -> {
            add();
        });
        long starTime = System.currentTimeMillis();
        //啓動三個線程
        t1.start();
        t2.start();
        t3.start();
        //讓線程同步
        t1.join();
        t2.join();
        t3.join();
        long endTime = System.currentTimeMillis();
        //System.out.println("累加完成,count:" + count);
        System.out.println("累加完成,count:" + atomicLong);
        System.out.println("耗時:" + (endTime - starTime) + " ms");
    }
}

結果:

可以得到累加的結果也是:30w,但時間卻比互斥鎖要久,這是爲什麼呢?我們一起來解剖一下源碼。

AtomicLong  incrementAndGet()源碼解析

/**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final long incrementAndGet() {
        return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
    }

    public final long getAndAddLong(Object var1, long var2, long var4) {
        long var6;
        do {
            var6 = this.getLongVolatile(var1, var2);
        } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

        return var6;
    }

我們來看看getAndAddLong方法,發現內部使用了一個 do  while  循環,我們看看循環的條件是什麼

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

這是循環條件的源碼,不知道你們發現沒有一個關鍵字:native,表示java代碼已經走完了,這裏需要調用C/C++代碼,這裏調用的時C++代碼,在這裏就要解釋一下爲什麼原子類的比較和賦值是線程安全的,那是因爲c++代碼中是有加鎖的,不知道你們是否瞭解過內存與cpu的消息總線制,c++就是再消息總線中加了lock,保證了互斥性,所以對比和賦值是一個原子操作,線程安全的。

Unsafe,這個類可以爲原子工具類提供硬件級別的原子性,雖然我們java中使用的這些原子工具類雖然都是無鎖的,但是我們無需考慮他的多線程安全問題。

爲什麼原子類比互斥鎖的效率低?

好了,現在來思考一下爲什麼原子工具類的效率會比互斥鎖低?明明沒有加鎖,反而比加了鎖慢,這是不是有點不合常理?其實這很符合常理,我們一起來分析一波,CAS(Compare and Swap)重在比較,我們看源碼的時候發現有一個  do  while循環,這個循環的作用是什麼呢?

1.判斷期望值是否和內存中的值一致;

2.如果不一致,獲取內存中最新的值(var6),此時期望值就等於了var6,使用改期望值繼續與內存中的值做對比,直到發現期望值和內存中的值一致,+1之後返回結果。

這裏有一個問題不知道你們發現沒有,就是這個循環問題,
1.我們假設線程1最先訪問內存中的num值=0;加載到cpu中;
2.還沒有做累加操作,cpu執行了線程切換操作;
3.線程2得到了使用權,線程2也去內存中加載num=0,累加之後將結果返回到了內存中;
4.線程切回線程1,接着上面的操作,要和內存中的num進行對比,期望值=0,內存num=1,法向對比不上,從新獲取內存中的num=1加載到線程1所在的cpu中;
5.此時線程又切換了,這次切換了線程3;
6.線程3從內存中加載num=1到線程3所在的cpu中,之後拿着期望值=1與內存中的num=1做對比,發現值並沒有被修改,此時,累加結果之後寫回內存;
7.線程1拿到使用權;
8.線程1期望值=1與內存num=2做對比,發現又不相同,此時又需要將內存中的新num=3加載到線程1所在的cpu中,然後拿着期望值=2與內存num=2做對比,發現相同,累加將結果寫回內存。

這是再多線程下的一種情況,我們發現線程1做了兩次對比,而真正的程序循環對比的次數肯定會比我們分析的多,互斥鎖三個線程累加10w,只需要累加30萬次即可,而原子類工具需要累加30萬次並且循環很多次,可能幾千次,也可能幾十萬次,所以再內存中累加操作互斥鎖會比原子類效率高,因爲內存的執行效率高,會導致一個對比執行很多循環,我們稱這個循環叫:自旋。

是不是所有情況下都是互斥鎖要快呢?肯定不是的,如果操作的數據再磁盤中,或者操作數據量太多時,原子類就會比互斥鎖的性能高很多,這很好理解,就像內存中單線程比多線程效率會更高(一般情況)。

CAS的ABA問題

ABA是什麼?我們來舉個例子:變量a初始值=0,被線程1獲取a=0,切換到線程2,獲取a=0,並且將a修改爲1寫回內存,切換到線程3,再內存中獲取數據a=1,將數據修改爲0然後寫回內存,切換到線程1,這時候線程1發現內存中的值還是0,線程1認爲內存中a沒有被修改,這時候線程1將a的值修改爲1,寫回內存。

我們來分析一下這波操作會不會有風險,從表面上看,好像沒什麼問題,累加或者值修改的時候問題不大,覺得這個ABA沒有什麼風險,如果你這樣認爲,那就大錯特錯了,我舉個例子,用戶A用網上銀行給用戶B轉錢,同時用戶C也在給用戶A轉錢,我們假設用戶A賬戶餘額100元,用戶A要給用戶B轉100元,用戶C要給用戶A轉100元,用戶A轉給用戶B、用戶C轉給用戶A同時發生,但由於用戶A的網絡不好,用戶A點了一下之後沒有反應,接着又點了一下,這時候就會發送兩條用戶A給用戶B轉100元的請求。

我們假設線程1:用戶A第一次轉用戶B100元

線程2:用戶A第二次轉用戶B100元

線程3:用戶C轉用戶A100元。

線程1執行的時候獲取用戶A的餘額=100元,此時切換到了線程2,也獲取到了用戶A的餘額=100元,線程2做了扣錢操作(update     money-100 where money=100),100是我們剛查出來的,扣完之後餘額應該變成了0元,切換到線程3,用戶C轉給用戶A100元,此時用戶A的賬戶又變成了100元,切換到線程1,執行扣錢操作(update     money-100 where money=100),本來是應該扣錢失敗的,由於用戶C給用戶A轉了100元,導致用戶A的餘額又變成了100元,所以線程1也扣錢成功了。

這是不是很恐怖?所以在開發的時候,ABA問題是否需要注意,還請分析好應用場景,像之前說的這個ABA問題,數據庫層面我們可以加版本號(版本號累加)就能解決,程序中原子類也給我們提供瞭解決方案:AtomicStampedReference,感興趣的小夥伴可以研究一下。其實思路和版本號類似,比較的時候不僅需要比較期望值,還要對比版本號,都相同的情況下才會做修改。

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