CAS(比較與交換,Compare and swap) 是一種有名的無鎖算法,它是樂觀鎖的一種實現方式。所以在進行CAS原理分析的時候,我們先來了解什么是樂觀鎖,什么是悲觀鎖~
樂觀鎖與悲觀鎖
樂觀鎖和悲觀鎖是在數據庫中引入的名詞,但是在我們Java的JUC里面的鎖也引入類似的思想!我們來看看兩種鎖的概念
悲觀鎖
悲觀鎖指對數據被外界修改持保守態度,認為數據很容易就會被其他線程修改,所有在數據被處理前先對數據進行加鎖,並在整個數據處理過程中,使數據處於鎖定狀態。我們的傳統數據庫就會用到這種排它鎖的機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在操作之前上鎖,操作結束提交事務之后釋放鎖!在Java中像Synchronized同步術語,ReentrantLock等也是悲觀鎖!而像volatile關鍵字雖然是synchronized關鍵字的輕量級實現,但是其無法保證原子性,所以一般也要搭配鎖使用。
樂觀鎖
樂觀鎖是相對悲觀鎖來說,它認為數據在一般情況下不會造成沖突,別人不會去修改,所以在訪問記錄前不會加排它鎖。但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號,時間戳來等記錄。因為不加鎖,所以樂觀鎖在多讀的情況下,可以極大的提升我們的吞吐量。在我們的數據庫中提供了類似write_condition機制,在Java中JUC下的原子變量類也是使用了樂觀鎖的一種實現方式CAS,也就是我們下面即將介紹的!
CAS(Compare And Swap)原理解析
Java中,鎖在並發處理中占據了一席之地,但是使用鎖有一個不好的地方,就是當一個線程沒有獲取到鎖時會被阻塞掛起,這會導致線程上下文的切換和重新調度開銷。Java提供了非阻塞的volatile關鍵字來解決共享變量的可見性問題,這在一定程度上彌補了鎖帶來的開銷問題,但是volatile只能保證共享變量的可見性,不能解決讀改一寫等的原子性問題。
CAS就是是JDK提供的非阻塞原子性操作,通過硬件保證了比較-更新操作的原子性。它的主要原理如下:
CAS有三個操作數
- 內存值v
- 舊的預期值A
- 要修改的新值B
當多個線程嘗試使用CAS同時更新一個變量的時候,只有一個能夠更新成功。那就是當我們的內存值V和舊的預期值A相等的情況下,才能將內存值V修改成B!然后失敗的線程不會掛起,而是被告知失敗,可以繼續嘗試(自旋)或者什么都不做!
嘗試重試
我們可以假設有兩個線程,一個線程1,一個線程2,同時對我們的內存值進行自增!我們的內存值剛開始是0,舊的預期值也是0。
- 這個時候線程1進來了,由於我們的內存值和舊的預期值相等,所以更新我們的內存值為要修改的新值1
- 當線程1結束之后,線程2進來了,要對我們的內存值進行修改。但是發現我們的內存A(此時為1)和我們的舊的預期值不相等(此時為0)不相等,所以不能將內存值更新為我們的預期值(預期值為2),所以只能進行將舊的預期值更新為內存值(此時舊的預期值 == 內存值),並告知下一次再試試!
- 當我們的線程2重試更新內存值,此時內存值(此時為1)與我們的舊的預期值(此時為1)相等,所以可以將我們的內存值更新為我們的預期值(2)。
所以,哪怕沒有加鎖,我們也能實現線程安全。
什么都不做
同樣的,我們舉例有兩個線程,一個線程1,一個線程2;我們兩個線程都要對內存進行更新為10。
- 我們假設線程1先進來,此時內存值與我們的舊的預期值都為0,所以可以更新,將我們要修改的新值10賦值給了內存值,完成了更新
- 當線程1完成之后,線程2進來要對我們的內存值進行修改為10,但是發現內存值與舊的預期值不相同(此時一個為10,一個為0),所以只能將舊的預期值更新為內存值,同時被告知了下次不用重試了。(因為我們的目的是將內存值更新為10,顯然我們的目的已經完成了)
原子變量類簡單分析
我們在開頭也提到了,在我們JUC下的原子變量類也是使用CAS來保證操作的原子性。而我們的具體原子變量類有以下這些:
我們以AtomicInteger為例,找一個其中自增的方法分析一下:
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
該方法主要為了自增,它調用了getAndAddInt方法。這個是方法是我們的Unsafe類下面
//var1 是this指針
//var2 是地址偏移量
//var4 是自增的數值,是自增1還是自增N
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//獲取我們的的期望值賦值給var5
var5 = this.getIntVolatile(var1, var2);
//調用了Unsafe下面的另一個方法,是一個native方法
//如果期望值var5與內存值var2相等的話,更新內存值為var5+var4,否則更新期望值為期望值為內存值
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
compareAndSwapInt方法是我們的調用native方法
// 第一和第二個參數代表對象的實例以及地址,第三個參數代表期望值,第四個參數代表更新值
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
它是由我們的底層c代碼調用匯編使用的,最后生成出一條CPU指令cmpxchg,完成操作。這也就為啥CAS是原子性的,因為它是一條CPU指令,不會被打斷。這個指令在我們早期的硬件廠商就在芯片大量使用了,比如intel。
ABA 問題
關於CAS還有一個比較典型的問題,那就是ABA問題。
ABA問題的產生是因為變量的狀態值產生了環形轉換,就是變量的值可以從A到B,然后再從B到A。舉個例子:
- 現在我有一個變量
count=10
,現在有三個線程,分別為A、B、C - 線程A和線程C同時讀到count變量,所以線程A和線程C的內存值和預期值都為10
- 此時線程A使用CAS將count值修改成100
- 修改完后,就在這時,線程B進來了,讀取得到count的值為100(內存值和預期值都是100),將count值修改成10
- 線程C拿到執行權,發現內存值是10,預期值也是10,將count值修改成11
我們重點放在C上面,雖然我們的C成功的修改了值。但是內存值和預期值和我們原來的相同,C就不知道之前這個變量已經被兩個線程操作過了。所以就會有一定的風險。舉個風險通俗的例子:
小明在提款機,提取了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問題帶來的成功提交。
我們針對這個思考,如果變量的值只能朝着一個方向轉換,比如A到B,B再到C,不構成環形,就不會存在問題。在我們的Java中提供了兩個原子類,為我們提供了版本號(時間戳)的方法解決了該問題!
(AtomicStampedReference
和AtomicMarkableReference
)。
這樣我們的A-B-A就會變成1A-2B-3A這種存在,就不存在環形問題了。
總結
我們的CAS雖然解決了原子性,避免了鎖的不必要開銷。但是還是存在三個問題。
第一個問題就是自旋時間長開銷大!有時候自旋時間過長,消耗CPU資源,如果資源競爭激烈,多線程自旋長時間消耗資源。所以我們通過具體場景來選擇加鎖還是通過CAS來解決,CAS是適用於多讀的環境的,如果是大量讀寫的操作的話,還是加鎖吧!
第二個問題就是我們的ABA問題!在上面已經具體介紹了,以及給上了解決方法。
第三個問題就是我們的CAS只能保證一個共享變量的原子操作。也就是說我們只能對一個變量進行賦值,不能同時更新多個。 解決的方法:把多個共享變量合並成一個共享變量。然后使用我們的AtomicReference類來保證引用對象之間的原子性。
參考資料
Java並發編程之美
公眾號《Java3y》多線程系列文章