淺談 Java 并發(fā)下的樂觀鎖
引子
各位少俠大家好!今天我們來聊聊 Java 并發(fā)下的樂觀鎖。
在聊樂觀鎖之前,先給大家復(fù)習(xí)一個概念:原子操作:
什么是原子操作呢?
我們知道,原子(atom)指化學(xué)反應(yīng)不可再分的基本微粒。在 Java 多線程編程中,所謂原子操作,就是即使命令涉及多個操作,這些操作依次執(zhí)行,不會被別的線程插隊(duì)打斷。

聊完原子操作了,我們進(jìn)入正題。
大家都知道,一般而言,由于多線程并發(fā)會導(dǎo)致安全問題,針對變量的讀和寫操作,都會采用鎖的機(jī)制。鎖一般會分為樂觀鎖和悲觀鎖兩種。
悲觀鎖
對于悲觀鎖,開發(fā)者認(rèn)為數(shù)據(jù)發(fā)送時發(fā)生并發(fā)沖突的概率很大,所以每次進(jìn)行讀操作前都會上鎖。
樂觀鎖
對于樂觀鎖,開發(fā)者認(rèn)為數(shù)據(jù)發(fā)送時發(fā)生并發(fā)沖突的概率不大,所以讀操作前不上鎖。
到了寫操作時才會進(jìn)行判斷,數(shù)據(jù)在此期間是否被其他線程修改。如果發(fā)生修改,那就返回寫入失敗;如果沒有被修改,那就執(zhí)行修改操作,返回修改成功。
樂觀鎖一般都采用 Compare And Swap(CAS)算法進(jìn)行實(shí)現(xiàn)。顧名思義,該算法涉及到了兩個操作,比較(Compare)和交換(Swap)。

CAS 算法的思路如下:
該算法認(rèn)為不同線程對變量的操作時產(chǎn)生競爭的情況比較少。 該算法的核心是對當(dāng)前讀取變量值 E 和內(nèi)存中的變量舊值 V 進(jìn)行比較。 如果相等,就代表其他線程沒有對該變量進(jìn)行修改,就將變量值更新為新值 N。 如果不等,就認(rèn)為在讀取值 E 到比較階段,有其他線程對變量進(jìn)行過修改,不進(jìn)行任何操作。
當(dāng)線程運(yùn)行 CAS 算法時,該運(yùn)行過程是原子操作,也就是說,Compare And Swap 這個過程雖然涉及邏輯比較繁冗,但具體操作一氣呵成。
Java中 CAS 的底層實(shí)現(xiàn)
Java 中的 Unsafe 類
我先問大家一個問題:
什么是指針?
針對學(xué)過 C、C++ 語言的同學(xué)想必都不陌生。說白了,指針就是內(nèi)存地址,指針變量也就是用來存放內(nèi)存地址的變量。
但對于指針這個東西的使用,有利有弊。有利的地方在于如果我們有了內(nèi)存的偏移量,換句話說有了數(shù)據(jù)在內(nèi)存中的存儲位置坐標(biāo),就可以直接針對內(nèi)存的變量操作;
弊端就在于指針是語言中功能強(qiáng)大的組件,如果一個新手在編程時,沒有考慮指針的安全性,錯誤的操作指針把某塊不該修改的內(nèi)存值修改,容易導(dǎo)致整個程序崩潰。

