面試官最喜歡問的CAS還不會?怎麼和他吹牛?!一文帶你搞懂CAS

後端開發中大家肯定遇到過實現一個線程安全的計數器這種需求,根據經驗你應該知道我們要在多線程中實現 共享變量 的原子性和可見性問題,於是鎖成爲一個不可避免的話題,今天我們討論的是與之對應的無鎖 CAS。本文會從怎麼來的、是什麼、怎麼用、原理分析、遇到的問題等不同的角度帶你真正搞懂 CAS。

爲什麼要無鎖

我們一想到在多線程下保證安全的方式頭一個要拎出來的肯定是鎖,不管從硬件、操作系統層面都或多或少在使用鎖。鎖有什麼缺點嗎?當然有了,不然 JDK 裏爲什麼出現那麼多各式各樣的鎖,就是因爲每一種鎖都有其優劣勢。

面試官最喜歡問的CAS還不會?怎麼和他吹牛?!一文帶你搞懂CAS

使用鎖就需要獲得鎖、釋放鎖,CPU 需要通過上下文切換和調度管理來進行這個操作,對於一個 獨佔鎖 而言一個線程在持有鎖後沒執行結束其他的哥們就必須在外面等着,等到前面的哥們執行完畢 CPU 大哥就會把鎖拿出來其他的線程來搶了(非公平)。鎖的這種概念基於一種悲觀機制,它總是認爲數據會被修改,所以你在操作一部分代碼塊之前先加一把鎖,操作完畢後再釋放,這樣就安全了。其實在 JDK1.5 使用 synchronized 就可以做到。

面試官最喜歡問的CAS還不會?怎麼和他吹牛?!一文帶你搞懂CAS

但是像上面的操作在多線程下會讓 CPU 不斷的切換,非常消耗資源,我們知道可以使用具體的某一類鎖來避免部分問題。那除了鎖的方式還有其他的嗎?當然,有人就提出了無鎖算法,比較有名的就是我們今天要說的 CAS(compare and swap),和鎖不同的是它是一種樂觀的機制,它認爲別人去拿數據的時候不會修改,但是在修改數據的時候去判斷一下數據此時的狀態,這樣的話 CPU 不會切換,在讀多的情況下性能將得到大幅提升。當前我們使用的大部分 CPU 都有 CAS 指令了,從硬件層面支持無鎖,這樣開發的時候去調用就可以了。

不論是鎖還是無鎖都有其優劣勢,後面我們也會通過例子說明 CAS 的問題。

什麼是 CAS

前面提了無鎖的 CAS,那到底 CAS 是個啥呢?我已經迫不及待了,我們來看看維基百科的解釋

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

CAS 給我們提供了一種思路,通過 比較 和 替換 來完成原子性,來看一段代碼:

int cas(long *addr, long old, long new) {
    /* 原子執行 */
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

這是一段 c 語言代碼,可以看到有 3 個參數,分別是:

  • *addr: 進行比較的值
  • old: 內存當前值
  • new: 準備修改的新值,寫入到內存

只要我們當前傳入的進行比較的值和內存裏的值相等,就將新值修改成功,否則返回 0 告訴比較失敗了。學過數據庫的同學都知道悲觀鎖和樂觀鎖,樂觀鎖總是認爲數據不會被修改。基於這種假設 CAS 的操作也認爲內存裏的值和當前值是相等的,所以操作總是能成功,我們可以不需要加鎖就實現多線程下的原子性操作。

在多線程情況下使用 CAS 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程並不會被阻塞掛起,而是告訴它這次修改失敗了,你可以重新嘗試,於是可以寫這樣的代碼。

while (!cas(&addr, old, newValue)) {

}
// success
printf("new value = %ld", addr);

不過這樣的代碼相信你可能看出其中的蹊蹺了,這個我們後面來分析,下面來看看 Java 裏是怎麼用 CAS 的。

Java 裏的 CAS

還是前面的問題,如果讓你用 Java 的 API 來實現你可能會想到兩種方式,一種是加鎖(可能是 synchronized 或者其他種類的鎖),另一種是使用 atomic 類,如 AtomicInteger,這一系列類是在 JDK1.5 的時候出現的,在我們常用的 java.util.concurrent.atomic 包下,我們來看個例子:

ExecutorService executorService = Executors.newCachedThreadPool();
AtomicInteger   atomicInteger   = new AtomicInteger(0);

for (int i = 0; i < 5000; i++) {
    executorService.execute(atomicInteger::incrementAndGet);
}

System.out.println(atomicInteger.get());
executorService.shutdown();

這個例子開啓了 5000 個線程去進行累加操作,不管你執行多少次答案都是 5000。這麼神奇的操作是如何實現的呢?就是依靠 CAS 這種技術來完成的,我們揭開 AtomicInteger 的老底看看它的代碼:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    /**
     * Gets the current value.
     *
     * @return the current value
     */
    public final int get() {
        return value;
    }

    /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }

}

這裏我只帖出了我們前面例子相關的代碼,其他都是類似的,可以看到 incrementAndGet 調用了 unsafe.getAndAddInt 方法。Unsafe 這個類是 JDK 提供的一個比較底層的類,它不讓我們程序員直接使用,主要是怕操作不當把機器玩壞了。。。(其實可以通過反射的方式獲取到這個類的實例)你會在 JDK 源碼的很多地方看到這傢伙,我們先說說它有什麼能力:

  • 內存管理:包括分配內存、釋放內存
  • 操作類、對象、變量:通過獲取對象和變量偏移量直接修改數據
  • 掛起與恢復:將線程阻塞或者恢復阻塞狀態
  • CAS:調用 CPU 的 CAS 指令進行比較和交換
  • 內存屏障:定義內存屏障,避免指令重排序

