慕課網實戰·高併發探索(三):線程安全性-原子性-CAS(CAS的ABA問題)

特別感謝:慕課網jimin老師的《Java併發編程與高併發解決方案》課程,以下知識點多數來自老師的課程內容。
jimin老師課程地址:Java併發編程與高併發解決方案


線程安全性

線程安全?

當多個線程訪問某個類時,不管運行時環境採用何種調度方式或者這些進程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類是線程安全的。

線程安全性?

線程安全性主要體現在三個方面:原子性、可見性、有序性

  • 原子性:提供了互斥訪問,同一時刻只能有一個線程來對它進行操作
  • 可見性:一個線程對主內存的修改可以及時的被其他線程觀察到
  • 有序性:一個線程觀察其他線程中的指令執行順序,由於指令重排序的存在,該觀察結果一般雜亂無序。

基礎代碼:以下代碼用於描述下方的知識點,所有代碼均在此代碼基礎上進行修改。

public class CountExample {

    //請求總數
    public static int clientTotal  = 5000;
    //同時併發執行的線程數
    public static int threadTotal = 200;
    //變量聲明:計數
    public static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();//創建線程池
        final Semaphore semaphore = new Semaphore(threadTotal);//定義信號量,給出允許併發的數目
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);//定義計數器閉鎖
        for (int i = 0;i<clientTotal;i++){
            executorService.execute(()->{
                try {
                    semaphore.acquire();//判斷進程是否允許被執行
                    add();
                    semaphore.release();//釋放進程
                } catch (InterruptedException e) {
                    log.error("excption",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();//保證信號量減爲0
        executorService.shutdown();//關閉線程池
        log.info("count:{}",count.get());//變量取值
    }

    private static void add(){
        count.incrementAndGet();//變量操作
    }
}

原子性

說到原子性,一共有兩個方面需要學習一下,一個是JDK中已經提供好的Atomic包,他們均使用了CAS完成線程的原子性操作,另一個是使用鎖的機制來處理線程之間的原子性。鎖包括:synchronized、Lock

Atomic包中的類與CAS:

這裏寫圖片描述
我們從最簡單的AtomicInteger類來了解什麼是CAS

AtomicInteger

上邊的示例代碼就是通過AtomicInteger類保證了線程的原子性。
那麼它是如何保證原子性的呢?我們接下來分析一下它的源碼。示例中,對count變量的+1操作,採用的是incrementAndGet方法,此方法的源碼中調用了一個名爲unsafe.getAndAddInt的方法

public final int incrementAndGet() {
     return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

而getAndAddInt方法的具體實現爲:

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

在此方法中,方法參數爲要操作的對象Object var1、期望底層當前的數值爲var2、要修改的數值var4。定義的var5爲真正從底層取出來的值。採用do..while循環的方式去獲取底層數值並與期望值進行比較,比較成功纔將值進行修改。而這個比較再進行修改的方法就是compareAndSwapInt就是我們所說的CAS,它是一系列的接口,比如下面羅列的幾個接口。使用native修飾,是底層的方法。CAS取的是compareAndSwap三個單詞的首字母.

另外,示例代碼中的count可以理解爲JMM中的工作內存,而這裏的底層數值即爲主內存,如果看過我上一篇文章的盆友就能把這一塊的知識點串聯起來了。

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
AtomicLong 與 LongAdder

LongAdder是java8爲我們提供的新的類,跟AtomicLong有相同的效果。首先看一下代碼實現:

AtomicLong:
//變量聲明
public static AtomicLong count = new AtomicLong(0);
//變量操作
count.incrementAndGet();
//變量取值
count.get();
LongAdder:
//變量聲明
public static LongAdder count = new LongAdder();
//變量操作
count.increment();
//變量取值
count

那麼問題來了,爲什麼有了AtomicLong還要新增一個LongAdder呢?
原因是:CAS底層實現是在一個死循環中不斷地嘗試修改目標值,直到修改成功。如果競爭不激烈的時候,修改成功率很高,否則失敗率很高。在失敗的時候,這些重複的原子性操作會耗費性能。

知識點: 對於普通類型的long、double變量,JVM允許將64位的讀操作或寫操作拆成兩個32位的操作。

LongAdder類的實現核心是將熱點數據分離,比如說它可以將AtomicLong內部的內部核心數據value分離成一個數組,每個線程訪問時,通過hash等算法映射到其中一個數字進行計數,而最終的計數結果則爲這個數組的求和累加,其中熱點數據value會被分離成多個單元的cell,每個cell獨自維護內部的值。當前對象的實際值由所有的cell累計合成,這樣熱點就進行了有效地分離,並提高了並行度。這相當於將AtomicLong的單點的更新壓力分擔到各個節點上。在低併發的時候通過對base的直接更新,可以保障和AtomicLong的性能基本一致。而在高併發的時候通過分散提高了性能。

源碼:
public void increment() {
    add(1L);
}
public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

缺點:如果在統計的時候,如果有併發更新,可能會有統計數據有誤差。實際使用中在處理高併發計數的時候優先使用LongAdder,而不是AtomicLong在線程競爭很低的時候,使用AtomicLong會簡單效率更高一些。比如序列號生成(準確性)

AtomicBoolean

這個類中值得一提的是它包含了一個名爲compareAndSet的方法,這個方法可以做到的是控制一個boolean變量在一件事情執行之前爲false,事情執行之後變爲true。或者也可以理解爲可以控制某一件事只讓一個線程執行,並僅能執行一次。
他的源碼如下:

public final boolean compareAndSet(boolean expect, boolean update) {
    int e = expect ? 1 : 0;
    int u = update ? 1 : 0;
    return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}

舉例說明:

    //是否發生過
    private static AtomicBoolean isHappened = new AtomicBoolean(false);
    // 請求總數
    public static int clientTotal = 5000;
    // 同時併發執行的線程數
    public static int threadTotal = 200;

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal ; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    test();
                    semaphore.release();
                } catch (Exception e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("isHappened:{}", isHappened.get());
    }

    private static void test() {
        if (isHappened.compareAndSet(false, true)) {//控制某有一段代碼只執行一次
            log.info("execute");
        }
    }

結果:(log只打印一次)
[pool-1-thread-2] INFO com.superboys.concurrency.example.Atomic.AtomicExample6 - execute
[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample6 - isHappened:true
AtomicIntegerFieldUpdater

這個類的核心作用是要更新一個指定的類的某一個字段的值。並且這個字段一定要用volatile修飾同時還不能是static的。
舉例說明:

@Slf4j
public class AtomicExample5 {

    //原子性更新某一個類的一個實例
    private static AtomicIntegerFieldUpdater<AtomicExample5> updater
            = AtomicIntegerFieldUpdater.newUpdater(AtomicExample5.class,"count");

    @Getter
    public volatile int count = 100;//必須要volatile標記,且不能是static

    public static void main(String[] args) {
        AtomicExample5 example5 = new AtomicExample5();

        if(updater.compareAndSet(example5,100,120)){
            log.info("update success 1,{}",example5.getCount());
        }

        if(updater.compareAndSet(example5,100,120)){
            log.info("update success 2,{}",example5.getCount());
        }else{
            log.info("update failed,{}",example5.getCount());
        }
    }
}

此方法輸出的結果爲:
[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample5 - update success 1,120
[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample5 - update failed,120

由此可見,count的值只修改了一次。
AtomicStampReference與CAS的ABA問題

什麼是ABA問題?
CAS操作的時候,其他線程將變量的值A改成了B,但是隨後又改成了A,本線程在CAS方法中使用期望值A與當前變量進行比較的時候,發現變量的值未發生改變,於是CAS就將變量的值進行了交換操作。但是實際上變量的值已經被其他的變量改變過,這與設計思想是不符合的。所以就有了AtomicStampReference。

源碼:
private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

private volatile Pair<V> pair;

private boolean casPair(Pair<V> cmp, Pair<V> val) {
        return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
    }

public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&   //排除新的引用和新的版本號與底層的值相同的情況
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
}

AtomicStampReference的處理思想是,每次變量更新的時候,將變量的版本號+1,之前的ABA問題中,變量經過兩次操作以後,變量的版本號就會由1變成3,也就是說只要線程對變量進行過操作,變量的版本號就會發生更改。從而解決了ABA問題。

解釋一下上邊的源碼:
類中維護了一個volatile修飾的Pair類型變量current,Pair是一個私有的靜態類,current可以理解爲底層數值。
compareAndSet方法的參數部分分別爲期望的引用、新的引用、期望的版本號、新的版本號。
return的邏輯爲判斷了期望的引用和版本號是否與底層的引用和版本號相符,並且排除了新的引用和新的版本號與底層的值相同的情況(即不需要修改)的情況(return代碼部分3、4行)。條件成立,執行casPair方法,調用CAS操作。

AtomicLongArray

這個類實際上維護了一個Array數組,我們在對數值進行更新的時候,會多一個索引值讓我們更新。

原子性,提供了互斥訪問,同一時刻只能有一個線程來對它進行操作。那麼在java裏,保證同一時刻只有一個線程對它進行操作的,除了Atomic包之外,還有鎖的機制。JDK提供鎖主要分爲兩種:synchronized和Lock。接下來我們瞭解一下synchronized。

synchronized

依賴於JVM去實現鎖,因此在這個關鍵字作用對象的作用範圍內,都是同一時刻只能有一個線程對其進行操作的。
synchronized是java中的一個關鍵字,是一種同步鎖。它可以修飾的對象主要有四種:

  • 修飾代碼塊:大括號括起來的代碼,作用於調用的對象
  • 修飾方法:整個方法,作用於調用的對象
  • ———————————————————————–
  • 修飾靜態方法:整個靜態方法,作用於所有對象
  • 修飾類:括號括起來的部分,作用於所有對象
synchronized 修飾一個代碼塊

被修飾的代碼稱爲同步語句塊,作用的範圍是大括號括起來的部分。作用的對象是調用這段代碼的對象
驗證:

public class SynchronizedExample {
    public void test(int j){
        synchronized (this){
            for (int i = 0; i < 10; i++) {
                log.info("test - {} - {}",j,i);
            }
        }
    }
    //使用線程池方法進行測試:
    public static void main(String[] args) {
        SynchronizedExample example1 = new SynchronizedExample();
        SynchronizedExample example2 = new SynchronizedExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(()-> example1.test(1));
        executorService.execute(()-> example2.test(2));
    }
}

結果:不同對象之間的操作互不影響
這裏寫圖片描述

synchronized 修飾一個方法

被修飾的方法稱爲同步方法,作用的範圍是大括號括起來的部分,作用的對象是調用這段代碼的對象
驗證:

public class SynchronizedExample 
    public synchronized void test(int j){
        for (int i = 0; i < 10; i++) {
            log.info("test - {} - {}",j,i);
        }
    }
    //驗證方法與上面相同
    ...
}

結果:不同對象之間的操作互不影響
這裏寫圖片描述

TIPS:
如果當前類是一個父類,子類調用父類的被synchronized修飾的方法,不會攜帶synchronized屬性,因爲synchronized不屬於方法聲明的一部分

synchronized 修飾一個靜態方法

作用的範圍是synchronized 大括號括起來的部分,作用的對象是這個類的所有對象
驗證:

public class SynchronizedExample{
    public static synchronized void test(int j){
        for (int i = 0; i < 10; i++) {
            log.info("test - {} - {}",j,i);
        }
    }
    //驗證方法與上面相同
    ...
}

結果:同一時間只有一個線程可以執行
這裏寫圖片描述

synchronized 修飾一個類

驗證:

public class SynchronizedExample{
    public static void test(int j){
        synchronized (SynchronizedExample.class){
            for (int i = 0; i < 10; i++) {
                log.info("test - {}-{}",j,i);
            }
        }
    }
    //驗證方法與上面相同
    ...
}

結果:同一時間只有一個線程可以執行
這裏寫圖片描述

原子性操作各方法間的對比

  • synchronized:不可中斷鎖,適合競爭不激烈,可讀性好
  • Lock:可中斷鎖,多樣化同步,競爭激烈時能維持常態
  • Atomic:競爭激烈時能維持常態,比Lock性能好,每次只能同步一個值
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章