面試:為了進阿里,又把併發CAS(Compare and Swap)實現重新精讀一遍

Ccww技術部落格發表於2020-09-06

該系列文章已收錄在公眾號【Ccww技術部落格】,原創技術文章第一時間推出

前言

在面試中,併發執行緒安全提問必然是不會缺少的,那基礎的CAS原理也必須瞭解,這樣在面試中才能加分,那來看看面試可能會問那些問題:

  • 什麼是樂觀鎖與悲觀鎖

  • 什麼樂觀鎖的實現方式-CAS(Compare and Swap),CAS(Compare and Swap)實現原理

  • 在JDK併發包中的使用

  • CAS的缺陷

 


1. 什麼是樂觀鎖與悲觀鎖?

悲觀鎖

總是假設最壞的情況,每次讀取資料的時候都預設其他執行緒會更改資料,因此需要進行加鎖操作,當其他執行緒想要訪問資料時,都需要阻塞掛起。悲觀鎖的實現:

  • 傳統的關係型資料庫使用這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖;

  • Java裡面的同步synchronized關鍵字的實現。

樂觀鎖

樂觀鎖,其實就是一種思想,總是認為不會產生併發問題,每次讀取資料的時候都認為其他執行緒不會修改資料,所以不上鎖,但是在更新的時候會判斷一下在此期間別的執行緒有沒有修改過資料,樂觀鎖適用於讀操作多的場景,這樣可以提高程式的吞吐量。實現方式:

  • CAS實現:Java中java.util.concurrent.atomic包下面的原子變數使用了樂觀鎖的一種CAS實現方式,CAS分析看下節。

  • 版本號控制:一般是在資料表中加上一個資料版本號version欄位,表示資料被修改的次數,當資料被修改時,version值會加一。當執行緒A要更新資料值時,在讀取資料的同時也會讀取version值,在提交更新時,若剛才讀取到的version值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功

樂觀鎖適用於讀多寫少的情況下(多讀場景),悲觀鎖比較適用於寫多讀少場景

 


2. 樂觀鎖的實現方式-CAS(Compare and Swap),CAS(Compare and Swap)實現原理

背景

在jdk1.5之前都是使用synchronized關鍵字保證同步,synchronized保證了無論哪個執行緒持有共享變數的鎖,都會採用獨佔的方式來訪問這些變數,導致會存在這些問題:

  • 在多執行緒競爭下,加鎖、釋放鎖會導致較多的上下文切換和排程延時,引起效能問題

  • 如果一個執行緒持有鎖,其他的執行緒就都會掛起,等待持有鎖的執行緒釋放鎖。

  • 如果一個優先順序高的執行緒等待一個優先順序低的執行緒釋放鎖,會導致優先順序倒置,引起效能風險

為了優化悲觀鎖這些問題,就出現了樂觀鎖:

假設沒有併發衝突,每次不加鎖操作同一變數,如果有併發衝突導致失敗,則重試直至成功。

CAS(Compare and Swap)原理

CAS 全稱是 compare and swap(比較並且交換),是一種用於在多執行緒環境下實現同步功能的機制,其也是無鎖優化,或者叫自旋,還有自適應自旋。

在jdk中,CASvolatile關鍵字作為實現併發包的基石。沒有CAS就不會有併發包,java.util.concurrent中藉助了CAS指令實現了一種區別於synchronized的一種樂觀鎖。

 

樂觀鎖的一種典型實現機制(CAS):

樂觀鎖主要就是兩個步驟:

  • 衝突檢測

  • 資料更新

當多個執行緒嘗試使用CAS同時更新同一個變數時,只有一個執行緒可以更新變數的值,其他的執行緒都會失敗,失敗的執行緒並不會掛起,而是告知這次競爭中失敗了,並可以再次嘗試。

在不使用鎖的情況下保證執行緒安全,CAS實現機制中有重要的三個運算元:

  • 需要讀寫的記憶體位置(V)

  • 預期原值(A)

  • 新值(B)

首先先讀取需要讀寫的記憶體位置(V),然後比較需要讀寫的記憶體位置(V)和預期原值(A),如果記憶體位置與預期原值的A相匹配,那麼將記憶體位置的值更新為新值B。如果記憶體位置與預期原值的值不匹配,那麼處理器不會做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。具體可以分成三個步驟:

  • 讀取(需要讀寫的記憶體位置(V))

  • 比較(需要讀寫的記憶體位置(V)和預期原值(A))

  • 寫回(新值(B))


