[Java併發-12] 原子類:無鎖工具類的典範

前面咱們屢次提到一個累加器的例子,示例代碼以下。在這個例子中,add10K() 這個方法不是線程安全的,問題就出在變量 count 的可見性和 count+=1 的原子性上。可見性問題能夠用 volatile 來解決,而原子性問題咱們前面一直都是採用的互斥鎖方案。數組

public class Test {
  long count = 0;
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
}

其實對於簡單的原子性問題,還有一種無鎖方案。Java SDK 併發包將這種無鎖方案封裝提煉以後,實現了一系列的原子類。安全

在下面的代碼中,咱們將原來的 long 型變量 count 替換爲了原子類 AtomicLong,原來的count +=1 替換成了 count.getAndIncrement(),僅須要這兩處簡單的改動就能使 add10K() 方法變成線程安全的,原子類的使用仍是挺簡單的。併發

public class Test {
  AtomicLong count = 
    new AtomicLong(0);
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count.getAndIncrement();
    }
  }
}

無鎖方案相對互斥鎖方案,最大的好處就是性能。互斥鎖方案爲了保證互斥性,須要執行加鎖、解鎖操做,而加鎖、解鎖操做自己就消耗性能;同時拿不到鎖的線程還會進入阻塞狀態,進而觸發線程切換,線程切換對性能的消耗也很大。 相比之下,無鎖方案則徹底沒有加鎖、解鎖的性能消耗,同時還能保證互斥性,既解決了問題,又沒有帶來新的問題,可謂絕佳方案。那它是如何作到的呢?函數

無鎖方案的實現原理

其實原子類性能高的祕密很簡單,硬件支持而已。CPU 爲了解決併發問題,提供了 CAS 指令(CAS,全稱是 Compare And Swap,即「比較並交換」)。CAS 指令包含 3 個參數:共享變量的內存地址 A、用於比較的值 B 和共享變量的新值 C;而且只有當內存中地址 A 處的值等於 B 時,才能將內存中地址 A 處的值更新爲新值 C。做爲一條 CPU 指令,CAS 指令自己是可以保證原子性的性能

你能夠經過下面 CAS 指令的模擬代碼來理解 CAS 的工做原理。在下面的模擬程序中有兩個參數,一個是指望值 expect,另外一個是須要寫入的新值 newValue, 只有當目前 count 的值和指望值 expect 相等時,纔會將 count 更新爲 newValuethis

class SimulatedCAS{
  int count;
  synchronized int cas(
    int expect, int newValue){
    // 讀目前 count 的值
    int curValue = count;
    // 比較目前 count 值是否 == 指望值
    if(curValue == expect){
      // 若是是,則更新 count 的值
      count = newValue;
    }
    // 返回寫入前的值
    return curValue;
  }
}

count += 1的一個核心問題是:基於內存中 count 的當前值 A 計算出來的 count+=1 爲 A+1,在將 A+1 寫入內存的時候,極可能此時內存中 count 已經被其餘線程更新過了,這樣就會致使錯誤地覆蓋其餘線程寫入的值。spa

使用 CAS 來解決併發問題,通常都會伴隨着自旋,而所謂自旋,其實就是循環嘗試。例如,實現一個線程安全的count += 1操做,CAS+ 自旋的實現方案以下所示,首先計算 newValue = count+1,若是 cas(count,newValue) 返回的值不等於 count,則意味着線程在執行完代碼①處以後,執行代碼②處以前,count 的值被其餘線程更新過。那此時該怎麼處理呢?能夠採用自旋方案,就像下面代碼中展現的,能夠從新讀 count 最新的值來計算 newValue 並嘗試再次更新,直到成功。線程

