Java無鎖系列——1、CAS 簡單介紹及使用

概述

在前一個系列中,我簡單整理了 synchronized 鎖的使用和原理,即採用 Monitor 管程對象控制同時只能有一個線程佔有鎖對象,以此來保證多線程場景下的線程安全。除了這種通過鎖對象實現的同步,還有一種在不使用鎖的情況下實現同步的方式,這種無鎖同步的實現原理是 CAS。本篇我就來介紹下 CAS 相關的知識。


無鎖同步

本篇博客分以下幾個模塊展開:

  1. 樂觀鎖和悲觀鎖
  2. CAS 簡單介紹
  3. CAS 如何使用
  4. CAS 的優勢
  5. 如何解決 ABA 問題
  6. CAS 實現自旋鎖

1、樂觀鎖和悲觀鎖

在正式介紹 CAS 前,我先引出以下兩個概念:

  • 悲觀鎖:所有事情總往壞的一面考慮。對應到多線程場景下,也就是說拿到數據後,總覺得該數據會被其他線程修改,因此任何操作都需要做加鎖處理。

  • 樂觀鎖:所有事情總往好的一面考慮。對應到多線程場景下,也就是說拿到數據後,總覺得該數據不會被其他線程修改,因此不做任何同步處理,只是每次操作前判斷該數據是否被修改。

有了上面的定義,我們很容易發現 synchronized 鎖就是最常見的悲觀鎖。因爲任何線程過來,執行到該關鍵字對應的代碼塊或方法後,都需要進行加解鎖處理。

樂觀鎖是相對悲觀鎖提出的概念。對應悲觀鎖的任何同步操作都加鎖,樂觀鎖的同步操作都不加鎖。本篇我們所要介紹的無鎖同步就是一種典型的樂觀鎖,CAS 是實現無鎖同步的關鍵。


2、CAS 簡單介紹

CAS 的全稱是 “Compare And Swap” 即比較和交換。有時候也可以簡寫爲 CAP

它的核心原理是:操作前比較當前值是否等於期望值

  • 如果值相等,說明當前線程讀取數據到準備修改數據這段時間,沒有其他線程修改數據值。因此可以接着向下執行

  • 如果值不相等,說明期間值已經被其他線程處理,後序不做任何處理

一般情況下,CAS 思想可以用以下僞代碼表示:

CAS(oldValue, hopeValue, newValue);

其中 oldValue 表示修改前的數據值,hopeValue 表示期望值,newValue 表示要修改的新值。下面我通過簡單的流程圖介紹整個過程:

CAS 核心思想
CAS 是樂觀鎖的一種實現方式,雖然它總是認爲其他線程不會修改數據值,但實際應用中就不一定了。當多個線程通過 CAS 修改同一變量時,只會有一個線程執行成功。執行失敗的線程不會像 synchronized 鎖那樣被掛起,CAS 方法會返回 false 提示當前線程執行失敗。實際應用中可以根據相應的業務需求決定接下來如何操作,可以選擇放棄操作,接着向下執行,或是說繼續執行,通過 while 循環關鍵字直到當前線程執行成功。

最後我簡單提一句:CAS 在CPU層面是線程安全的原子操作。也就是說,整個執行過程是連續的,不會在執行期間中斷或上下文切換到其他線程。也就是說,線程從判斷期望值是否相等到執行後續操作的這段時間,不會有線程修改當前數據值。這也是 CAS 能夠實現無鎖同步的主要原理,關於 java 如何實現 CAS,後序我通過其它博客着重介紹。


3、CAS 如何使用

在 java 代碼中,CAS 操作最常出現在 JDK 5 引入的原子併發包下( java.util.concurrent.atomic )。

根據操作對象的不同,CAS 可以分以下四種:

  1. CAS 更新基本類型
  2. CAS 更新對象引用
  3. CAS 更新數組類型
  4. CAS 更新對象屬性

3-1、CAS 更新基本類型

CAS 更新基本類型主要包括以下三種:

  • CAS 修改 Integer 類型變量
  • CAS 修改 Long 類型變量
  • CAS 修改 Boolean 類型變量

上述這幾種處理方式比較類似,這裏我主要給出 CAS 修改 Integer 類型變量的案例:

