還在用Synchronized?Atomic你瞭解不?

Java3y發表於2018-11-22

前言

只有光頭才能變強

之前已經寫過多執行緒相關的文章了,有興趣的同學可以去了解一下:

多執行緒文章

在閱讀《阿里巴巴 Java開發手冊》讀後感時,還有未解決的問題:

如果是count++操作,使用如下類實現: AtomicInteger count = new AtomicInteger(); count.addAndGet(1);如果是 JDK8,推薦使用 LongAdder 物件,比 AtomicLong 效能更好(減少樂觀鎖的重試次數)。

之前在學習的時候也看過AtomicInteger類很多次了,一直沒有去做相關的筆記。現在遇到問題了,於是就過來寫寫筆記,並希望在學習的過程中解決掉問題

一、基礎鋪墊

首先我們來個例子:


public class AtomicMain {

    public static void main(String[] args) throws InterruptedException {

        ExecutorService service = Executors.newCachedThreadPool();

        Count count = new Count();
        // 100個執行緒對共享變數進行加1
        for (int i = 0; i < 100; i++) {
            service.execute(() -> count.increase());
        }

        // 等待上述的執行緒執行完
        service.shutdown();
        service.awaitTermination(1, TimeUnit.DAYS);


        System.out.println("公眾號:Java3y---------");
        System.out.println(count.getCount());
    }

}

class Count{

    // 共享變數
    private Integer count = 0;
    public Integer getCount() {
        return count;
    }
    public  void increase() {
        count++;
    }
}
複製程式碼

你們猜猜得出的結果是多少?是100嗎?

多執行幾次可以發現:結果是不確定的,可能是95,也可能是98,也可能是100

結果不確定

根據結果我們得知:上面的程式碼是執行緒不安全的!如果執行緒安全的程式碼,多次執行的結果是一致的!

我們可以發現問題所在:count++不是原子操作。因為count++需要經過讀取-修改-寫入三個步驟。舉個例子:

  • 如果某一個時刻:執行緒A讀到count的值是10,執行緒B讀到count的值也是10
  • 執行緒A對count++,此時count的值為11
  • 執行緒B對count++,此時count的值也是11(因為執行緒B讀到的count是10)
  • 所以到這裡應該知道為啥我們的結果是不確定了吧。

要將上面的程式碼變成執行緒安全的(每次得出的結果是100),那也很簡單,畢竟我們是學過synchronized鎖的人:

  • increase()加synchronized鎖就好了

public synchronized void increase() {
    count++;
}
複製程式碼

無論執行多少次,得出的都是100:

結果都是100

從上面的程式碼我們也可以發現,只做一個++這麼簡單的操作,都用到了synchronized鎖,未免有點小題大做了。

  • Synchronized鎖是獨佔的,意味著如果有別的執行緒在執行,當前執行緒只能是等待!

於是我們原子變數的類就登場了!

1.2CAS再來看看

在寫文章之前,本以為對CAS有一定的瞭解了(因為之前已經看過相關概念,以為自己理解了)..但真正敲起鍵盤寫的時候,還是發現沒完全弄懂...所以再來看看CAS吧。

來源維基百科:

比較並交換(compare and swap, CAS),是原子操作的一種,可用於在多執行緒程式設計中實現不被打斷的資料交換操作,從而避免多執行緒同時改寫某一資料時由於執行順序不確定性以及中斷的不可預知性產生的資料不一致問題。 該操作通過將記憶體中的值與指定資料進行比較,當數值一樣時將記憶體中的資料替換為新的值。

CAS有3個運算元:

  • 記憶體值V
  • 舊的預期值A
  • 要修改的新值B

當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值(A和記憶體值V相同時,將記憶體值V修改為B),而其它執行緒都失敗,失敗的執行緒並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試(或者什麼都不做)

我們畫張圖來理解一下:

CAS理解

我們可以發現CAS有兩種情況:

  • 如果記憶體值V和我們的預期值A相等,則將記憶體值修改為B,操作成功!
  • 如果記憶體值V和我們的預期值A不相等,一般也有兩種情況:
    • 重試(自旋)
    • 什麼都不做

我們再繼續往下看,如果記憶體值V和我們的預期值A不相等時,應該什麼時候重試,什麼時候什麼都不做。

1.2.1CAS失敗重試(自旋)

比如說,我上面用了100個執行緒,對count值進行加1。我們都知道:如果線上程安全的情況下,這個count值最終的結果一定是為100的。那就意味著:每個執行緒都會對這個count值實質地進行加1

我繼續畫張圖來說明一下CAS是如何重試(迴圈再試)的:

CAS迴圈重試

上面圖只模擬出兩個執行緒的情況,但足夠說明問題了。

1.2.2CAS失敗什麼都不做

上面是每個執行緒都要為count值加1,但我們也可以有這種情況:將count值設定為5

我也來畫個圖說明一下:

CAS失敗什麼都不做

理解CAS的核心就是:CAS是原子性的,雖然你可能看到比較後再修改(compare and swap)覺得會有兩個操作,但終究是原子性的!

二、原子變數類簡單介紹

原子變數類在java.util.concurrent.atomic包下,總體來看有這麼多個:

原子變數類

我們可以對其進行分類:

  • 基本型別:
    • AtomicBoolean:布林型
    • AtomicInteger:整型
    • AtomicLong:長整型
  • 陣列:
    • AtomicIntegerArray:陣列裡的整型
    • AtomicLongArray:陣列裡的長整型
    • AtomicReferenceArray:陣列裡的引用型別
  • 引用型別:
    • AtomicReference:引用型別
    • AtomicStampedReference:帶有版本號的引用型別
    • AtomicMarkableReference:帶有標記位的引用型別
  • 物件的屬性:
    • AtomicIntegerFieldUpdater:物件的屬性是整型
    • AtomicLongFieldUpdater:物件的屬性是長整型
    • AtomicReferenceFieldUpdater:物件的屬性是引用型別
  • JDK8新增DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder
    • 是對AtomicLong等類的改進。比如LongAccumulator與LongAdder在高併發環境下比AtomicLong更高效。

Atomic包裡的類基本都是使用Unsafe實現的包裝類。

Unsafe裡邊有幾個我們喜歡的方法(CAS):


// 第一和第二個引數代表物件的例項以及地址,第三個引數代表期望值,第四個引數代表更新值
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);

複製程式碼

從原理上概述就是:Atomic包的類的實現絕大呼叫Unsafe的方法,而Unsafe底層實際上是呼叫C程式碼,C程式碼呼叫匯編,最後生成出一條CPU指令cmpxchg,完成操作。這也就為啥CAS是原子性的,因為它是一條CPU指令,不會被打斷。

2.1原子變數類使用

既然我們上面也說到了,使用Synchronized鎖有點小題大作了,我們用原子變數類來改一下:


class Count{

    // 共享變數(使用AtomicInteger來替代Synchronized鎖)
    private AtomicInteger count = new AtomicInteger(0);
    
    public Integer getCount() {
        return count.get();
    }
    public void increase() {
        count.incrementAndGet();
    }
}


// Main方法還是如上

複製程式碼

修改完,無論執行多少次,我們的結果永遠是100!

其實Atomic包下原子類的使用方式都不會差太多,瞭解原子類各種型別,看看API,基本就會用了(網上也寫得比較詳細,所以我這裡果斷偷懶了)...

2.2ABA問題

使用CAS有個缺點就是ABA的問題,什麼是ABA問題呢?首先我用文字描述一下:

  • 現在我有一個變數count=10,現在有三個執行緒,分別為A、B、C
  • 執行緒A和執行緒C同時讀到count變數,所以執行緒A和執行緒C的記憶體值和預期值都為10
  • 此時執行緒A使用CAS將count值修改成100
  • 修改完後,就在這時,執行緒B進來了,讀取得到count的值為100(記憶體值和預期值都是100),將count值修改成10
  • 執行緒C拿到執行權,發現記憶體值是10,預期值也是10,將count值修改成11

上面的操作都可以正常執行完的,這樣會發生什麼問題呢??執行緒C無法得知執行緒A和執行緒B修改過的count值,這樣是有風險的。

下面我再畫個圖來說明一下ABA的問題(以連結串列為例):

CAS ABA的問題講解

2.3解決ABA問題

要解決ABA的問題,我們可以使用JDK給我們提供的AtomicStampedReference和AtomicMarkableReference類。

AtomicStampedReference:

An {@code AtomicStampedReference} maintains an object referencealong with an integer "stamp", that can be updated atomically.

簡單來說就是在給為這個物件提供了一個版本,並且這個版本如果被修改了,是自動更新的。

原理大概就是:維護了一個Pair物件,Pair物件儲存我們的物件引用和一個stamp值。每次CAS比較的是兩個Pair物件



	// Pair物件
    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;

	// 比較的是Pari物件
    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)));
    }

複製程式碼

因為多了一個版本號比較,所以就不會存在ABA的問題了。

2.4LongAdder效能比AtomicLong要好

如果是 JDK8,推薦使用 LongAdder 物件,比 AtomicLong 效能更好(減少樂觀鎖的重試次數)。

去查閱了一些部落格和資料,大概的意思就是:

  • 使用AtomicLong時,在高併發下大量執行緒會同時去競爭更新同一個原子變數,但是由於同時只有一個執行緒的CAS會成功,所以其他執行緒會不斷嘗試自旋嘗試CAS操作,這會浪費不少的CPU資源。
  • 而LongAdder可以概括成這樣:內部核心資料value分離成一個陣列(Cell),每個執行緒訪問時,通過雜湊等演算法對映到其中一個數字進行計數,而最終的計數結果,則為這個陣列的求和累加
    • 簡單來說就是將一個值分散成多個值,在併發的時候就可以分散壓力,效能有所提高。

參考資料:

最後

參考資料:

如果你覺得我寫得還不錯,瞭解一下:

  • 堅持原創的技術公眾號:Java3y。回覆 1 加入Java交流群
  • 文章的目錄導航(精美腦圖+海量視訊資源):github.com/ZhongFuChen…

相關文章