深入淺出Java多執行緒(十):CAS

解码猿發表於2024-03-11

引言


在多執行緒程式設計中,對共享資源的安全訪問和同步控制是至關重要的。傳統的鎖機制,如synchronized關鍵字和ReentrantLock等,能夠有效防止多個執行緒同時修改同一資料導致的競態條件(race condition),但同時也帶來了一定的效能開銷。尤其是在高併發場景下,頻繁的加鎖解鎖操作可能導致執行緒上下文切換加劇、系統響應延遲等問題。

為了應對這一挑戰,Java從JDK 1.5版本開始引入了基於CAS(Compare And Swap)機制的原子類庫,這些原子類不僅提供了一種無鎖化的併發控制策略,還能夠在不阻塞其他執行緒的情況下實現高效的記憶體同步。CAS作為樂觀鎖的一種實現方式,其核心思想是在更新變數時僅當該變數的當前值與預期值相等時才會執行更新操作,否則就放棄更新並允許執行緒繼續嘗試或採取其他策略。

例如,在一個簡單的場景中,假設有一個被多個執行緒共享的整型變數i,若我們想要透過CAS將其從初始值5原子性地遞增到6,可以利用AtomicInteger類中的compareAndSet方法:

import java.util.concurrent.atomic.AtomicInteger;

public class CASExample {
    private static AtomicInteger sharedValue = new AtomicInteger(5);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (true) {
                int oldValue = sharedValue.get();
                if (sharedValue.compareAndSet(oldValue, oldValue + 1)) {
                    System.out.println("Thread " + Thread.currentThread().getName() + " updated the value to " + (oldValue + 1));
                    break;
                }
            }
        });

        t1.start();
        // 確保t1有機會更新值
        t1.join();

        // 輸出結果應為:Thread Thread-0 updated the value to 6
    }
}

在這個示例中,如果sharedValue的當前值確實是5,那麼執行緒t1將成功地將它更改為6,並退出迴圈;如果有其他執行緒在此期間改變了sharedValue的值,則t1會不斷重試直至成功。由於CAS操作直接由CPU指令級別保證其原子性,因此不會出現因併發寫入導致的資料混亂。

透過深入探討Java多執行緒中的CAS技術,我們將揭示其背後的具體實現原理——Unsafe類及其native方法,剖析AtomicInteger等原子類如何藉助CAS機制實現在無鎖環境下的高效併發操作,並進一步討論在實際應用中可能出現的問題,如ABA問題、迴圈自旋消耗過大以及只能針對單個變數進行原子操作的侷限性及其相應的解決方案。

在多執行緒程式設計領域中,鎖機制是實現資料同步和避免併發問題的關鍵手段。其中,樂觀鎖與悲觀鎖作為兩種不同的併發控制策略,在處理共享資源時採用了截然不同的假設和操作方式。

悲觀鎖&樂觀鎖


悲觀鎖

悲觀鎖,顧名思義,採取保守的策略對待併發訪問。它假定每次對共享資源進行操作時都可能發生衝突,因此在執行任何更新前都會預先鎖定資源。例如,在Java中使用synchronized關鍵字或ReentrantLock等工具實現悲觀鎖時,一個執行緒在獲取鎖後才能進入臨界區執行程式碼,其他執行緒則必須等待鎖釋放後才能獲得執行機會。以下是一個簡單的悲觀鎖示例:

public class PessimisticLockExample {
    private final Lock lock = new ReentrantLock();

    public void decrementCounter() {
        lock.lock(); // 獲取悲觀鎖
        try {
            // 臨界區程式碼
            int count = this.count;
            if (count > 0) {
                this.count--;
            }
        } finally {
            lock.unlock(); // 釋放悲觀鎖
        }
    }

    // 共享資源變數
    private int count = 10;
}

在這個例子中,當一個執行緒試圖修改計數器時,會先鎖定整個方法,確保同一時間只有一個執行緒能夠執行減一操作。這種機制雖然保證了資料一致性,但可能造成執行緒間的頻繁阻塞和上下文切換,尤其在高併發環境下效能損耗明顯。

樂觀鎖

相對而言,樂觀鎖則是基於積極樂觀的假設:認為大部分情況下多個執行緒同時訪問同一資源並不會發生衝突。因此,樂觀鎖允許執行緒無須獲取鎖就可以執行業務邏輯,僅在更新資料時採用CAS(Compare And Swap)原子操作檢查並更新資料。如果發現資料已被其它執行緒改變,則放棄本次更新,通常會重新讀取資料並再次嘗試。

以Java中的AtomicInteger為例,它利用CAS機制實現了樂觀鎖的特性:

public class OptimisticLockExample {
    private final AtomicInteger counter = new AtomicInteger(10);

    public void incrementCounter() {
        while (true) { // 自旋
            int currentValue = counter.get();
            int newValue = currentValue + 1;
            if (counter.compareAndSet(currentValue, newValue)) { // 使用CAS原子操作
                break; // 更新成功,退出迴圈
            }
        }
    }
}

// AtomicInteger 的 compareAndSet 方法原始碼簡化示意
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

上述程式碼展示瞭如何在一個迴圈內連續嘗試原子地增加計數器值。只有噹噹前值等於預期值時,CAS操作才會成功,否則執行緒將不斷重試直至成功更新。由於樂觀鎖在沒有衝突的情況下不涉及執行緒掛起,故適用於“讀多寫少”的場景,能有效降低加鎖開銷,提高系統吞吐量。然而,若併發更新頻率較高,可能會導致大量的CAS失敗和重試,從而帶來額外的CPU消耗。

CAS原理


在併發程式設計中,CAS(Compare and Swap,比較並交換)是一種無鎖演算法,它在不阻塞其他執行緒的情況下實現原子性的變數更新操作。在Java中,CAS的實現基於Unsafe類提供的native方法,這些方法直接與底層硬體互動,利用CPU級別的原子指令來保證資料更新的安全性。

CAS流程

在CAS操作中涉及三個關鍵值:V(要更新的變數),E(預期值),N(新值)。當需要對一個共享變數進行修改時,執行緒首先檢查該變數當前值是否等於預期值E。如果相等,則將變數值更新為新值N;如果不等,則說明已經有其他執行緒更新了該變數,此時當前執行緒放棄更新操作,保持原值不變。

以AtomicInteger為例,我們可以透過以下程式碼片段理解CAS的工作過程:

import java.util.concurrent.atomic.AtomicInteger;

public class CASTest {
    private AtomicInteger counter = new AtomicInteger(5);

    public void increment() {
        int expectedValue = counter.get();
        while (!counter.compareAndSet(expectedValue, expectedValue + 1)) {
            // 當前執行緒獲取到的值已經被其他執行緒改變,重新獲取最新值
            expectedValue = counter.get();
        }
    }
}

在這個例子中,compareAndSet方法會不斷嘗試將計數器從舊值遞增1,直到成功為止。當多個執行緒同時嘗試增加計數器時,只有一個執行緒能夠透過CAS成功更新,其餘執行緒將繼續迴圈直至其看到的預期值和實際值匹配後再嘗試更新。

原子性和作業系統

CAS的核心優勢在於其原子性——即整個比較和交換的操作作為一個不可分割的整體執行。在現代多核CPU架構下,諸如cmpxchg指令這樣的原子指令能夠確保在沒有外部干預的情況下完成這一系列步驟。在Linux X86系統中,cmpxchgl指令配合lock字首可以確保在同一時刻僅有一個處理器能成功更新記憶體位置,從而避免了併發問題。

ABA問題

儘管CAS機制在大多數情況下表現優異,但存在一種特殊情況——ABA問題。假設一個變數初始值為A,被更改為B後又改回A,這種情況下使用單純的CAS檢查將會誤判為未發生過變化。為了應對ABA問題,JDK提供了一個名為AtomicStampedReference的類,它在每個物件引用上附加了一個版本號或時間戳,使得每次更改不僅檢查引用本身,還檢查版本號,只有兩者都匹配時才會進行替換。

import java.util.concurrent.atomic.AtomicStampedReference;

public class ABATest {
    private AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);

    public void update(int newValue, int newStamp) {
        while (true) {
            int currentStamp = ref.getStamp();
            if (ref.compareAndSet(1, newValue, currentStamp, newStamp)) {
                break; // 更新成功
            } else {
                // 失敗則重試,獲取最新的stamp
            }
        }
    }
}

在上述程式碼中,compareAndSet方法不僅要比較引用物件的值,還要比較並更新相關聯的版本資訊,因此有效防止了ABA問題的發生。

綜上所述,CAS作為一種高效的無鎖同步機制,在Java多執行緒程式設計中扮演著重要角色,透過直接呼叫CPU指令實現了併發環境下的原子操作,但也需要注意潛在的ABA問題以及長時間自旋帶來的效能開銷等問題,並選擇合適的解決方案。

Unsafe類


在Java中,為了能夠直接與底層硬體進行互動並執行原子操作,如CAS,Java使用了一個名為sun.misc.Unsafe的類。由於該類提供了一些不受JVM訪問控制約束的方法,並允許開發者直接操作記憶體和執行非安全但高效的原語操作,因此被稱為“Unsafe”。儘管這個類不在公共API中,但在併發包java.util.concurrent.atomic中的原子類,如AtomicInteger等,都依賴於Unsafe類提供的CAS操作來保證執行緒間的原子性和可見性。

Unsafe類與CAS方法
Unsafe類包含了一系列native方法,這些方法用於執行原子性的CAS操作,例如:

public native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

這些方法分別用於比較並交換物件引用、整型值以及長整型值。引數含義如下:

  • o:一個物件例項,CAS操作將作用在其內部的一個欄位上。
  • offset:指定欄位相對於物件起始地址的偏移量,由objectFieldOffset()方法計算得出。
  • expected:期望的舊值,只有當欄位當前值等於此預期值時,才會進行更新。
  • x:新值,如果條件滿足,則用新值替換舊值。

以AtomicInteger為例,其getAndAddInt方法就利用了Unsafe類的compareAndSwapInt方法實現原子遞增:

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset); // 獲取當前值
    } while (!weakCompareAndSetInt(o, offset, v, v + delta)); // 使用CAS嘗試更新
    return v; // 返回更新前的值
}

這裡首先獲取到共享變數的當前值v,然後在一個迴圈中不斷嘗試透過CAS指令將變數從v更新為v+delta,直到成功為止。

CPU級別的原子操作
值得注意的是,CAS操作在Java中的實現實際上呼叫了作業系統和CPU提供的原子指令。在Linux X86系統下,是透過cmpxchgl這樣的CPU指令實現的,而在多處理器環境中,為了確保跨多個CPU核心的原子性,還需要配合lock字首指令鎖定匯流排或快取行,防止其他處理器同時修改同一資料。

弱版本CAS與強版本CAS的區別
從JDK 9開始,Unsafe類提供了兩個看似相似但實際上可能有不同實現策略的方法:compareAndSetIntweakCompareAndSetInt。雖然在早期版本中它們的行為一致,但在某些情況下,weakCompareAndSet系列方法可能只保留了volatile變數本身的特性,而放棄了happens-before規則帶來的記憶體語義保障。這意味著weakCompareAndSet無法確保除了目標volatile變數以外的其他變數的操作順序和可見性,從而有可能帶來更高的效能,但也可能需要開發人員更小心地處理併發邏輯。

總之,Java透過Unsafe類實現了對CAS原子操作的支援,使得程式設計師可以在高階語言層面上利用底層硬體的原子指令,構建出高效且無鎖化的併發程式。然而,這也要求開發者具備對併發程式設計機制深刻的理解,以便正確解決潛在的問題,比如ABA問題,以及合理應對CAS自旋可能導致的效能開銷。

AtomicInteger原始碼簡析


Java併發包中的java.util.concurrent.atomic.AtomicInteger類是一個基於CAS實現的執行緒安全整數容器,它提供了一系列原子操作方法,如get、set、incrementAndGet等。以getAndAdd(int delta)方法為例,該方法用於獲取當前值並原子性地將值增加指定的delta。

Java 17下的Atomic類:

首先,我們觀察到getAndAdd(int delta)方法呼叫了Unsafe類的getAndAddInt()方法:

public final int getAndAdd(int delta) {
    return U.getAndAddInt(this, VALUE, delta);
}

這裡的UUnsafe類的一個例項,其內部欄位VALUE儲存了AtomicInteger類中value變數相對於物件起始地址的偏移量。objectFieldOffset()方法用於計算這個偏移量:

private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");

然後,深入到Unsafe類的getAndAddInt()方法實現:

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset); // 獲取volatile型別的舊值
    } while (!weakCompareAndSetInt(o, offset, v, v + delta)); // 使用CAS更新新值
    return v; // 返回更新前的值
}

這段程式碼展示了典型的CAS迴圈模式。首先透過getIntVolatile()讀取記憶體中AtomicInteger例項的volatile變數value的當前值,並儲存在區域性變數v中。接下來進入一個do-while迴圈,在迴圈體內嘗試使用weakCompareAndSetInt()執行CAS操作。只有當value的當前值等於我們剛讀取到的v時,才會將value設定為v+delta。如果此時value已經被其他執行緒更改,則CAS失敗,程式會再次讀取新的value值,並重新進行CAS嘗試,直到成功為止。

值得注意的是,這裡雖然使用了weakCompareAndSetInt()方法,但在JDK 8及之前版本中,compareAndSetInt()weakCompareAndSetInt()的功能實際上是相同的。而在JDK 9及以上版本中,weakCompareAndSetInt()可能具有更弱的記憶體語義保證,即不強制滿足happens-before規則,這有助於提升效能但要求開發者對併發程式設計有更深的理解。