對于 Java 語言,沒有直接的指針組件,一般也不能使用偏移量對某塊內(nèi)存進(jìn)行操作。這些操作相對來講是安全(safe)的。
但其實(shí) Java 有個類叫 Unsafe 類,這個類類使 Java 擁有了像 C 語言的指針一樣操作內(nèi)存空間的能力,同時也帶來了指針的問題。這個類可以說是 Java 并發(fā)開發(fā)的基礎(chǔ)。
Unsafe 類中的 CAS
一般而言,大家接觸到的 CAS 函數(shù)都是 Unsafe 類提供的封裝。下面就是一些 CAS 函數(shù)。
public?final?native?boolean?compareAndSwapObject(
????Object?paramObject1,?
????long?paramLong,?
????Object?paramObject2,?
????Object?paramObject3);
public?final?native?boolean?compareAndSwapInt(
????Object?paramObject,?
????long?paramLong,?
????int?paramInt1,?
????int?paramInt2);
public?final?native?boolean?compareAndSwapLong(
????Object?paramObject,?
????long?paramLong1,?
????long?paramLong2,?
????long?paramLong3);
這就是 Unsafe 包下提供的 CAS 更新對象、CAS 更新 int 型變量、CAS 更新 long 型變量三個函數(shù)。
我們以最好理解的 compareAndSwapInt 為例,來看一下吧:
public?final?native?boolean?compareAndSwapInt(
????Object?paramObject,?
????long?paramLong,?
????int?paramInt1,?
????int?paramInt2);
可以看到,該函數(shù)有四個參數(shù):
第一個是目標(biāo)對象 第二個參數(shù)用來表示我們上文講的指針,這里是一個 long 類型的數(shù)值,表示該成員變量在其對應(yīng)對象屬性的偏移量。換句話說,函數(shù)就可以利用這個參數(shù),找到變量在內(nèi)存的具體位置,從而進(jìn)行 CAS 操作 第三個參數(shù)就是預(yù)期的舊值,也就是示例中的 V。 第四個參數(shù)就是修改出的新值,也就是示例中的 N。
有同學(xué)會問了,Java 中只有整型的 CAS 函數(shù)嗎?有沒有針對 double 型和 boolean 型的 CAS 函數(shù)?
很可惜的是, Java 中 CAS 操作和 UnSafe 類沒有提供對于 double 型和 boolean 型數(shù)據(jù)的操作方法。但我們可以利用現(xiàn)有方法進(jìn)行包裝,自制 double 型和 boolean 型數(shù)據(jù)的操作方法。
對于 boolean 類型,我們可以在入?yún)⒌臅r候?qū)?boolean 類型轉(zhuǎn)為 int 類型,在返回值的時候,將 int 類型轉(zhuǎn)為 boolean 類型。 對于 double 類型,則依賴 long 類型了, double 類型提供了一種 double 類型和 long 類型互轉(zhuǎn)的函數(shù)。
public?static?native?double?longBitsToDouble(
????long?bits);
public?static?native?long?doubleToRawLongBits(
????double?value);
大家都知道,基礎(chǔ)數(shù)據(jù)類型在底層的存儲方式都是bit類型。因此無論是long類型還是double類型在計(jì)算機(jī)底層存儲方式都是比特。所以就很好理解這兩個函數(shù)了:
longBitsToDouble 函數(shù)將 long 類型底層的實(shí)際二進(jìn)制存儲數(shù)據(jù),用 double 類型強(qiáng)行翻譯出來 
doubleToRawLongBits 函數(shù)將 double 類型底層的實(shí)際二進(jìn)制存儲數(shù)據(jù),用 long 類型強(qiáng)行翻譯出來 
CAS 在 Java 中的使用
一個比較常見的操作,使用變量 i 來為程序計(jì)數(shù),可以對 i 自增來實(shí)現(xiàn)。
int?i=0;
i++;?
但稍有經(jīng)驗(yàn)的同學(xué)都知道這種寫法是線程不安全的。
如果 500 個線程同時執(zhí)行一次 i++,得到 i 的結(jié)果不一定為 500,可能會比 500 小。
這是因?yàn)?i++ 其實(shí)并不只是一行命令,它涉及以下幾個操作:(以下代碼為 Java 代碼編譯后的字節(jié)碼)
getfield??#從內(nèi)存中獲取變量?i?的值
iadd??????#將?count?加?1
putfield??#將加?1?后的結(jié)果賦值給?i?變量
可以看到,簡簡單單一個自增操作涉及這三個命令,而且這些命令并不是一氣呵成的,在多線程情況下很容易被別的線程打斷。

雖然兩個線程都進(jìn)行了 i++ 的操作,i 的值本應(yīng)是 2,但是按上圖的流程來說,i 的值就變?yōu)?1 了
如果需要執(zhí)行我們想要的操作,代碼可以這樣改寫。
int?i=0;
synchronized{
????i++;
}
我們知道,通過 synchronized 關(guān)鍵字修飾時代價(jià)很大,Java 提供了一個 atomic 類,如果變量 i 被聲明為 atomic 類,并執(zhí)行對應(yīng)操作,就不會有之前所說的問題了,而且相較 synchronized 代價(jià)較小。
AtomicInteger?i=?new?AtomicInteger(0);
i.getAndIncrement();
Java 的 Atomic 基礎(chǔ)數(shù)據(jù)類型類還提供
AtomicInteger針對 int 類型的原子操作AtomicLong針對 long 類型的原子操作AtomicBoolean針對 boolean 類型的原子操作
Atomic基礎(chǔ)數(shù)據(jù)類型支持的方法如下圖所示:

