併發編程之 CAS 的原理

CAS 的原理

前言

在併發編程中,鎖是消耗性能的操做,同一時間只能有一個線程進入同步塊修改變量的值,好比下面的代碼java

synchronized void function(int b){
  a = a + b;
}

若是不加 synchronized 的話,多線程修改 a 的值就會致使結果不正確,出現線程安全問題。但鎖又是要給耗費性能的操做。不管是拿鎖,解鎖,仍是等待鎖,阻塞,都是很是耗費性能的。那麼能不能不加鎖呢?算法

能夠。編程

什麼意思呢?咱們看上面的代碼,分爲幾個步驟:緩存

  1. 讀取a
  2. 將 a 和 b 相加
  3. 將計算的值賦值給a。

咱們知道,這不是一個原子的操做,多線程上面時候會出問題:當兩個線程同時訪問 a ,都獲得了a 的值,而且通知對a 加 1,而後同時將計算的值賦值給a,這樣就會致使 a 的值只增長了1,但實際上咱們想加 2.安全

問題出在哪裏?第三步,對 a 賦值操做,若是有一種判斷,判斷 a 已經別的線程修改,你須要從新計算。好比下面這樣:多線程

void function(int b) {
   int backup = a;
   int c = a + b;
   compareAndSwap(a, backup, c);
}

void compareAndSwap(int backup ,int c ){
       if (a == backup) {
           a = c;
       }
}

從代碼中,咱們看到,咱們備份了 a 的值,而且對 a 進行計算,若是 a 的值和備份的值一致,說明 a 沒有被別的線程更改過,這個時候就能夠進行修改了。架構

這裏有個問題:compareAndSwap 方法有多步操做,不是原子的,而且沒有使用鎖,如何保證線程安全。其實樓主這裏只是僞代碼。下面就要好好說說什麼是 CAS (compareAndSwap);併發

1. 什麼是 CAS

CAS (compareAndSwap),中文叫比較交換,一種無鎖原子算法。過程是這樣:它包含 3 個參數 CAS(V,E,N),V表示要更新變量的值,E表示預期值,N表示新值。僅當 V值等於E值時,纔會將V的值設爲N,若是V值和E值不一樣,則說明已經有其餘線程作兩個更新,則當前線程則什麼都不作。最後,CAS 返回當前V的真實值。CAS 操做時抱着樂觀的態度進行的,它老是認爲本身能夠成功完成操做。性能

當多個線程同時使用CAS 操做一個變量時,只有一個會勝出,併成功更新,其他均會失敗。失敗的線程不會掛起,僅是被告知失敗,而且容許再次嘗試,固然也容許實現的線程放棄操做。基於這樣的原理,CAS 操做即便沒有鎖,也能夠發現其餘線程對當前線程的干擾。測試

與鎖相比,使用CAS會使程序看起來更加複雜一些,但因爲其非阻塞的,它對死鎖問題天生免疫,而且,線程間的相互影響也很是小。更爲重要的是,使用無鎖的方式徹底沒有鎖競爭帶來的系統開銷,也沒有線程間頻繁調度帶來的開銷,所以,他要比基於鎖的方式擁有更優越的性能。

簡單的說,CAS 須要你額外給出一個指望值,也就是你認爲這個變量如今應該是什麼樣子的。若是變量不是你想象的那樣,哪說明它已經被別人修改過了。你就須要從新讀取,再次嘗試修改就行了。

那麼這個CAS 是如何實現的呢?也就是說,比較和交換其實是兩個操做,如何變成一個原子操做呢?

2. CAS 底層原理

這樣歸功於硬件指令集的發展,實際上,咱們可使用同步將這兩個操做變成原子的,可是這麼作就沒有意義了。因此咱們只能靠硬件來完成,硬件保證一個從語義上看起來須要屢次操做的行爲只經過一條處理器指令就能完成。這類指令經常使用的有:

  1. 測試並設置(Tetst-and-Set)
  2. 獲取並增長(Fetch-and-Increment)
  3. 交換(Swap)
  4. 比較並交換(Compare-and-Swap)
  5. 加載連接/條件存儲(Load-Linked/Store-Conditional)

其中,前面的3條是20世紀時,大部分處理器已經有了,後面的2條是現代處理器新增的。並且這兩條指令的目的和功能是相似的,在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令實現,而在 ARM 和 PowerPC 架構下,則須要使用一對 ldrex/strex 指令來完成 LL/SC 的功能。

CPU 實現原子指令有2種方式:

  1. 經過總線鎖定來保證原子性。
    總線鎖定其實就是處理器使用了總線鎖,所謂總線鎖就是使用處理器提供的一個 LOCK# 信號,當一個處理器咋總線上輸出此信號時,其餘處理器的請求將被阻塞住,那麼該處理器能夠獨佔共享內存。可是該方法成本太大。所以有了下面的方式。

  2. 經過緩存鎖定來保證原子性。
    所謂 緩存鎖定 是指內存區域若是被緩存在處理器的緩存行中,而且在Lock 操做期間被鎖定,那麼當他執行鎖操做寫回到內存時,處理器不在總線上聲言 LOCK# 信號,而時修改內部的內存地址,並容許他的緩存一致性機制來保證操做的原子性,由於緩存一致性機制會阻止同時修改兩個以上處理器緩存的內存區域數據(這裏和 volatile 的可見性原理相同),當其餘處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

注意:有兩種狀況下處理器不會使用緩存鎖定。

  1. 當操做的數據不能被緩存在處理器內部,或操做的數據跨多個緩存行時,則處理器會調用總線鎖定
  2. 有些處理器不支持緩存鎖定,對於 Intel 486 和 Pentium 處理器,就是鎖定的內存區域在處理器的緩存行也會調用總線鎖定。

3. Java 如何實現原子操做

java 在 1.5 版本中提供了 java.util.concurrent.atomic 包,該包下全部的類都是原子操做:

java.util.concurrent.atomic 包

如何使用呢?看代碼

public static void main(String[] args) throws InterruptedException {
    AtomicInteger integer = new AtomicInteger();
    System.out.println(integer.get());


    Thread[] threads = new Thread[1000];

    for (int j = 0; j < 1000; j++) {
      threads[j] = new Thread(() ->
          integer.incrementAndGet()
      );
      threads[j].start();
    }

    for (int j = 0; j < 1000; j++) {
      threads[j].join();
    }

    System.out.println(integer.get());
  }
}

上面的代碼,咱們啓動了1000個線程對 AtomicInteger 變量作了自增操做。結果是咱們預期的1000,表示沒有發生同步問題。

咱們看看他的內部實現,咱們找到該類的 compareAndSet 方法,也就是比較而且設置。咱們看看該方法實現:

該方法調用了 unsafe 類的 compareAndSwapInt 方法,有幾個參數,一個是該變量的內存地址,一個是指望值,一個是更新值,一個是對象自身。徹底符合咱們以前CAS 的定義。那麼 ,這個 unsafe 又是什麼呢?

該類在 rt.jar 包中,但不在咱們熟悉的 java 包下,而是 sun.misc 包下。而且都是 class 文件,註釋都沒有,符合他的名字:不安全。

咱們能構造他嗎?不能,除非反射。

咱們看看他的源碼:

getUnsafe 方法中,會檢查調用 getUnsafe 方法的類,若是這個類的 ClassLoader 不爲null ,就直接拋出異常,什麼狀況下會爲null呢?當類加載器是 Bootstrap 加載器的時候,Bootstrap 加載器是沒有對象的,也就是說,加載這個類極有多是 rt.jar 下的。

而在最新的 Java 9 當中,該類已經被隱藏。由於該類使用了指針。但指針的缺點就是不安全。

4. CAS 的缺點

CAS 看起來很是的吊,可是,他仍然有缺點,最著名的就是 ABA 問題,假設一個變量 A ,修改成 B以後又修改成 A,CAS 的機制是沒法察覺的,但實際上已經被修改過了。若是在基本類型上是沒有問題的,可是若是是引用類型呢?這個對象中有多個變量,我怎麼知道有沒有被改過?聰明的你必定想到了,加個版本號啊。每次修改就檢查版本號,若是版本號變了,說明改過,就算你仍是 A,也不行。

在 java.util.concurrent.atomic 包中,就有 AtomicReference 來保證引用的原子性,但樓主以爲有點雞肋,不如使用同步加互斥,可能會更加高效。

總結

今天咱們從各類角度理解了CAS 的原理,該算法特別的重要,從CPU 都特別的設計一條指令來實現可見一斑。而JDK的源碼中,處處都 unSafe 的 CAS 算法,能夠說,若是沒有CAS ,就沒有 1.5 的併發容器。好,今天就到這裏。

good luck !!!

相關文章
相關標籤/搜索