死磕java concurrent包系列(一)從樂觀鎖、悲觀鎖到AtomicInteger的CAS演算法

lyowish發表於2018-12-01

前言

Java中有各式各樣的鎖,主流的鎖和概念如下:

死磕java concurrent包系列(一)從樂觀鎖、悲觀鎖到AtomicInteger的CAS演算法

這篇文章主要是為了讓大家通過樂觀鎖和悲觀鎖出發,理解CAS演算法,因為CAS是整個Concurrent包的基礎。

樂觀鎖和悲觀鎖

首先,java和資料庫中都有這種概念,他只是一種廣義的概念(從執行緒同步的角度上看):

悲觀鎖:悲觀的認為自己在使用資料的時候一定有別的執行緒來修改資料,因此在獲取資料的時候會先加鎖,確保資料不會被別的執行緒修改。Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖。

樂觀鎖:樂觀的認為自己在使用資料時不會有別的執行緒修改資料,所以不會新增鎖,只是在更新資料的時候去判斷之前有沒有別的執行緒更新了這個資料。如果這個資料沒有被更新,當前執行緒將自己修改的資料成功寫入。如果資料已經被其他執行緒更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)。

根據從上面的概念描述我們可以發現:

  • 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時資料正確。
  • 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的效能大幅提升。

從程式碼層面理解:

悲觀鎖:

// ------------------------- 悲觀鎖的使用方法 -------------------------
// synchronized
public synchronized void testMethod() {
    // 操作同步資源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保證多個執行緒使用的是同一個鎖
public void modifyPublicResources() {
    lock.lock();
    // 操作同步資源
    lock.unlock();
}複製程式碼

樂觀鎖:

private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保證多個執行緒使用的是同一個AtomicInteger
atomicInteger.incrementAndGet(); //執行自增1複製程式碼

悲觀鎖基本上比較好理解:就是在顯示的鎖定資源後再操作同步資源。

那麼問題來了:

樂觀鎖不鎖定資源是如何實現執行緒同步的呢?

答案是CAS

CAS

CAS全稱 Compare And Swap(比較與交換),本質上是一種無鎖演算法:就是在沒有鎖的情況下實現同步。

CAS相關的三個運算元:

  • 需要讀寫的記憶體值 V。
  • 需要進行比較的值 A。
  • 要寫入的新值 B。

當且僅當 V 的值等於 A 時,CAS通過原子方式用新值B來更新V的值(“比較+更新”整體是一個原子操作),否則不會執行任何操作。一般情況下,“更新”是一個不斷重試的操作。

先看一下基本的定義:

什麼是unsafe呢?Java沒辦法直接訪問底層作業系統,但是JVM為我們提供了一個後門,它後門就是unsafe。unsafe為我們提供了硬體級別的原子操作

對於valueOffset物件,是通過unsafe.objectFieldOffset方法得到,所代表的是AtomicInteger物件value成員變數在記憶體中的偏移量。我們可以簡單地把valueOffset理解為value變數的記憶體地址。

死磕java concurrent包系列(一)從樂觀鎖、悲觀鎖到AtomicInteger的CAS演算法

接下來看incrementAndGet:

// ------------------------- JDK 8 -------------------------
// AtomicInteger 自增方法
public final int incrementAndGet() {
  return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe.class
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;
}複製程式碼

getAndAddInt方法的入參:var1是aotmicInteger物件,var2是valueOffset,var4是1;它本質上在迴圈獲取物件aotmicInteger中的偏移量(即valueoffset)處的值var5,然後判斷記憶體的值是否等於var5;如果相等則將記憶體的值設定為var5+1;否則繼續迴圈重試,直到設定成功時再推出迴圈。

CAS操作封裝在compareAndSwapInt方法內部,在JNI裡是藉助於一個CPU指令完成的,屬於原子操作,可以保證多個執行緒都能夠看到同一個變數的修改值。後續JDK通過CPU的cmpxchg指令,去比較暫存器中的 A 和 記憶體中的值 V。如果相等,就把要寫入的新值 B 存入記憶體中。如果不相等,就將記憶體值 V 賦值給暫存器中的值 A。然後通過Java程式碼中的while迴圈再次呼叫cmpxchg指令進行重試,直到設定成功為止。

這裡native方法比較多,如果覺得不太好理解,我們可以通俗的總結一下:

迴圈體當中做了三件事: 

1.獲取當前值。 (通過volatile關鍵字保證可見性)

2.計算出目標值:本例子中為當前值+1 

3.進行CAS操作,如果成功則跳出迴圈,如果失敗則重複上述步驟。 


CAS的問題

ABA問題

什麼是ABA呢?

因為CAS中的當前值和目標值都是隨機的,假設記憶體中有一個值為A的變數,儲存在地址V當中:

記憶體地址V→A

此時有三個執行緒想使用CAS的方式更新這個變數值,每個執行緒的執行時間有略微的偏差。執行緒1和執行緒2已經獲得當前值,執行緒3還未獲得當前值。

執行緒1:獲取到了A,計算目標值,期望更新為B

執行緒2:獲取到了A,計算目標值,期望更新為B

執行緒3:還沒有獲取到當前值


接下來,執行緒1先一步執行成功,把當前值成功從A更新為B;同時執行緒2因為某種原因被阻塞住,沒有做更新操作;執行緒3線上程1更新之後,獲得了當前值B。

記憶體地址V→B

執行緒1:獲取到了A,成功更新為B

執行緒2:獲取到了A,計算目標值,期望更新為B,Block

執行緒3:獲取當前值B,計算目標值,期望更新為A


執行緒2仍然處於阻塞狀態,執行緒3繼續執行,成功把當前值從B更新成了A。

記憶體地址V→A

執行緒1:獲取到了A,成功更新為B,已返回

執行緒2:獲取到了A,計算目標值,期望更新為B,Block

執行緒3:獲取當前值B,成功更新為A


最後,執行緒2終於恢復了執行狀態,由於阻塞之前已經獲得了“當前值”A,並且經過compare檢測,記憶體地址V中的實際值也是A,所以成功把變數值A更新成了B。

記憶體地址V→B

執行緒1:獲取到了A,成功更新為B,已返回

執行緒2:獲取到了“當前值”A,成功更新為B

執行緒3:獲取當前值B,成功更新為A,已返回

這個過程中,執行緒2獲取到的變數值A是一箇舊值,儘管和當前的實際值相同,但記憶體地址V中的變數已經經歷了A->B->A的改變。

可這樣的話看起來好像也沒毛病。

接下來我們來結合實際的場景分析它:

我們假設有一個CAS原理的ATM,小明有100元存款,要取錢50元。

由於提款機硬體出了點小問題,提款操作被同時提交兩次,兩個執行緒都是獲取當前值100元,要更新成50元。理想情況下,應該一個執行緒更新成功,另一個執行緒更新失敗,小明的存款只被扣一次。

存款餘額:100元

ATM執行緒1:獲取當前值100,期望更新為50

ATM執行緒2:獲取當前值100,期望更新為50


執行緒1首先執行成功,把餘額從100改成50。執行緒2因為某種原因阻塞了。這時候,他的媽媽剛好給小明匯款50元

存款餘額:50元

ATM執行緒1:獲取當前值100,成功更新為50

ATM執行緒2:獲取當前值100,期望更新為50,Block

執行緒3(他媽來存錢了):獲取當前值50,期望更新為100


執行緒2仍然是阻塞狀態,執行緒3執行成功,把餘額從50改成100。

存款餘額:100元

ATM執行緒1:獲取當前值100,成功更新為50,已返回

ATM執行緒2:獲取當前值100,期望更新為50,Block

執行緒3(他媽來存錢了):獲取當前值50,成功更新為100


執行緒2恢復執行,由於阻塞之前已經獲得了“當前值”100,並且經過compare檢測,此時存款實際值也是100,所以成功把變數值100更新成了50。

存款餘額:50元

ATM執行緒1:獲取當前值100,成功更新為50,已返回

ATM執行緒2:獲取“當前值”100,成功更新為50

執行緒3(他媽來存錢了):獲取當前值50,成功更新為100,已返回

這下問題就來了,小明的50元錢白白沒有了,原本執行緒2應當提交失敗,小灰的正確餘額應該保持為100元,結果由於ABA問題提交成功了。

如何解決ABA問題呢

思路和樂觀鎖差不多,採用版本號就行了,在compare階段不僅要比較期望值A和地址V中的實際值,還要比較版本號是否一致。

我們仍然以最初的例子來說明一下,假設地址V中儲存著變數值A,當前版本號是01。執行緒1獲得了當前值A和版本號01,想要更新為B,但是被阻塞了。

版本號01:記憶體地址V→A

執行緒1:獲取當前值A,版本號01,期望更新為B

這時候發生ABA問題,記憶體中的值發生了多次改變,最後仍然是A,版本號提升為03


版本號03:記憶體地址V→A

執行緒1:獲取當前值A,版本號01,期望更新為B

隨後執行緒1恢復執行,發現版本號不相等,所以更新失敗。

具體的可以參考java中的AtomicStampedReference它用版本號比較做了CAS機制。

總結

CAS原理差不多了,它雖然高效,但是有如下問題:

1、ABA問題,可以通過版本號解決

2、迴圈時間長,開銷比較大:如果併發量相當高,CAS操作長時間不成功時,會導致其一直自旋,帶來CPU消耗比較大

補充一下自旋鎖和非自旋鎖的概念:

死磕java concurrent包系列(一)從樂觀鎖、悲觀鎖到AtomicInteger的CAS演算法

CAS作為concurrent包基礎中的基礎,在戰勝併發程式設計的旅途中有著舉足輕重的地位,接下來我們將基於CAS講解,Concurrent包中最基礎的元件,我們常用的ReetrantLock SemaPhore LinkedBlockingQueue ArrayBlockingQueue都是基於它實現的。它就是AQS。

傳送門:https://juejin.im/post/5c021b59f265da6175737f0b




相關文章