public class AtomicIntegerTest {

    AtomicInteger atomicInteger = new AtomicInteger(0);

    class Worker implements Runnable {

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                atomicInteger.incrementAndGet();
            }
        }
    }

    @Test
    public void test() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            new Thread(new Worker()).start();
        }
        Thread.sleep(3000);
        System.out.println(atomicInteger.get());
    }

}

執行結果:1000000

在上述Demo中,我們使用併發包下的 AtomicInteger 類保證線程的安全性。其中它的底層是通過 CAS 實現的,從執行結果我們可以看出,的確沒有出現線程安全問題。


3-2、CAS 更新對象引用

CAS 更新對象引用和更新基礎類型相似。只是更新基礎類型時比較具體的數據值,更新對象引用時比較引用指向的對象地址,核心思想是一樣的。

需要注意的一點是,更新對象引用時,即使對象屬性發生變化,也不會影響 CAS 方法的執行。兩個屬性完全相同的對象,如果地址不同,執行 CAS 所得到的結果也不相同。具體我們看實例:

public class AtomicReferenceTest {

    class Demo {
        int id;
        String name;

        public Demo(int id, String name) {
            this.id = id;
            this.name = name;
        }

        @Override
        public String toString() {
            return "Demo{" +
                    "id=" + id +
                    ", name='" + name + '\'' +
                    '}';
        }
    }

    @Test
    public void test() {
        Demo demo = new Demo(1, "測試");
        Demo demo2 = new Demo(2, "測試2");
        Demo demo3 = new Demo(3, "測試3");
        AtomicReference<Demo> atomicReference = new AtomicReference<>(demo);
        System.out.println("CAS 修改前:" + atomicReference.get().toString());
        atomicReference.compareAndSet(demo, demo2);
        System.out.println("CAS 修改後:" + atomicReference.get().toString());
        demo2.id = 3;
        demo2.name = "測試3";
        atomicReference.compareAndSet(demo3, demo);
        System.out.println("CAS 修改後:" + atomicReference.get().toString());
    }
}

執行結果

CAS 修改前:Demo{id=1, name='測試'}
CAS 修改後:Demo{id=2, name='測試2'}
CAS 修改後:Demo{id=3, name='測試3'}

在上述代碼中,首先通過 CAS 的方式將 demo 對象更換爲 demo2 對象。後面將 demo2 對象的屬性改爲和 demo3 相同,根據 demo3 對象判斷是否地址相等,如果地址相等就修改爲 demo 對象。

從運行結果可以看出,第一次執行 CAS 操作時,根據 demo 對象修改爲 demo2 對象成功。當我們把 demo2 對象屬性修改爲和 demo3對象相同時,根據 demo3 對象修改 demo 對象失敗。也就是說整個過程是根據對象地址來實現的,而不是根據對象屬性。


3-3、CAS 更新數組類型

CAS 更新數組類型主要分以下三種:

  • CAS 更新整數數組中的元素
  • CAS 更新長整數數組中的元素
  • CAS 更新引用類型數組中的元素

以上三種類型的處理方式類似,這裏我主要給出 CAS 更新整數數組的具體實例:

public class AtomicIntegerArrayTest {

    AtomicIntegerArray array = new AtomicIntegerArray(10);

    class Worker implements Runnable {

        @Override
        public void run() {
            for (int i = 0; i < 100000; i++) {
                array.incrementAndGet(i % array.length());
            }
        }
    }

    @Test
    public void test() throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Worker()).start();
        }
        Thread.sleep(3000);
        for (int i = 0; i < array.length(); i++) {
            System.out.println(array.get(i));
        }
    }
}

執行結果

100000
100000
100000
100000
100000
100000
100000
100000
100000
100000

上述代碼中,我們創建10個線程併發執行,每個線程在每個數組元素上自增 10000次。從運行結果可以看出,數組中所有元素都自增 100000 次,沒有出現線程安全問題。其中它內部通過字節計算確定數組元素的地址,後續通過 CAS 操作執行自增。關於這塊的原理,後續我們通過其他博客專門討論。


3-4、CAS 更新對象屬性

在部分業務場景下,對象本身是不能更換的,但對象中部分屬性需要更新。如果這個對象被多個線程所共享,那麼就必須考慮線程安全問題。如果採用有鎖的方式解決,只需通過 synchronized 關鍵字修飾對應獲取屬性的方法。此時只需要一個線程修改對應屬性值,那麼當其他線程獲取屬性值時,都會獲取到更新後的屬性。

在無鎖同步中,CAS 併發包提供了以下三種處理對象屬性的方式:

  • CAS 更新對象 Integer 類型屬性
  • CAS 更新對象 Long 類型屬性
  • CAS 更新對象引用類型屬性

當使用 CAS 更新對象屬性時,關於屬性的限制條件也比較多:

  • 屬性必須通過 volatile 關鍵字修飾
  • 屬性不能是 static 修飾的
  • 屬性不能是 final 修飾的
  • 在調用 cas 修改屬性時,屬性訪問權限必須包含的

下面我簡單給出通過 CAS 修改 Integer 類型屬性的示例:

public class AtomicIntegerFieldUpdaterTest {

    class Node {
        volatile int num = 0;
    }

    Node node = new Node();

    class Worker implements Runnable {

        @Override
        public void run() {
            AtomicIntegerFieldUpdater<Node> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(Node.class, "num");
            for (int i = 0; i < 10000; i++) {
                fieldUpdater.incrementAndGet(node);
            }
        }
    }

    @Test
    public void test() throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Worker()).start();
        }
        Thread.sleep(3000);
        System.out.println(node.num);
    }
}

執行結果:100000

上述代碼中,我們根據對象類型,屬性名稱創建 AtomicIntegerFieldUpdater 對象,通過該對象調用 CAS 函數修改 node 對象的 num 屬性值。其中每個線程自增10000次,共十個線程併發執行,從運行結果來說,整個過程是線程安全的。


4、CAS 的優勢

關於 CAS 的優勢,我認爲主要有以下兩點:

  • 無死鎖
  • 效率高

無死鎖:之前我們提到,產生死鎖的主要條件是形成環形等待。而無鎖操作在執行失敗時直接返回 false,不會阻塞。也就是說,線程之間不存在互相等待的情況。因此 CAS 可以從源頭避免死鎖發生。

效率高:這裏的效率高是相對 synchronized 有鎖操作來說的。無鎖同步相比有鎖同步在操作系統層面執行較少的上下文切換,而每次上下文切換都需要耗費不少資源,因此無鎖相比有鎖更加高效。

在操作系統層面,爲了讓較少的CPU執行遠超CPU數量的任務,需要頻繁的進行上下文切換。操作系統分配給每個任務相應的時間片,當時間片執行完畢後,就上下文切換到下個任務。在 synchronized 有鎖同步中,當線程搶佔鎖失敗導致阻塞後,即使時間片還沒有執行完,仍需要上下文切換到其他線程。這也就導致,有鎖操作需要更多的上下文切換。而無鎖操作失敗後,根據代碼規則繼續向下執行,線程本身不會阻塞。也就是說,無鎖操作失敗不會導致更多的上下文切換髮生,這也是爲什麼無鎖操作效率更高的主要原因。

當然上面只是只是一種理想情況,實際應用場景中,如果爲了同步,導致 CAS 自旋判斷的次數過多。那麼線程自身佔用CPU執行所消耗的資源可能已經大於加鎖在操作系統層面阻塞所帶來的消耗,在這種場景下,synchronzed 鎖的效率就比 CAS 高了。


5、如何解決 ABA 問題

CAS 的核心思想是操作前判斷,如果操作前多個線程都修改了目標數據,最終修改爲和期望值相同,那麼就無法判斷是否有其他線程修改過目標數據,這也是CAS帶來的ABA問題。

舉個簡單的例子:

  1. 線程A 讀取數據值10
  2. 線程B 修改數據值爲20
  3. 線程C 修改數據值爲10
  4. 線程A 判斷當前值等於期望值,都是10
  5. 線程A 認爲獲取數據期間沒有其他線程修改數據值,接着向下執行

ABA 問題在有些場景下不會產生影響,如上述的加減運算類。但部分場景下還是有必要解決,因爲它導致當前線程無法準確判斷是否有其他線程修改數據。

