天天看點

Java CAS 和ABA問題

獨占鎖:是一種悲觀鎖,synchronized就是一種獨占鎖,會導緻其它所有需要鎖的線程挂起,等待持有鎖的線程釋放鎖。

樂觀鎖:每次不加鎖,假設沒有沖突去完成某項操作,如果因為沖突失敗就重試,直到成功為止。

一、CAS 操作

樂觀鎖用到的機制就是CAS,Compare and Swap。

CAS有3個操作數,記憶體值V,舊的預期值A,要修改的新值B。當且僅當預期值A和記憶體值V相同時,将記憶體值V修改為B,否則什麼都不做。

1、非阻塞算法 (nonblocking algorithms)

一個線程的失敗或者挂起不應該影響其他線程的失敗或挂起的算法。

現代的CPU提供了特殊的指令,可以自動更新共享資料,而且能夠檢測到其他線程的幹擾,而 compareAndSet() 就用這些代替了鎖定。

2、AtomicInteger示例

拿出AtomicInteger來研究在沒有鎖的情況下是如何做到資料正确性的。

在沒有鎖的機制下需要借助volatile原語,保證線程間的資料是可見的(共享的)。

這樣才擷取變量的值的時候才能直接讀取。

然後來看看 ++i 是怎麼做到的。

在這裡采用了CAS操作,每次從記憶體中讀取資料然後将此資料和+1後的結果進行CAS操作,如果成功就傳回結果,否則重試直到成功為止。

而compareAndSet利用JNI來完成CPU指令的操作。

整體的過程就是這樣子的,利用CPU的CAS指令,同時借助JNI來完成Java的非阻塞算法。其它原子操作都是利用類似的特性完成的。

而整個J.U.C都是建立在CAS之上的,是以對于synchronized阻塞算法,J.U.C在性能上有了很大的提升。參考資料的文章中介紹了如果利用CAS建構非阻塞計數器、隊列等資料結構。

二、ABA問題

CAS看起來很爽,但是會導緻“ABA問題”。

CAS算法實作一個重要前提需要取出記憶體中某時刻的資料,而在下時刻比較并替換,那麼在這個時間差類會導緻資料的變化。

比如說一個線程one從記憶體位置V中取出A,這時候另一個線程two也從記憶體中取出A,并且two進行了一些操作變成了B,然後two又将V位置的資料變成A,這時候線程one進行CAS操作發現記憶體中仍然是A,然後one操作成功。盡管線程one的CAS操作成功,但是不代表這個過程就是沒有問題的。如果連結清單的頭在變化了兩次後恢複了原值,但是不代表連結清單就沒有變化。是以前面提到的原子操作AtomicStampedReference/AtomicMarkableReference就很有用了。這允許一對變化的元素進行原子操作。

在運用CAS做Lock-Free操作中有一個經典的ABA問題:

線程1準備用CAS将變量的值由A替換為B,在此之前,線程2将變量的值由A替換為C,又由C替換為A,然後線程1執行CAS時發現變量的值仍然為A,是以CAS成功。但實際上這時的現場已經和最初不同了,盡管CAS成功,但可能存在潛藏的問題,例如下面的例子:

​​

Java CAS 和ABA問題

現有一個用單向連結清單實作的堆棧,棧頂為A,這時線程T1已經知道A.next為B,然後希望用CAS将棧頂替換為B:

head.compareAndSet(A,B);

在T1執行上面這條指令之前,線程T2介入,将A、B出棧,再pushD、C、A,此時堆棧結構如下圖,而對象B此時處于遊離狀态:

Java CAS 和ABA問題

此時輪到線程T1執行CAS操作,檢測發現棧頂仍為A,是以CAS成功,棧頂變為B,但實際上B.next為null,是以此時的情況變為:

Java CAS 和ABA問題

其中堆棧中隻有B一個元素,C和D組成的連結清單不再存在于堆棧中,平白無故就把C、D丢掉了。

以上就是由于ABA問題帶來的隐患,各種樂觀鎖的實作中通常都會用版本戳version來對記錄或對象标記,避免并發操作帶來的問題,在Java中,AtomicStampedReference<E>也實作了這個作用,它通過包裝[E,Integer]的元組來對對象标記版本戳stamp,進而避免ABA問題,例如下面的代碼分别用AtomicInteger和AtomicStampedReference來對初始值為100的原子整型變量進行更新,AtomicInteger會成功執行CAS操作,而加上版本戳的AtomicStampedReference對于ABA問題會執行CAS失敗: