java 基礎回顧 - 基於 CAS 實現原子操作的基本理解

1. 什麼是原子操作

所謂原子操作是指不會被打斷的操作,這種”打斷”在操作系統層面, 一般是指線程間的上下文切換. 這種操作一旦開始, 就一直運行到結束. 簡單來說, 就是這個操作無論多複雜要麼都成功, 要麼全都失敗.

2. 怎麼實現原子操作

實現原子操作可以使用鎖, 使用鎖機制來滿足基本的需求是沒問題的, 但是有的時候我們需求並非那麼簡單, 我們需要更有效, 更加靈活的機制. synchronized 關鍵字是基於阻塞的鎖機制, 也就是說當一個線程有鎖的時候, 訪問同一資源的其他線程需要等待, 直到該線程釋放鎖.

volatile 是不錯的機制, 但是 volatile 並不能保證原子性.

那麼如果被阻塞的線程優先級很高很重要怎麼辦? 如果獲得鎖的線程一直不釋放怎麼辦? 同時還有可能出現例如死鎖之類的情況. 其實鎖機制是一種比較粗糙, 粒度較大的機制. 而且加鎖, 釋放鎖會導致比較多的上下文切換和調度延時, 引起性能問題. 那麼這裏就引出了另外一種實現原子操作的機制 CAS 機制. 那麼什麼又是 CAS 呢.

3. CAS 的基本理解

CASCompare And Swap (比較並交換), 是利用現代 CPU 的一個指令. 同時藉助 JNI 來完成 Java 的非阻塞算法
CAS 的操作包含三個操作數 —— 內存位置 (V). 預期原值/舊值 (A). 和新值 (B). 如果內存位置的值與預期原值/舊值相匹配, 那麼處理器會自動將該位置值更新爲新值. 否則, 處理器不做任何操作. 無論哪種情況, 它都會在 CAS 指令之前返回該 位置的值. CAS 有效地說明了 “我認爲位置 V 應該包含值 A, 如果包含該值, 則將 B 放到這個位置. 否則, 不要更改該位置, 只告訴我這個位置現在的值即可. ”

通常將CAS 用於 同步 的方式從內存中讀取舊值, 執行多步計算來獲得新值, 準備將新值寫入的時候, 執行 CAS 指令, 如果內存中的值與預期的舊值相匹配, 則將新值寫入. 若不匹配則表示內存中的值已經被修改, 不做任何操作; 同時返回現在的值. 對現在的值再次進行計算, 再一次執行 CAS操作. 也叫自旋.

例如一個變量 x = 0, 然後 A, B 兩個線程拿到 x 的值, 對 x 進行累加. 累加後, 這時候兩個線程中的 x 值都爲 1, x 舊值都爲 0.

  1. 兩個線程對 x 累加完在準備寫入的時候, A 線程執行 CAS 指令, 這時候內存中 x 的值爲 0. 通過比較內存位置的值與 A 線程中 x 的舊值比較發現匹配, 則將 x 的值更新爲新的值. 那麼這個時候 x 內存中的值爲 1.

  2. 到 B 線程來執行 CAS 指令. 這時候 B 線程中 x 的舊值還是一開始的 0, 通過與內存位置值比較發現不匹配, 說明 x 的值已經被修改過了, 就會將內存中 x = 1 重新給線程 B, 線程 B 就會再來執行一次累加的操作 (累加後 B 線程中的 x =2, x舊值爲1) ,然後再次執行 CAS指令. 拿 B 線程中 x 的舊值與內存中 x 的值進行對比較, 發現匹配, 則更新 x 的值爲 2.

如下圖所示.


JDK5 之前 Java 是靠 synchronized 關鍵字保證同步的,這是一種獨佔鎖,也是悲觀鎖。
JDK5 增加了併發包 java.util.concurrent.*, 其內部以 Atomic 開頭的原子操作類都是基於 CAS 機制實現了區別於 synchronouse 同步鎖的一種樂觀鎖。

