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

前言

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


這篇文章主要是爲了讓你們經過樂觀鎖和悲觀鎖出發,理解CAS算法,由於CAS是整個Concurrent包的基礎。算法

樂觀鎖和悲觀鎖

首先,java和數據庫中都有這種概念,他只是一種廣義的概念(從線程同步的角度上看):數據庫

悲觀鎖:悲觀的認爲本身在使用數據的時候必定有別的線程來修改數據,所以在獲取數據的時候會先加鎖,確保數據不會被別的線程修改。Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖。編程

樂觀鎖:樂觀的認爲本身在使用數據時不會有別的線程修改數據,因此不會添加鎖,只是在更新數據的時候去判斷以前有沒有別的線程更新了這個數據。若是這個數據沒有被更新,當前線程將本身修改的數據成功寫入。若是數據已經被其餘線程更新,則根據不一樣的實現方式執行不一樣的操做(例如報錯或者自動重試)。bash

根據從上面的概念描述咱們能夠發現:併發

  • 悲觀鎖適合寫操做多的場景,先加鎖能夠保證寫操做時數據正確。
  • 樂觀鎖適合讀操做多的場景,不加鎖的特色可以使其讀操做的性能大幅提高。

從代碼層面理解:post

悲觀鎖:性能

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

樂觀鎖:ui

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

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

那麼問題來了:

樂觀鎖不鎖定資源是如何實現線程同步的呢?

答案是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變量的內存地址。


接下來看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原理差很少了,它雖然高效,可是有以下問題:

一、ABA問題,能夠經過版本號解決

二、循環時間長,開銷比較大:若是併發量至關高,CAS操做長時間不成功時,會致使其一直自旋,帶來CPU消耗比較大

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


CAS做爲concurrent包基礎中的基礎,在打敗併發編程的旅途中有着舉足輕重的地位,接下來咱們將基於CAS講解,Concurrent包中最基礎的組件,咱們經常使用的ReetrantLock SemaPhore LinkedBlockingQueue ArrayBlockingQueue都是基於它實現的。它就是AQS。

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

相關文章
相關標籤/搜索