getCurrentValue:獲取該基礎(chǔ)數(shù)據(jù)類型的當(dāng)前值。setValue:設(shè)置當(dāng)前基礎(chǔ)數(shù)據(jù)類型的值為目標(biāo)值。getAndSet:獲取該基礎(chǔ)數(shù)據(jù)類型的當(dāng)前值并設(shè)置當(dāng)前基礎(chǔ)數(shù)據(jù)類型的值為目標(biāo)值。getAndIncrement:獲取該基礎(chǔ)數(shù)據(jù)類型的當(dāng)前值并自增 1,類似于 i++。getAndDecrement:獲取該基礎(chǔ)數(shù)據(jù)類型的當(dāng)前值并自減 1,類似于 i--。getAndAdd:獲取該基礎(chǔ)數(shù)據(jù)類型的當(dāng)前值并自增給定參數(shù)的值。IncrementAndGet:自增 1 并獲取增加后的該基礎(chǔ)數(shù)據(jù)類型的值,類似于 ++i。decrementAndGet:自減 1 并獲取增加后的該基礎(chǔ)數(shù)據(jù)類型的值,類似于 --i。AddAndGet:自增給定參數(shù)的值并獲取該基礎(chǔ)數(shù)據(jù)類型自增后的值。
這些基本數(shù)據(jù)類型的函數(shù)底層實(shí)現(xiàn)都有 CAS 的身影。
我們來拿最簡單的 AtomicInteger 的 getAndIncrement 函數(shù)舉例吧:(源碼來源 JDK 7 )
volatile?int?value;
···
public?final?int?getAndIncrement(){
????for(;;){
????????int?current?=?get();
????????int?next=?current?+?1;
????????if(compareAndSet(current,?next))
????????????return?current;
????}
}
這就類似之前的 i++ 自增操作,這里的 compareAndSet 其實(shí)就是封裝了 Unsafe 類的一個 native 函數(shù):
public?final?compareAndSet(int?expect,?undate){
????return?unsafe.compareAndSwapInt
????(this,?valueOffset,?expect,?update);
}
也就回到了我們剛剛講述的 unsafe 包下的 compareAndSwapInt 函數(shù)了。
自旋
除了 CAS 之外,Atomic 類還采用了一種方式優(yōu)化拿到鎖的過程。
我們知道,當(dāng)一個線程拿不到對應(yīng)的鎖的時候,可以有兩種策略:
策略 1:放棄獲得 CPU ,將線程置于阻塞狀態(tài),等待后續(xù)被操作系統(tǒng)喚醒和調(diào)度。
當(dāng)然這么做的弊端很明顯,這種狀態(tài)的切換涉及到了用戶態(tài)到內(nèi)核態(tài)的切換,開銷一般比較大,如果線程很快就把占用的鎖釋放了,這么做顯然是不合算的。
策略 2:不放棄 CPU ,不停的重試,這種操作也稱為自旋。
當(dāng)然這么做也有弊端,如果某個線程持有鎖的時間過長,就會導(dǎo)致其它等待獲取鎖的線程一直在毫無意義的消耗 CPU 資源。使用不當(dāng)會造成 CPU 使用率極高。在這種情況下,策略 1 更合理一些。
我們前文中所說的 AtomicInteger,AtomicLong 在執(zhí)行相關(guān)操作的時候就采取策略 2。一般這種策略也被稱為自旋鎖。
可以看到在 AtomicInteger 的 getAndIncrement 函數(shù)中,函數(shù)外包了一個
for(;;)
其實(shí)就是一個不斷重試的死循環(huán),也就是這里說的自旋。
但現(xiàn)在大多采取的策略是開發(fā)者設(shè)置一個門限值,在門限值內(nèi)進(jìn)行不斷地自旋。
如果自旋失敗次數(shù)超過門限值了,那就采取進(jìn)入阻塞狀態(tài)。