5. CAS 實現原子操作的三大問題

  • ABA 問題
    因爲 CAS 需要在操作值的時候, 檢查值有沒有發生變化, 如果沒有發生變化則更新. 但是如果有一個值原來是 A, 有一個線程變爲 B 後, 又變成了 A, 那麼使用 CAS 進行比較檢查的時候發現它的值是沒有發生變化, 但是實際上是發生了變化的. 不過 ABA 問題可以通過使用版本號的方式來解決, 就是在變量前追加上版本號. 每次變量更新的時候把版本號加 1, 那麼 A-B-A, 就變成了 1A-2B-3A. JDK 也同樣提供了兩個類來幫助我們實現這個版本號的問題, 分別是 AtomicStampedReference, AtomicMarkableReference 這兩個還是有所不同的, 下面會有說明.

  • 循環時間長開銷大
    自旋 CAS 如果長時間都不成功, 那麼會給 CPU 帶來非常大的執行開銷. 這個目前好像無解, 但是如果 JVM 能支持處理器提供的 pause 指令那麼效率會有一定的提升, pause 指令有兩個作用,

    • 第一它可以延遲流水線執行指令(de-pipeline), 使 CPU 不會消耗過多的執行資源, 延遲的時間取決於具體實現的版本, 在一些處理器上延遲時間是零.
    • 第二它可以避免在退出循環的時候因內存順序衝突 (memory order violation) 而引起 CPU 流水線被清空(CPU pipeline flush), 從而提高 CPU 的執行效率.
  • 只能保證一個共享變量的原子操作
    當對一個共享變量進行操作時, 我們可以使用自旋 CAS 的方式來保證原子操作, 但是對於多個共享變量操作時, 自旋CAS 就無法保證操作的原子性, 這個時候就可以使用鎖的機制.
    同樣, JDK也提供了 AtomicReference 原子操作類來保證引用對象之間的原子性, 就可以把多個原子變量放在一個對象裏來進行 CAS 操作. 下面也會有對其進行說明.

6. JDK 中相關原子操作類的使用示例

JDK 中爲我們提供了相關的原子操作類, 常用的如下.
更新基本類型: AtomicBoolean, AtomicInteger, AtomicLong.
更新數組類型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray.
更新引用類型: AtomicReference, AtomicMarkableReference, AtomicStampedReference

  • AtomicInteger 用法基本示例
static AtomicInteger ai = new AtomicInteger(10);
public static void main(String[] args) throws InterruptedException {
    //getAndIncrement 返回的是自增前的值
    //int beforeValue = ai.getAndIncrement();
    //輸出 自增前的值:10
    //System.out.println("自增前的值:" + beforeValue);
    //輸出 自增後的值:11
    //System.out.println("自增後的值:" + ai);

    //incrementAndGet 返回的是自增後的值
    //int afterValue = ai.incrementAndGet();
    //輸出 11
    //System.out.println("afterValue:" + afterValue);

    //addAndGet 增加指定的值並返回增加後的值, 輸出 110
    //System.out.println(ai.addAndGet(100));

    //getAndAdd 增加指定的值並返回增加前的值, 輸出 10
    //System.out.println(ai.getAndAdd(100));

    //ai 的值是否符合期望值, 符合則修改爲 200, 並返回 true., 輸出 true.
    //System.out.println(ai.compareAndSet(1,200));
    //輸出 200
    //System.out.println(ai);
}

int getAndIncrement(): 以原子操作的方式將當前值加 1. 這裏返回的是自增前的值.
int incrementAndGet(): 也是以原子操作的方式將當前值加 1, 這裏返回的是自增後的值.
int addAndGet(int delta): 以原子方式將輸入的數值與實例中的值相加, 並返回相加後的結果.
int getAndAdd(int delta): 以原子方式將輸入的數值與實例中的值相加, 並返回相加前的結果.
boolean compareAndSet(int 期望值, int 新值): 如果輸入的數值等於期望值, 則以原子方式將該值設置爲輸入的值.設置成功返回 true

  • AtomicIntegerArray 用法基本示例
    AtomicIntegerArray 方法的使用基本和 AtomicInteger 基本類似, 不同的是, AtomicIntegerArray 構造方法有兩個.
public AtomicIntegerArray(int length) {
    array = new int[length];
}

public AtomicIntegerArray(int[] array) {
    // Visibility guaranteed by final field guarantees
    this.array = array.clone();
}

第一個構造方法不必多說, 就是創建一個指定長度的 AtomicIntegerArray 數組.
主要是第二個構造方法, 傳入一個數組後 AtomicIntegerArray 會將傳入的數組複製一份, 所以當 AtomicIntegerArray 對內部的數組元素進行修改的時候, 不會影響到傳入的數組.