class SimulatedCAS{
  volatile int count;
  // 實現 count+=1
  addOne(){
    do {
      newValue = count+1; //①
    }while(count !=
      cas(count,newValue) //②
  }
  // 模擬實現 CAS,僅用來幫助理解
  synchronized int cas(
    int expect, int newValue){
    // 讀目前 count 的值
    int curValue = count;
    // 比較目前 count 值是否 == 指望值
    if(curValue == expect){
      // 若是是,則更新 count 的值
      count= newValue;
    }
    // 返回寫入前的值
    return curValue;
  }
}

經過上面的示例代碼,想必你已經發現了,CAS 這種無鎖方案,徹底沒有加鎖、解鎖操做,即使兩個線程徹底同時執行 addOne() 方法,也不會有線程被阻塞,因此相對於互斥鎖方案來講,性能好了不少。code

可是在 CAS 方案中,有一個問題可能會常被你忽略,那就是ABA問題。對象

前面咱們提到「若是 cas(count,newValue) 返回的值不等於count,意味着線程在執行完代碼①處以後,執行代碼②處以前,count 的值被其餘線程更新過。那若是 cas(count,newValue) 返回的值等於count,是否就可以認爲 count 的值沒有被其餘線程更新過呢?

顯然不是的,假設 count 本來是 A,線程 T1 在執行完代碼①處以後,執行代碼②處以前,有可能 count 被線程 T2 更新成了 B,以後又被 T3 更新回了 A,這樣線程 T1 雖然看到的一直是 A,可是其實已經被其餘線程更新過了,這就是 ABA 問題。

可能大多數狀況下咱們並不關心 ABA 問題,例如數值的原子遞增,但也不能全部狀況下都不關心,例如原子化的更新對象極可能就須要關心 ABA 問題,由於兩個 A 雖然相等,可是第二個 A 的屬性可能已經發生變化了。因此在使用 CAS 方案的時候,必定要先 check 一下。

Java 如何實現原子化的 count += 1

在本文開始部分,咱們使用原子類 AtomicLong 的 getAndIncrement() 方法替代了count+1

1,從而實現了線程安全。原子類 AtomicLong 的 getAndIncrement() 方法內部就是基於 CAS 實現的,下面咱們來看看 Java 是如何使用 CAS 來實現原子化的

在 Java 1.8 版本中,getAndIncrement() 方法會轉調 unsafe.getAndAddLong() 方法。這裏 this 和 valueOffset 兩個參數能夠惟一肯定共享變量的內存地址。

final long getAndIncrement() {
  return unsafe.getAndAddLong(
    this, valueOffset, 1L);
}

unsafe.getAndAddLong() 方法的源碼以下,該方法首先會在內存中讀取共享變量的值,以後循環調用 compareAndSwapLong() 方法來嘗試設置共享變量的值,直到成功爲止。compareAndSwapLong() 是一個 native 方法,只有當內存中共享變量的值等於 expected 時,纔會將共享變量的值更新爲 x,而且返回 true;不然返回 fasle。compareAndSwapLong 的語義和 CAS 指令的語義的差異僅僅是返回值不一樣而已。

public final long getAndAddLong(
  Object o, long offset, long delta){
  long v;
  do {
    // 讀取內存中的值
    v = getLongVolatile(o, offset);
  } while (!compareAndSwapLong(
      o, offset, v, v + delta));
  return v;
}
// 原子性地將變量更新爲 x
// 條件是內存中的值等於 expected
// 更新成功則返回 true
native boolean compareAndSwapLong(
  Object o, long offset, 
  long expected,
  long x);

另外,須要你注意的是,getAndAddLong() 方法的實現,基本上就是 CAS 使用的經典範例。因此請你再次體會下面這段抽象後的代碼片斷,它在不少無鎖程序中常常出現。Java 提供的原子類裏面 CAS 通常被實現爲 compareAndSet(),compareAndSet() 的語義和 CAS 指令的語義的差異僅僅是返回值不一樣而已,compareAndSet() 裏面若是更新成功,則會返回 true,不然返回 false。

do {
  // 獲取當前值
  oldV = xxxx;
  // 根據當前值計算新值
  newV = ...oldV...
}while(!compareAndSet(oldV,newV);

原子類概覽

Java SDK 併發包裏提供的原子類內容很豐富,咱們能夠將它們分爲五個類別:

  1. 原子化的基本數據類型
  2. 原子化的對象引用類型
  3. 原子化數組
  4. 原子化對象屬性更新器
  5. 原子化的累加器

。這五個類別提供的方法基本上是類似的,而且每一個類別都有若干原子類,你能夠經過下面的原子類組成概覽圖來得到一個全局的印象。下面咱們詳細解讀這五個類別。

圖片描述

1. 原子化的基本數據類型

相關實現有 AtomicBoolean、AtomicInteger 和 AtomicLong,提供的方法主要有如下這些,詳情你能夠參考 SDK 的源代碼,都很簡單,這裏就不詳細介紹了。

getAndIncrement() // 原子化 i++
getAndDecrement() // 原子化的 i--
incrementAndGet() // 原子化的 ++i
decrementAndGet() // 原子化的 --i
// 當前值 +=delta,返回 += 前的值
getAndAdd(delta) 
// 當前值 +=delta,返回 += 後的值
addAndGet(delta)
//CAS 操做,返回是否成功
compareAndSet(expect, update)
// 如下四個方法
// 新值能夠經過傳入 func 函數來計算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)

2. 原子化的對象引用類型

相關實現有 AtomicReference、AtomicStampedReference 和 AtomicMarkableReference,利用它們能夠實現對象引用的原子化更新。AtomicReference 提供的方法和原子化的基本數據類型差很少,這裏再也不贅述。不過須要注意的是,對象引用的更新須要重點關注 ABA 問題,AtomicStampedReference 和 AtomicMarkableReference 這兩個原子類能夠解決 ABA 問題。

解決 ABA 問題的思路其實很簡單,增長一個版本號維度就能夠了。每次執行 CAS 操做,附加再更新一個版本號,只要保證版本號是遞增的,那麼即使 A 變成 B 以後再變回 A,版本號也不會變回來(版本號遞增的)。AtomicStampedReference 實現的 CAS 方法就增長了版本號參數,方法簽名以下:

boolean compareAndSet(
  V expectedReference,
  V newReference,
  int expectedStamp,
  int newStamp)

AtomicMarkableReference 的實現機制則更簡單,將版本號簡化成了一個 Boolean 值,方法簽名以下:

boolean compareAndSet(
  V expectedReference,
  V newReference,
  boolean expectedMark,
  boolean newMark)

3. 原子化數組

相關實現有 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,利用這些原子類,咱們能夠原子化地更新數組裏面的每個元素。這些類提供的方法和原子化的基本數據類型的區別僅僅是:每一個方法多了一個數組的索引參數,因此這裏也再也不贅述了。

4. 原子化對象屬性更新器

相關實現有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater,利用它們能夠原子化地更新對象的屬性,這三個方法都是利用反射機制實現的,建立更新器的方法以下:

public static <U>
AtomicXXXFieldUpdater<U> 
newUpdater(Class<U> tclass, 
  String fieldName)

須要注意的是,對象屬性必須是 volatile 類型的,只有這樣才能保證可見性;若是對象屬性不是 volatile 類型的,newUpdater() 方法會拋出 IllegalArgumentException 這個運行時異常。

你會發現 newUpdater() 的方法參數只有類的信息,沒有對象的引用,而更新對象的屬性,必定須要對象的引用,那這個參數是在哪裏傳入的呢?是在原子操做的方法參數中傳入的。例如 compareAndSet() 這個原子操做,相比原子化的基本數據類型多了一個對象引用 obj。原子化對象屬性更新器相關的方法,相比原子化的基本數據類型僅僅是多了對象引用參數,因此這裏也再也不贅述了。

boolean compareAndSet(
  T obj, 
  int expect, 
  int update)

5. 原子化的累加器

DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder,這四個類僅僅用來執行累加操做,相比原子化的基本數據類型,速度更快,可是不支持 compareAndSet() 方法。若是你僅僅須要累加操做,使用原子化的累加器性能會更好。

總結

無鎖方案相對於互斥鎖方案,優勢很是多,首先性能好,其次是基本不會出現死鎖問題(但可能出現飢餓和活鎖問題,由於自旋會反覆重試)。Java 提供的原子類大部分都實現了 compareAndSet() 方法。

Java 提供的原子類可以解決一些簡單的原子性問題,但你可能會發現,上面咱們全部原子類的方法都是針對一個共享變量的,若是你須要解決多個變量的原子性問題,建議仍是使用互斥鎖方案。原子類雖好,但使用要慎之又慎。

相關文章
相關標籤/搜索