ABA 問題與 AtomicMarkable
CAS 算法本身有一個很大的缺陷,那就是 ABA 問題。
我們可以看到, CAS 算法是基于值來做比較的,如果當(dāng)前有兩個線程,一個線程將變量值從 A 改為 B ,再由 B 改回為 A ,當(dāng)前線程開始執(zhí)行 CAS 算法時,就很容易認(rèn)為值沒有變化,誤認(rèn)為讀取數(shù)據(jù)到執(zhí)行 CAS 算法的期間,沒有線程修改過數(shù)據(jù)。

咋一看好像這個缺陷不會引發(fā)什么問題,實(shí)則不然,給大家舉個例子吧。
假設(shè)小艾銀行卡有 100 塊錢余額,且假定銀行轉(zhuǎn)賬操作就是一個單純的 CAS 命令,對比余額舊值是否與當(dāng)前值相同,如果相同則發(fā)生扣減/增加,我們將這個指令用
CAS(origin,expect)表示。于是,我們看看接下來發(fā)生了什么:

小明欠小艾100塊錢,小艾欠小牛100塊錢,
小艾在 ATM 1號機(jī)上打算 轉(zhuǎn)賬 100 塊錢給小牛;假設(shè)銀行轉(zhuǎn)賬底層是用CAS算法實(shí)現(xiàn)的。由于ATM 1號機(jī)突然卡了,這時候小艾跑到旁邊的 ATM 2號機(jī)再次操作轉(zhuǎn)賬;
ATM 2號機(jī)執(zhí)行了 CAS(100,0),順順利利地完成了轉(zhuǎn)賬,此時小艾的賬戶余額為 0;
小明這時候又給小艾賬上轉(zhuǎn)了 100,此時小艾賬上余額為 100;
這時候 ATM 1 網(wǎng)絡(luò)恢復(fù),繼續(xù)執(zhí)行 CAS(100,0),居然執(zhí)行成功了,小艾賬戶上余額又變?yōu)榱?0;
可憐的小艾,由于 CAS 算法的缺陷,讓他損失了100塊錢。

解決 ABA 問題的方法也不復(fù)雜,對于這種 CAS 函數(shù),不僅要比較變量值,還需要比較版本號。
public?boolean?compareAndSet(V?expectedReference,
?????????????????????????????V?newReference,?
?????????????????????????????int?expectedStamp,
?????????????????????????????int?newStamp)
之前的 CAS 只有兩個參數(shù),帶上版本號比較的 CAS 就有四個參數(shù)了,其中 expectedReference 指的是變量預(yù)期的舊值, newReference 指的是變量需要更改成的新值, expectedStamp 指的是版本號的舊值, newStamp 指的是版本號新值。
修改后的 CAS 算法執(zhí)行流程如下圖:

AtomicStampedReference
那如何能在 Java 中順暢的使用帶版本號比較的 CAS 函數(shù)呢?
Java 開發(fā)人員都幫我們想好了,他們提供了一個類叫做 Java 的 AtomicStampedReference ,該類封裝了帶版本號比較的 CAS 函數(shù),一起來看看吧。
AtomicStampedReference 定義在 java.util.concurrent.atomic 包下。
下圖描述了該類對應(yīng)的幾個常用方法:

attemptStamp:如果expectReference和目前值一致,設(shè)置當(dāng)前對象的版本號戳為newStampcompareAndSet:該方法就是前文所述的帶版本號的 CAS 方法。get:該方法返回當(dāng)前對象值和當(dāng)前對象的版本號戳getReference:該方法返回當(dāng)前對象值getStamp:該方法返回當(dāng)前對象的版本號戳set:直接設(shè)置當(dāng)前對象值和對象的版本號戳
參考:
Java并發(fā)實(shí)現(xiàn)原理:JDK源碼剖析 https://mp.weixin.qq.com/s/Ad6ufmGSEiQpL38YrvO4mw https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/AtomicStampedReference.html https://zhang0peter.blog.csdn.net/article/details/84020496?utm_medium=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.control&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.control https://mp.weixin.qq.com/s/kvuPxn-vc8dke093XSE5IQ
感謝各位少俠閱讀,我們將會為大家?guī)砀嗑饰恼?/p>