3. CAS在JDK併發包中的使用

在JDK1.5以上 java.util.concurrent(JUC java併發工具包)是基於CAS演算法實現的,相比於synchronized獨佔鎖,堵塞演算法,CAS是非堵塞演算法的一種常見實現,使用樂觀鎖JUC在效能上有了很大的提升。

 

CAS如何在不使用鎖的情況下保證執行緒安全,看併發包中的原子操作類AtomicInteger::getAndIncrement()方法(相當於i++的操作):

AtomicIntegerå®ç°

// AtomicInteger中
//value的偏移量
private static final long valueOffset; 
//獲取值
private volatile int value;
//設定value的偏移量
static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
//增加1
public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
​

  

  • 首先value必須使用了volatile修飾,這就保證了他的可見性與有序性

  • 需要初始化value的偏移量

  • unsafe.getAndAddInt通過偏移量進行CAS操作,每次從記憶體中讀取資料然後將資料進行+1操作,然後對原資料,+1後的結果進行CAS操作,成功的話返回結果,否則重試直到成功為止。

    //unsafe中
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            //使用偏移量獲取記憶體中value值
            var5 = this.getIntVolatile(var1, var2);
           //比較並value加+1
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        return var5;
    }
    

      

     

JAVA實現CAS的原理,unsafe::compareAndSwapInt是藉助C來呼叫CPU底層指令實現的。下面是sun.misc.Unsafe::compareAndSwapInt()方法的原始碼:

public final native boolean compareAndSwapInt(Object o, long offset,
                                               int expected, int x);

  

4. CAS的缺陷

ABA問題

在多執行緒場景下CAS會出現ABA問題,例如有2個執行緒同時對同一個值(初始值為A)進行CAS操作,這三個執行緒如下

執行緒1,期望值為A,欲更新的值為B

執行緒2,期望值為A,欲更新的值為B

執行緒3,期望值為B,欲更新的值為A

  • 執行緒1搶先獲得CPU時間片,而執行緒2因為其他原因阻塞了,執行緒1取值與期望的A值比較,發現相等然後將值更新為B,

  • 這個時候出現了執行緒3,執行緒3取值與期望的值B比較,發現相等則將值更新為A

  • 此時執行緒2從阻塞中恢復,並且獲得了CPU時間片,這時候執行緒2取值與期望的值A比較,發現相等則將值更新為B,雖然執行緒2也完成了操作,但是執行緒2並不知道值已經經過了A->B->A的變化過程。

ABA問題帶來的危害:

小明在提款機,提取了50元,因為提款機問題,有兩個執行緒,同時把餘額從100變為50

執行緒1(提款機):獲取當前值100,期望更新為50,

執行緒2(提款機):獲取當前值100,期望更新為50,

執行緒1成功執行,執行緒2某種原因block了,這時,某人給小明匯款50 執行緒3(預設):獲取當前值50,期望更新為100,

這時候執行緒3成功執行,餘額變為100, 執行緒2從Block中恢復,獲取到的也是100,compare之後,繼續更新餘額為50!!!

此時可以看到,實際餘額應該為100(100-50+50),但是實際上變為了50(100-50+50-50)這就是ABA問題帶來的成功提交。

 

解決方法

  • AtomicStampedReference: 帶有時間戳的物件引用來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

  • public boolean compareAndSet(
                   V      expectedReference,//預期引用
                   V      newReference,//更新後的引用
                  int    expectedStamp, //預期標誌
                  int    newStamp //更新後的標誌
    ​
    ) 
  • version:在變數前面加上版本號,每次變數更新的時候變數的版本號都+1,即A->B->A就變成了1A->2B->3A

迴圈時間長開銷大

自旋CAS(不成功,就一直迴圈執行,直到成功)如果長時間不成功,會給CPU帶來極大的執行開銷。

解決方法:

  • 限制自旋次數,防止進入死迴圈

  • JVM能支援處理器提供的pause指令那麼效率會有一定的提升,

只能保證一個共享變數的原子操作

當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性

解決方法:

  • 如果需要對多個共享變數進行操作,可以使用加鎖方式(悲觀鎖)保證原子性,

  • 可以把多個共享變數合併成一個共享變數進行CAS操作。

該系列文章已收錄在公眾號【Ccww技術部落格】,原創技術文章第一時間推出

 

相關文章