淺談 Java 并發(fā)下的樂觀鎖
本文轉載自微信公眾號「后端技術小牛說」,作者小明 。轉載本文請聯(lián)系后端技術小牛說公眾號。
引子
各位少俠大家好!今天我們來聊聊 Java 并發(fā)下的樂觀鎖。
在聊樂觀鎖之前,先給大家復習一個概念:原子操作:
什么是原子操作呢?
我們知道,原子(atom)指化學反應不可再分的基本微粒。在 Java 多線程編程中,所謂原子操作,就是即使命令涉及多個操作,這些操作依次執(zhí)行,不會被別的線程插隊打斷。
原子操作
聊完原子操作了,我們進入正題。
大家都知道,一般而言,由于多線程并發(fā)會導致安全問題,針對變量的讀和寫操作,都會采用鎖的機制。鎖一般會分為樂觀鎖和悲觀鎖兩種。
悲觀鎖
對于悲觀鎖,開發(fā)者認為數(shù)據發(fā)送時發(fā)生并發(fā)沖突的概率很大,所以每次進行讀操作前都會上鎖。
樂觀鎖
對于樂觀鎖,開發(fā)者認為數(shù)據發(fā)送時發(fā)生并發(fā)沖突的概率不大,所以讀操作前不上鎖。
到了寫操作時才會進行判斷,數(shù)據在此期間是否被其他線程修改。如果發(fā)生修改,那就返回寫入失敗;如果沒有被修改,那就執(zhí)行修改操作,返回修改成功。
樂觀鎖一般都采用 Compare And Swap(CAS)算法進行實現(xiàn)。顧名思義,該算法涉及到了兩個操作,比較(Compare)和交換(Swap)。
CAS 算法流程
CAS 算法的思路如下:
該算法認為不同線程對變量的操作時產生競爭的情況比較少。
該算法的核心是對當前讀取變量值 E 和內存中的變量舊值 V 進行比較。
如果相等,就代表其他線程沒有對該變量進行修改,就將變量值更新為新值 N。
如果不等,就認為在讀取值 E 到比較階段,有其他線程對變量進行過修改,不進行任何操作。
當線程運行 CAS 算法時,該運行過程是原子操作,也就是說,Compare And Swap 這個過程雖然涉及邏輯比較繁冗,但具體操作一氣呵成。
Java中 CAS 的底層
實現(xiàn)Java 中的 Unsafe 類我先問大家一個問題:
什么是指針?
針對學過 C、C++ 語言的同學想必都不陌生。說白了,指針就是內存地址,指針變量也就是用來存放內存地址的變量。
但對于指針這個東西的使用,有利有弊。有利的地方在于如果我們有了內存的偏移量,換句話說有了數(shù)據在內存中的存儲位置坐標,就可以直接針對內存的變量操作;
弊端就在于指針是語言中功能強大的組件,如果一個新手在編程時,沒有考慮指針的安全性,錯誤的操作指針把某塊不該修改的內存值修改,容易導致整個程序崩潰。
錯誤使用指針
對于 Java 語言,沒有直接的指針組件,一般也不能使用偏移量對某塊內存進行操作。這些操作相對來講是安全(safe)的。
但其實 Java 有個類叫 Unsafe 類,這個類類使 Java 擁有了像 C 語言的指針一樣操作內存空間的能力,同時也帶來了指針的問題。這個類可以說是 Java 并發(fā)開發(fā)的基礎。
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ù):
- 第一個是目標對象
- 第二個參數(shù)用來表示我們上文講的指針,這里是一個 long 類型的數(shù)值,表示該成員變量在其對應對象屬性的偏移量。換句話說,函數(shù)就可以利用這個參數(shù),找到變量在內存的具體位置,從而進行 CAS 操作
- 第三個參數(shù)就是預期的舊值,也就是示例中的 V。
- 第四個參數(shù)就是修改出的新值,也就是示例中的 N。
有同學會問了,Java 中只有整型的 CAS 函數(shù)嗎?有沒有針對 double 型和 boolean 型的 CAS 函數(shù)?
很可惜的是, Java 中 CAS 操作和 UnSafe 類沒有提供對于 double 型和 boolean 型數(shù)據的操作方法。但我們可以利用現(xiàn)有方法進行包裝,自制 double 型和 boolean 型數(shù)據的操作方法。
- 對于 boolean 類型,我們可以在入參的時候將 boolean 類型轉為 int 類型,在返回值的時候,將 int 類型轉為 boolean 類型。
- 對于 double 類型,則依賴 long 類型了, double 類型提供了一種 double 類型和 long 類型互轉的函數(shù)。
- public static native double longBitsToDouble(
- long bits);
- public static native long doubleToRawLongBits(
- double value);
大家都知道,基礎數(shù)據類型在底層的存儲方式都是bit類型。因此無論是long類型還是double類型在計算機底層存儲方式都是比特。所以就很好理解這兩個函數(shù)了:
- longBitsToDouble 函數(shù)將 long 類型底層的實際二進制存儲數(shù)據,用 double 類型強行翻譯出來
- doubleToRawLongBits 函數(shù)將 double 類型底層的實際二進制存儲數(shù)據,用 long 類型強行翻譯出來
CAS 在 Java 中的使用
一個比較常見的操作,使用變量 i 來為程序計數(shù),可以對 i 自增來實現(xiàn)。
- int i=0;
- i++;
但稍有經驗的同學都知道這種寫法是線程不安全的。
如果 500 個線程同時執(zhí)行一次 i++,得到 i 的結果不一定為 500,可能會比 500 小。
這是因為 i++ 其實并不只是一行命令,它涉及以下幾個操作:(以下代碼為 Java 代碼編譯后的字節(jié)碼)
- getfield #從內存中獲取變量 i 的值
- iadd #將 count 加 1
- putfield #將加 1 后的結果賦值給 i 變量
可以看到,簡簡單單一個自增操作涉及這三個命令,而且這些命令并不是一氣呵成的,在多線程情況下很容易被別的線程打斷。
自增操作
雖然兩個線程都進行了 i++ 的操作,i 的值本應是 2,但是按上圖的流程來說,i 的值就變?yōu)?1 了
如果需要執(zhí)行我們想要的操作,代碼可以這樣改寫。
- int i=0;
- synchronized{
- i++;
- }
我們知道,通過 synchronized 關鍵字修飾時代價很大,Java 提供了一個 atomic 類,如果變量 i 被聲明為 atomic 類,并執(zhí)行對應操作,就不會有之前所說的問題了,而且相較 synchronized代價較小。
- AtomicInteger i= new AtomicInteger(0);
- i.getAndIncrement();
Java 的 Atomic 基礎數(shù)據類型類還提供
- AtomicInteger 針對 int 類型的原子操作
- AtomicLong 針對 long 類型的原子操作
- AtomicBoolean 針對 boolean 類型的原子操作
Atomic基礎數(shù)據類型支持的方法如下圖所示:
Atomic基礎數(shù)據類型
- getCurrentValue :獲取該基礎數(shù)據類型的當前值。
- setValue :設置當前基礎數(shù)據類型的值為目標值。
- getAndSet :獲取該基礎數(shù)據類型的當前值并設置當前基礎數(shù)據類型的值為目標值。
- getAndIncrement :獲取該基礎數(shù)據類型的當前值并自增 1,類似于 i++。
- getAndDecrement :獲取該基礎數(shù)據類型的當前值并自減 1,類似于 i--。
- getAndAdd :獲取該基礎數(shù)據類型的當前值并自增給定參數(shù)的值。
- IncrementAndGet :自增 1 并獲取增加后的該基礎數(shù)據類型的值,類似于 ++i。
- decrementAndGet :自減 1 并獲取增加后的該基礎數(shù)據類型的值,類似于 --i。
- AddAndGet :自增給定參數(shù)的值并獲取該基礎數(shù)據類型自增后的值。
這些基本數(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 其實就是封裝了 Unsafe 類的一個 native 函數(shù):
- public final compareAndSet(int expect, undate){
- return unsafe.compareAndSwapInt
- (this, valueOffset, expect, update);
- }
也就回到了我們剛剛講述的 unsafe 包下的 compareAndSwapInt 函數(shù)了。
自旋
除了 CAS 之外,Atomic 類還采用了一種方式優(yōu)化拿到鎖的過程。
我們知道,當一個線程拿不到對應的鎖的時候,可以有兩種策略:
策略 1:放棄獲得 CPU ,將線程置于阻塞狀態(tài),等待后續(xù)被操作系統(tǒng)喚醒和調度。
當然這么做的弊端很明顯,這種狀態(tài)的切換涉及到了用戶態(tài)到內核態(tài)的切換,開銷一般比較大,如果線程很快就把占用的鎖釋放了,這么做顯然是不合算的。
策略 2:不放棄 CPU ,不停的重試,這種操作也稱為自旋。
當然這么做也有弊端,如果某個線程持有鎖的時間過長,就會導致其它等待獲取鎖的線程一直在毫無意義的消耗 CPU 資源。使用不當會造成 CPU 使用率極高。在這種情況下,策略 1 更合理一些。
我們前文中所說的 AtomicInteger,AtomicLong 在執(zhí)行相關操作的時候就采取策略 2。一般這種策略也被稱為自旋鎖。
可以看到在 AtomicInteger 的 getAndIncrement 函數(shù)中,函數(shù)外包了一個
- for(;;)
其實就是一個不斷重試的死循環(huán),也就是這里說的自旋。
但現(xiàn)在大多采取的策略是開發(fā)者設置一個門限值,在門限值內進行不斷地自旋。
如果自旋失敗次數(shù)超過門限值了,那就采取進入阻塞狀態(tài)。
自旋
ABA 問題與 AtomicMarkable
CAS 算法本身有一個很大的缺陷,那就是 ABA 問題。
我們可以看到, CAS 算法是基于值來做比較的,如果當前有兩個線程,一個線程將變量值從 A 改為 B ,再由 B 改回為 A ,當前線程開始執(zhí)行 CAS 算法時,就很容易認為值沒有變化,誤認為讀取數(shù)據到執(zhí)行 CAS 算法的期間,沒有線程修改過數(shù)據。
ABA 問題
咋一看好像這個缺陷不會引發(fā)什么問題,實則不然,給大家舉個例子吧。
假設小艾銀行卡有 100 塊錢余額,且假定銀行轉賬操作就是一個單純的 CAS 命令,對比余額舊值是否與當前值相同,如果相同則發(fā)生扣減/增加,我們將這個指令用CAS(origin,expect) 表示。于是,我們看看接下來發(fā)生了什么:
銀行轉賬
- 小明欠小艾100塊錢,小艾欠小牛100塊錢,
- 小艾在 ATM 1號機上打算 轉賬 100 塊錢給小牛;假設銀行轉賬底層是用CAS算法實現(xiàn)的。由于ATM 1號機突然卡了,這時候小艾跑到旁邊的 ATM 2號機再次操作轉賬;
- ATM 2號機執(zhí)行了 CAS(100,0),順順利利地完成了轉賬,此時小艾的賬戶余額為 0;
- 小明這時候又給小艾賬上轉了 100,此時小艾賬上余額為 100;
- 這時候 ATM 1 網絡恢復,繼續(xù)執(zhí)行 CAS(100,0),居然執(zhí)行成功了,小艾賬戶上余額又變?yōu)榱?0;
可憐的小艾,由于 CAS 算法的缺陷,讓他損失了100塊錢。
解決 ABA 問題的方法也不復雜,對于這種 CAS 函數(shù),不僅要比較變量值,還需要比較版本號。
- public boolean compareAndSet(V expectedReference,
- V newReference,
- int expectedStamp,
- int newStamp)
之前的 CAS 只有兩個參數(shù),帶上版本號比較的 CAS 就有四個參數(shù)了,其中 expectedReference指的是變量預期的舊值, newReference 指的是變量需要更改成的新值, expectedStamp 指的是版本號的舊值, newStamp 指的是版本號新值。
修改后的 CAS 算法執(zhí)行流程如下圖:
改正 CAS 算法
AtomicStampedReference
那如何能在 Java 中順暢的使用帶版本號比較的 CAS 函數(shù)呢?
Java 開發(fā)人員都幫我們想好了,他們提供了一個類叫做 Java 的 AtomicStampedReference ,該類封裝了帶版本號比較的 CAS 函數(shù),一起來看看吧。
AtomicStampedReference 定義在 java.util.concurrent.atomic 包下。
下圖描述了該類對應的幾個常用方法:

AtomicStampedReference
- attemptStamp :如果 expectReference 和目前值一致,設置當前對象的版本號戳為 newStamp
- compareAndSet :該方法就是前文所述的帶版本號的 CAS 方法。
- get :該方法返回當前對象值和當前對象的版本號戳
- getReference :該方法返回當前對象值
- getStamp :該方法返回當前對象的版本號戳
- set :直接設置當前對象值和對象的版本號戳
參考:
Java并發(fā)實現(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