常用方法調用:
int getAndSet(int i, int newValue): 以原子的方式將數組中索引i的元素值設置爲newValue.返回數組中索引i元素設置前的值.
int getAndAdd(int i, int delta): 以原子的方式將 delta 與數組索引 i 的元素值相加. 返回數組中索引 i 元素相加前的值.
int addAndGet(int i, int delta): 以原子的方式將 delta 與數組索引 i 的元素值相加. 返回數組中索引 i 元素相加後的值.
boolean compareAndSet(int i, int expectedValue, int newValue): 如果數組索引 i的元素值等於期望值 expectedValue, 那麼將以原子方式將位置i的值設置爲 newValue.

  • AtomicReference用法基本示例
static class UserInfo {
    private String name;
    private int age;

    public UserInfo(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {return name;}
    public int getAge() {return age;}

    @Override
    public String toString() {
        return "UserInfo{" + "name='" + name + '\'' + ", age=" + age + '}';
    }
}
static AtomicReference<UserInfo> atomicReference;
  public static void main(String[] args) {
      UserInfo userInfo = new UserInfo("張三", 28);
      atomicReference = new AtomicReference<>(userInfo);
      //或者
      //atomicReference = new AtomicReference<>();
      //atomicReference.set(userInfo);

      //與預期值不符合, 輸出 UserInfo{name='張三', age=28}
      // UserInfo expectedValue = new UserInfo("張三", 30);
      // UserInfo updateUser = new UserInfo("李四", 30);
      // atomicReference.compareAndSet(expectedValue,updateUser);
      // System.out.println(atomicReference.get());

      //與預期值匹配, 這更新爲新值.輸出 UserInfo{name='李四', age=30}
      //UserInfo updateUser = new UserInfo("李四", 30);
      //atomicReference.compareAndSet(userInfo,updateUser);
      //System.out.println(atomicReference.get());
  }

用法基本和其他的都基本類似. 這裏就不再進行說明.

  • AtomicStampedReference
    上面說過, 通過 AtomicStampedReferenceAtomicMarkableReference 利用版本號可以解決 ABA 的問題. 記錄了每次改變以後的版本號. 這樣的話就不會存在 ABA 問題.
    AtomicStampedReference 是使用 pairint stamp 作爲計數器使用.
    AtomicMarkableReferencepair 使用的是 boolean 來記錄.不關心次數的問題, 只關心有沒有被改動過.
    他們兩個的用法差不多, 這裏就只用 AtomicStampedReference 爲例來解決一個 ABA 的問題.
//創建一個帶版本號的 String 類型的變量, 值爲"張三", 版本號爲 1
static AtomicStampedReference<String> asr = new AtomicStampedReference<>("張三",1);

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

    System.out.println("原始值爲:" + asr.getReference() +"----原始版本號爲:"+ asr.getStamp());

    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            //參數依次爲 期望值, 新值, 期望版本號, 新版本號
            boolean compareAndSetResult = asr.compareAndSet(asr.getReference(),"李四", asr.getStamp(), asr.getStamp() + 1);
            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 後的值爲:" + asr.getReference()
                    + "----版本號爲:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult);

            boolean compareAndSetResult2 = asr.compareAndSet(asr.getReference(),"張三", asr.getStamp(), asr.getStamp() + 1);
            System.out.println(Thread.currentThread().getName()
                    + "----第二次 compareAndSet 後的值爲:" + asr.getReference()
                    + "----版本號爲:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult2);
        }
    },"線程 1");

    Thread thread2 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 前的值爲:" + asr.getReference()
                    + "----版本號爲:" + asr.getStamp());

            boolean compareAndSetResult = asr.compareAndSet(asr.getReference(), "麻子", asr.getStamp(), asr.getStamp() + 1);

            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 後的值爲:" + asr.getReference()
                    + "----版本號爲:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult);
        }
    },"線程 2");

    thread1.start();
    thread1.join();
    thread2.start();
    thread2.join();

    System.out.println("最後值爲:" + asr.getReference() +"----版本號爲:"+ asr.getStamp());
}

輸出結果爲

原始值爲:張三----原始版本號爲:1
線程 1----第一次 compareAndSet 後的值爲:李四----版本號爲:2----修改是否成功:true
線程 1----第二次 compareAndSet 後的值爲:張三----版本號爲:3----修改是否成功:true
線程 2----第一次 compareAndSet 前的值爲:張三----版本號爲:3
線程 2----第一次 compareAndSet 後的值爲:麻子----版本號爲:4----修改是否成功:true
最後值爲:麻子----版本號爲:4

在線程 1 中第一次將值改爲了 "李四", 第二次再將 "李四" 改爲 "張三"
接着在線程 2 中拿到的"張三", 版本號已經變成了 3. 這也就解決了 ABA 的問題.


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