透過這種方式,AtomicInteger藉助Unsafe提供的底層支援實現了無鎖的原子操作,不僅避免了傳統鎖機制帶來的上下文切換開銷,還確保了在多執行緒環境下的資料一致性。同時,透過對原始碼的分析,我們可以更加深入地理解Java如何利用CAS機制來解決併發問題。

常見問題與解決方案


迴圈自旋開銷問題及其解決方案

使用CAS通常伴隨著迴圈重試機制,即當CAS失敗時,執行緒會不斷嘗試再次執行CAS操作直至成功。然而,在高競爭條件下,這可能導致執行緒長時間處於“自旋”狀態,佔用大量CPU資源且無實質性工作進展。

為了解決這一問題,JVM支援處理器提供的pause指令,比如在HotSpot虛擬機器中,可以插入適當的pause指令來降低自旋等待過程中的CPU消耗。pause指令可以使CPU暫時放棄當前執行緒的執行,並讓其他執行緒有機會執行,從而減少空轉帶來的效能損失。此外,現代JVM還透過自適應自旋策略調整自旋次數,以達到更好的效能效果。

單變數原子操作侷限及其擴充套件方案

雖然CAS能很好地保證單個共享變數的原子性,但在涉及多個變數的操作場景下,單純的CAS將顯得力不從心。為了應對這種情況,有以下兩種解決方案:

  1. 使用AtomicReference類封裝物件
    當需要對包含多個變數的物件進行原子性更新時,可以利用java.util.concurrent.atomic.AtomicReference類。將多個變數封裝到一個物件中,然後對整個物件進行CAS操作,如:

    class Data {
        int a;
        int b;
    }
    AtomicReference<Data> atomicData = new AtomicReference<>(new Data(1, 2));
    // 更新a和b欄位的原子操作
    Data newData = new Data(3, 4);
    atomicData.compareAndSet(currentData, newData);
    
  2. 使用鎖保護臨界區
    在一些複雜的多變數操作場景下,CAS可能無法直接滿足需求,此時可以選擇傳統的鎖機制,如synchronized關鍵字或ReentrantLock類來保護臨界區程式碼,確保在給定時間內只有一個執行緒能夠訪問並更新這些變數,從而實現多變數操作的原子性。

綜上所述,雖然CAS帶來了高效的無鎖併發控制機制,但也存在諸如ABA問題、迴圈自旋開銷過大以及只能處理單個變數等問題。針對這些問題,Java平臺提供了相應的解決方案,如AtomicStampedReference類、pause指令最佳化以及AtomicReference等工具,幫助開發者在複雜多樣的併發場景下更靈活地運用CAS技術。

總結


在Java多執行緒程式設計中,CAS(Compare and Swap)機制扮演著至關重要的角色。作為樂觀鎖的一種實現方式,它透過比較並交換記憶體位置的值來保證原子操作,避免了傳統悲觀鎖帶來的併發效能瓶頸和上下文切換開銷。在JDK的java.util.concurrent.atomic包中,諸如AtomicInteger、AtomicStampedReference等原子類庫就是基於Unsafe類提供的CAS原語構建的。

以AtomicInteger為例,其getAndAdd方法利用CAS迴圈實現了無鎖的原子遞增操作,確保在高併發場景下變數更新的正確性和高效性。然而,CAS並非完美無缺,其中的ABA問題需要透過引入版本號或時間戳的方式來解決,如AtomicStampedReference透過比較引用與版本戳防止了兩次相同值之間的中間狀態被忽視。

針對迴圈自旋導致的CPU資源浪費問題,現代JVM如HotSpot支援處理器pause指令,能夠在自旋失敗時降低CPU活動頻率,減少不必要的消耗。同時,為了克服單個共享變數原子操作的侷限性,Java提供了AtomicReference類,可以封裝多個變數作為一個整體進行CAS操作,或者在必要時採用鎖機制,如synchronized關鍵字或ReentrantLock,確保多變數間的原子性。

綜上所述,CAS為Java開發者提供了一種強大的無鎖併發工具,但其使用需結合具體應用場景和可能遇到的問題靈活選擇解決方案。只有充分理解併合理應用CAS及其相關技術,才能在實際開發中編寫出高效能且執行緒安全的多執行緒程式碼。儘管文件中未給出具體的程式碼例項,但上述分析和解釋已經清晰描繪瞭如何在Java中運用CAS實現原子操作以及應對相關挑戰的過程。

相關文章