併發的核心:CAS 是什麼?Java8是如何優化 CAS 的?

帥地發表於2019-05-10

大家可能都聽說說 Java 中的併發包,如果想要讀懂 Java 中的併發包,其核心就是要先讀懂 CAS 機制,因為 CAS 可以說是併發包的底層實現原理。

今天就帶大家讀懂 CAS 是如何保證操作的原子性的,以及 Java8 對 CAS 進行了哪些優化。

synchronized:大材小用

我們先來看幾行程式碼:

public class CASTest {
    static int i = 0;

    public static void increment() {
        i++;
    }
}
複製程式碼

假如有100個執行緒同時呼叫 increment() 方法對 i 進行自增操作,i 的結果會是 100 嗎?

學會多執行緒的同學應該都知道,這個方法是執行緒不安全的,由於 i++ 不是一個原子操作,所以是很難得到 100 的。

這裡稍微解釋下為啥會得不到 100(知道的可直接跳過), i++ 這個操作,計算機需要分成三步來執行。 1、讀取 i 的值。 2、把 i 加 1. 3、把 最終 i 的結果寫入記憶體之中。所以,假如執行緒 A 讀取了 i 的值為 i = 0,這個時候執行緒 B 也讀取了 i 的值 i = 0。接著 A把 i 加 1,然後寫入記憶體,此時 i = 1。緊接著,B也把 i 加 1,此時執行緒B中的 i = 1,然後執行緒 B 把 i 寫入記憶體,此時記憶體中的 i = 1。也就是說,執行緒 A, B 都對 i 進行了自增,但最終的結果卻是 1,不是 2.

那該怎麼辦呢?解決的策略一般都是給這個方法加個鎖,如下

public class CASTest {
    static int i = 0;

    public synchronized static void increment() {
        i++;
    }
}
複製程式碼

加了 synchronized 之後,就最多隻能有一個執行緒能夠進入這個 increment() 方法了。這樣,就不會出現執行緒不安全了。不懂 synchronized 的可以看我這篇文章:徹底搞懂synchronized(從偏向鎖到重量級鎖)

然而,一個簡簡單單的自增操作,就加了 synchronized 進行同步,好像有點大材小用的感覺,加了 synchronized 關鍵詞之後,當有很多執行緒去競爭 increment 這個方法的時候,拿不到鎖的方法是會被阻塞在方法外面的,最後再來喚醒他們,而阻塞/喚醒這些操作,是非常消耗時間的。

這裡可能有人會說,synchronized 到了JDK1.6之後不是做了很多優化嗎?是的,確實做了很多優化,增加了偏向鎖、輕量級鎖等, 但是,就算增加了這些,當很多執行緒來競爭的時候,開銷依然很多,不信你看我另外一篇文章的介紹:徹底搞懂synchronized(從偏向鎖到重量級鎖)

CAS :這種小事交給我

那有沒有其他方法來代替 synchronized 對方法的加鎖,並且保證 increment() 方法是執行緒安全呢?

大家看一下,如果我採用下面這種方式,能否保證 increment 是執行緒安全的呢?步驟如下:

1、執行緒從記憶體中讀取 i 的值,假如此時 i 的值為 0,我們把這個值稱為 k 吧,即此時 k = 0。

2、令 j = k + 1。

3、用 k 的值與記憶體中i的值相比,如果相等,這意味著沒有其他執行緒修改過 i 的值,我們就把 j(此時為1) 的值寫入記憶體;如果不相等(意味著i的值被其他執行緒修改過),我們就不把j的值寫入記憶體,而是重新跳回步驟 1,繼續這三個操作。

翻譯成程式碼的話就是這樣:

public static void increment() {
    do{
        int k = i;
        int j = k + 1;
    }while (compareAndSet(i, k, j))
}
複製程式碼

如果你去模擬一下,就會發現,這樣寫是執行緒安全的。

這裡可能有人會說,第三步的 compareAndSet 這個操作不僅要讀取記憶體,還幹了比較、寫入記憶體等操作,,,這一步本身就是執行緒不安全的啊?