這裏只是大致提一下常用的操作,具體細節可以在文末的參考鏈接中查看。下面我們繼續看 unsafe 的 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;
}

public native int getIntVolatile(Object var1, long var2);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

其實很簡單,先通過 getIntVolatile 獲取到內存的當前值,然後進行比較,展開 compareAndSwapInt 方法的幾個參數:

  • var1: 當前要操作的對象(其實就是 AtomicInteger 實例)
  • var2: 當前要操作的變量偏移量(可以理解爲 CAS 中的內存當前值)
  • var4: 期望內存中的值
  • var5: 要修改的新值

所以 this.compareAndSwapInt(var1, var2, var5, var5 + var4) 的意思就是,比較一下 var2 和內存當前值 var5 是否相等,如果相等那我就將內存值 var5 修改爲 var5 + var4var4 就是 1,也可以是其他數)。


這裏我們還需要解釋一下 偏移量 是個啥?你在前面的代碼中可能看到這麼一段:

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

可以看出在靜態代碼塊執行的時候將 AtomicInteger 類的 value 這個字段的偏移量獲取出來,拿這個 long 數據幹嘛呢?在 Unsafe 類裏很多地方都需要傳入 obj 和偏移量,結合我們說 Unsafe 的諸多能力,其實就是直接通過更底層的方式將對象字段在內存的數據修改掉。

使用上面的方式就可以很好的解決多線程下的原子性和可見性問題。由於代碼裏使用了 do while 這種循環結構,所以 CPU 不會被掛起,比較失敗後重試,就不存在上下文切換了,實現了無鎖併發編程。

CAS 存在的問題

自旋的劣勢

你留意上面的代碼會發現一個問題,while 循環如果在最壞情況下總是失敗怎麼辦?會導致 CPU 在不斷處理。像這種 while(!compareAndSwapInt) 的操作我們稱之爲自旋,CAS 是樂觀的,認爲大家來並不都是修改數據的,現實可能出現非常多的線程過來都要修改這個數據,此時隨着併發量的增加會導致 CAS 操作長時間不成功,CPU 也會有很大的開銷。所以我們要清楚,如果是讀多寫少的情況也就滿足樂觀,性能是非常好的。

ABA 問題

提到 CAS 不得不說 ABA 問題,它是說假如內存的值原來是 A,被一個線程修改爲了 B,此時又有一個線程把它修改爲了 A,那麼 CAS 肯定是操作成功的。真的這樣做的話代碼可能就有 bug 了,對於修改數據爲 B 的那個線程它應該讀取到 B 而不是 A,如果你做過數據庫相關的樂觀鎖機制可能會想到我們在比較的時候使用一個版本號 version 來進行判斷就可以搞定。在 JDK 裏提供了一個 AtomicStampedReference 類來解決這個問題,來看一個例子:

int stamp = 10001;

AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(0, stamp);

stampedReference.compareAndSet(0, 10, stamp, stamp + 1);

System.out.println("value: " + stampedReference.getReference());
System.out.println("stamp: " + stampedReference.getStamp());

它的構造函數是 2 個參數,多傳入了一個初始 時間戳,用這個戳來給數據加了一個版本,這樣的話多個線程來修改如果提供的戳不同。在修改數據的時候除了提供一個新的值之外還要提供一個新的戳,這樣在多線程情況下只要數據被修改了那麼戳一定會發生改變,另一個線程拿到的是舊的戳所以會修改失敗。

嘗試應用

既然 CAS 提供了這麼好的 API,我們不妨用它來實現一個簡易版的獨佔鎖。思路是當某個線程進入 lock 方法就比較鎖對象的內存值是否是 false,如果是則代表這把鎖它可以獲取,獲取後將內存之修改爲 true,獲取不到就自旋。在 unlock 的時候將內存值再修改爲 false 即可,代碼如下:

public class SpinLock {

    private AtomicBoolean mutex = new AtomicBoolean(false);

    public void lock() {
        while (!mutex.compareAndSet(false, true)) {
            // System.out.println(Thread.currentThread().getName()+ " wait lock release");
        }
    }

    public void unlock() {
        while (!mutex.compareAndSet(true, false)) {
            // System.out.println(Thread.currentThread().getName()+ " wait lock release");
        }
    }

}

這裏使用了 AtomicBoolean 這個類,當然用 AtomicInteger 也是可以的,因爲我們只保存一個狀態 boolean 佔用比較小就用它了。這個鎖的實現比較簡單,缺點非常明顯,由於 while 循環導致的自旋會讓其他線程都在佔用 CPU,但是也可以使用,關於鎖的優化版本實現我會在後續的文章中進行改進和說明,正因爲這些問題我們也會在後續研究 AQS 這把利器的優點。

CAS 源碼

看了上面的這些代碼和解釋相信你對 CAS 已經理解了,下面我們要說的原理是前面的 native 方法中的 C++ 代碼寫了什麼,在 openjdk 的 /hotspot/src/share/vm/prims 目錄中有一個 Unsafe.cpp 文件中有這樣一段代碼:

注意:這裏以 hotspot 實現爲例

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  // 通過偏移量獲取對象變量地址
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  // 執行一個原子操作
  // 如果結果和現在不同,就直接返回,因爲有其他人修改了;否則會一直嘗試去修改。直到成功。
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章