JAVA 代碼中提供了以下兩個原子類解決 ABA 問題:

  • AtomicStampedReference

  • AtomicMarkableReference


5-1、AtomicStampedReference

AtomicStampedReference 的實現原理比較簡單,它在原先單期望值的基礎上,增加時間戳期望值,也就是說除了判斷數據值是否相等外,還需要判斷時間戳是否被修改。通過雙重判斷的方式增加整體 CAS 的準確度。下面我們看一個具體Demo:

public class AtomicStampedTest {
    AtomicStampedReference<Integer> num = new AtomicStampedReference<>(1, 0);
    
    class Worker implements Runnable {
        @Override
        public void run() {
            Integer time = num.getStamp();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Boolean bool = num.compareAndSet(1, 10, time, time + 1);
            if (!bool) {
                System.out.println("修改失敗,當前數據值爲:" + num.getReference() + ",期望值爲:" + 1 + "。但時間戳有異常");
            }
        }
    }
    
    class Worker1 implements Runnable {
        @Override
        public void run() {
            Integer time = num.getStamp();
            num.compareAndSet(1, 100, time, time + 1);
        }
    }
    
    class Worker2 implements Runnable {
        @Override
        public void run() {
            Integer time = num.getStamp();
            while (!num.compareAndSet(100, 1, time, time + 1)) {

            }
        }
    }
    
    @Test
    public void test() throws InterruptedException {
        new Thread(new Worker()).start();
        new Thread(new Worker1()).start();
        new Thread(new Worker2()).start();
        Thread.sleep(2000);
    }
}

執行結果:修改失敗,當前數據值爲:1,期望值爲:1。但時間戳有異常

在上述案例中,Worker 線程讀取期望值後,休眠 1s 時間。在休眠前面,Worker1 和 Worker2 線程分別將期望值修改爲100 和 1。此時當 Worker 線程執行時,即使期望值相同,由於時間戳不同,CAS 執行也失敗。

下面我們簡單看一下 AtomicStampedReference 類核心方法的源碼:

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

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

通過源碼我們可以看出,該方法首先判斷期望值和時間戳是否都相等。只有在都相等的情況下,才調用 casPair() 方法將新的值和時間戳賦予當前對象。這裏引出了 UNSAFE 類,該類是 CAS 底層實現的原理。關於 CAS 原理的介紹,後序我們通過其他博客專門展開說明。

有了 AtomicStampedReference 的介紹,我們再來看 AtomicMarkableReference。


5-2、AtomicMarkableReference

AtomicMarkableReference 和 AtomicStampedReference 本質上都是通過增加一個新的期望值的方式解決 ABA 問題。只是 AtomicStampedReference 通過新增時間戳的方式解決,而 AtomicMarkableReference 通過一個boolean 類型變量的方式來解決

一般情況下,時間戳相比 Boolean 類型變量準確度更高。而且 Boolean 類型變量不能完全解決 ABA 問題。下面我們看一個具體案例:

public class AtomicMarkableTest {

    AtomicMarkableReference<Integer> num = new AtomicMarkableReference<>(1, true);
    
    class Worker implements Runnable {
        @Override
        public void run() {
            Boolean jundge = num.isMarked();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if(num.compareAndSet(1, 10, jundge, !jundge)){
                System.out.println("當前線程修改成功,沒有發生ABA問題");
            }
        }
    }
    
    class Worker1 implements Runnable {
        @Override
        public void run() {
            Boolean jundge = num.isMarked();
            num.compareAndSet(1, 100, jundge, !jundge);
            System.out.println("Worker1 線程在 Worker 線程讀取數據期間修改數據");
        }
    }
    
    class Worker2 implements Runnable {
        @Override
        public void run() {
            Boolean jundge = num.isMarked();
            num.compareAndSet(100, 1, jundge, !jundge);
            System.out.println("Worker2 線程在 Worker 線程讀取數據期間修改數據");
        }
    }
    
    @Test
    public void test() throws InterruptedException {
        new Thread(new Worker()).start();
        new Thread(new Worker1()).start();
        new Thread(new Worker2()).start();
        Thread.sleep(2000);
    }
}

執行結果

Worker1 線程在 Worker 線程讀取數據期間修改數據
Worker2 線程在 Worker 線程讀取數據期間修改數據
當前線程修改成功,沒有發生ABA問題