如果你能想到這個,說明你是真的有去思考、模擬這個過程,不過我想要告訴你的是,這個 compareAndSet 操作,他其實只對應作業系統的一條硬體操作指令,儘管看似有很多操作在裡面,但作業系統能夠保證他是原子執行的。

對於一條英文單詞很長的指令,我們都喜歡用它的簡稱來稱呼他,所以,我們就把 compareAndSet 稱為 CAS 吧。

所以,採用 CAS 這種機制的寫法也是執行緒安全的,通過這種方式,可以說是不存在鎖的競爭,也不存在阻塞等事情的發生,可以讓程式執行的更好。

在 Java 中,也是提供了這種 CAS 的原子類,例如:

  1. AtomicBoolean
  2. AtomicInteger
  3. AtomicLong
  4. AtomicReference

具體如何使用呢?我就以上面那個例子進行改版吧,程式碼如下:

public class CASTest {
    static AtomicInteger i = new AtomicInteger(0);

    public static void increment() {
        // 自增 1並返回之後的結果
        i.incrementAndGet();
    }
}
複製程式碼

CAS:誰偷偷更改了我的值

雖然這種 CAS 的機制能夠保證increment() 方法,但依然有一些問題,例如,當執行緒A即將要執行第三步的時候,執行緒 B 把 i 的值加1,之後又馬上把 i 的值減 1,然後,執行緒 A 執行第三步,這個時候執行緒 A 是認為並沒有人修改過 i 的值,因為 i 的值並沒有發生改變。而這,就是我們平常說的ABA問題

對於基本型別的值來說,這種把數字改變了在改回原來的值是沒有太大影響的,但如果是對於引用型別的話,就會產生很大的影響了。

來個版本控制吧

為了解決這個 ABA 的問題,我們可以引入版本控制,例如,每次有執行緒修改了引用的值,就會進行版本的更新,雖然兩個執行緒持有相同的引用,但他們的版本不同,這樣,我們就可以預防 ABA 問題了。Java 中提供了 AtomicStampedReference 這個類,就可以進行版本控制了。

Java8 對 CAS 的優化。

由於採用這種 CAS 機制是沒有對方法進行加鎖的,所以,所有的執行緒都可以進入 increment() 這個方法,假如進入這個方法的執行緒太多,就會出現一個問題:每次有執行緒要執行第三個步驟的時候,i 的值老是被修改了,所以執行緒又到回到第一步繼續重頭再來。

而這就會導致一個問題:由於執行緒太密集了,太多人想要修改 i 的值了,進而大部分人都會修改不成功,白白著在那裡迴圈消耗資源。

為了解決這個問題,Java8 引入了一個 cell[] 陣列,它的工作機制是這樣的:假如有 5 個執行緒要對 i 進行自增操作,由於 5 個執行緒的話,不是很多,起衝突的機率較小,那就讓他們按照以往正常的那樣,採用 CAS 來自增吧。

但是,如果有 100 個執行緒要對 i 進行自增操作的話,這個時候,衝突就會大大增加,系統就會把這些執行緒分配到不同的 cell 陣列元素去,假如 cell[10] 有 10 個元素吧,且元素的初始化值為 0,那麼系統就會把 100 個執行緒分成 10 組,每一組對 cell 陣列其中的一個元素做自增操作,這樣到最後,cell 陣列 10 個元素的值都為 10,系統在把這 10 個元素的值進行彙總,進而得到 100,二這,就等價於 100 個執行緒對 i 進行了 100 次自增操作。

當然,我這裡只是舉個例子來說明 Java8 對 CAS 優化的大致原理,具體的大家有興趣可以去看原始碼,或者去搜尋對應的文章哦。

總結

理解 CAS 的原理還是非常重要的,它是 AQS 的基石,而 AQS 又是併發框架的基石,接下來有時間的話,還會寫一篇 AQS 的文章。

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個忙,讓更多的人看到這篇文章:

1、點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)

2、關注我,讓我們成為長期關係

3、關注公眾號「苦逼的碼農」,裡面已有100多篇原創文章,我也分享了很多視訊、書籍的資源,以及開發工具,歡迎各位的關注,第一時間閱讀我的文章。

相關文章