從執行結果可以看出,Worker1 和 Worker2 線程在 Worker 線程讀取數據期間,都對數據值進行了修改。但是當 Worker 線程執行時無法真正判斷是否有ABA問題產生。因爲 Worker1 線程和 Worker2 線程通過兩輪操作,將數據值和 Boolean 類型值都設置爲期望值相同,因此無法判斷是有存在ABA問題。

從這裏也就可以看出,AtomicStampedReference 相對更加安全一點,我們每次操作將時間戳向前加一,這樣無論多少線程執行都不會導致時間戳最終等於前面線程讀取到的時間戳期望值,也就可以完全避免ABA 問題。


6、CAS 實現自旋鎖

最後我們再來聊一聊上個系列提到的自旋鎖。當時我們提到,jvm 爲了提高 synchronized 鎖的效率,當輕量級鎖升級爲重量級鎖後,爲了防止線程在操作系統層面掛起。首先會自旋一段時間。在自旋的過程中嘗試獲取鎖,獲取到鎖都就繼續向下執行,否則再掛起。

這裏我們可以通過 CAS 配合 while 循環模擬自旋獲取鎖的過程。用 CAS 返回 ture 模擬獲取鎖成功。如果返回false,通過 while 循環模擬自旋過程,重複進行 CAS 操作,直到獲取鎖成功。具體實例可以看一下代碼:

public class CAS_SpinLockDemo {

    class Lock {
        private int state;
        private String message;

        public Lock(int state, String message) {
            this.state = state;
            this.message = message;
        }
    }

    Lock lockState = new Lock(1, "鎖定狀態");
    Lock unLockState = new Lock(0, "未鎖定狀態");

    AtomicReference<Lock> atomicReference = new AtomicReference<>(unLockState);

    class Worker implements Runnable {
        @Override
        public void run() {
            int num = 0;
            String threadName = Thread.currentThread().getName();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            while (!atomicReference.compareAndSet(unLockState, lockState)) {
                num++;
            }
            System.out.println("線程:" + threadName + "在第" + num + "次自旋獲取鎖對象");
            System.out.println("線程:" + threadName + "釋放鎖對象");
            atomicReference.compareAndSet(lockState, unLockState);
        }
    }

    @Test
    public void test() throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Worker()).start();
        }
        Thread.sleep(5000);
    }
}

執行結果

線程:Thread-4在第0次自旋獲取鎖對象
線程:Thread-4釋放鎖對象
線程:Thread-5在第3648次自旋獲取鎖對象
線程:Thread-5釋放鎖對象
線程:Thread-6在第2657次自旋獲取鎖對象
線程:Thread-6釋放鎖對象
線程:Thread-3在第1000次自旋獲取鎖對象
線程:Thread-3釋放鎖對象
線程:Thread-2在第0次自旋獲取鎖對象
線程:Thread-2釋放鎖對象
線程:Thread-0在第5565次自旋獲取鎖對象
線程:Thread-0釋放鎖對象
線程:Thread-1在第0次自旋獲取鎖對象
線程:Thread-1釋放鎖對象
線程:Thread-9在第128次自旋獲取鎖對象
線程:Thread-9釋放鎖對象
線程:Thread-8在第0次自旋獲取鎖對象
線程:Thread-8釋放鎖對象
線程:Thread-7在第0次自旋獲取鎖對象
線程:Thread-7釋放鎖對象

在上述代碼中,我們通過兩個對象引用模擬鎖被獲取和鎖沒有被獲取兩種狀態。通過 CAS 修改對象引用模擬獲取鎖的過程。如果返回 true,說明鎖獲取成功,繼續向下執行並釋放鎖資源。如果返回 false,說明獲取鎖失敗,此時自旋次數加一併嘗試重新獲取鎖資源。

通過執行結果可以看出,部分線程確實通過一定次數的自旋後獲取到鎖資源才得以繼續向下執行。事實上在併發包中,基礎類型如 Integer、Long 的自增或自減操作也是通過這種配合 while,循環調用 CAS的方式實現的。


參考:
https://blog.csdn.net/javazejian/article/details/72772470
https://blog.csdn.net/qq_24672657/article/details/102731489
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章