淺談 Java 併發下的樂觀鎖


引子

各位少俠你們好!今天咱們來聊聊 Java 併發下的樂觀鎖。html

在聊樂觀鎖以前,先給你們複習一個概念:原子操做:java

什麼是原子操做呢?web

咱們知道,原子(atom)指化學反應不可再分的基本微粒。在 Java 多線程編程中,所謂原子操做,就是即便命令涉及多個操做,這些操做依次執行,不會被別的線程插隊打斷。算法

原子操做

聊完原子操做了,咱們進入正題。編程

你們都知道,通常而言,因爲多線程併發會致使安全問題,針對變量的操做,都會採用鎖的機制。鎖通常會分爲樂觀鎖悲觀鎖兩種。api

悲觀鎖

對於悲觀鎖,開發者認爲數據發送時發生併發衝突的機率很大,因此每次進行讀操做前都會上鎖。安全

樂觀鎖

對於樂觀鎖,開發者認爲數據發送時發生併發衝突的機率不大,因此讀操做前不上鎖。微信

到了寫操做時纔會進行判斷,數據在此期間是否被其餘線程修改。若是發生修改,那就返回寫入失敗;若是沒有被修改,那就執行修改操做,返回修改爲功。網絡

樂觀鎖通常都採用 Compare And Swap(CAS)算法進行實現。顧名思義,該算法涉及到了兩個操做,比較(Compare)和交換(Swap)。多線程

CAS 算法流程

CAS 算法的思路以下:

  1. 該算法認爲不一樣線程對變量的操做時產生競爭的狀況比較少。
  2. 該算法的核心是對當前讀取變量值 E 和內存中的變量舊值 V 進行比較。
  3. 若是相等,就表明其餘線程沒有對該變量進行修改,就將變量值更新爲新值 N。
  4. 若是不等,就認爲在讀取值 E 到比較階段,有其餘線程對變量進行過修改,不進行任何操做。

當線程運行 CAS 算法時,該運行過程是原子操做,也就是說,Compare And Swap 這個過程雖然涉及邏輯比較繁冗,但具體操做一鼓作氣。

Java中 CAS 的底層實現

Java 中的 Unsafe 類

我先問你們一個問題:

什麼是指針?

針對學過 C、C++ 語言的同窗想必都不陌生。說白了,指針就是內存地址,指針變量也就是用來存放內存地址的變量。

但對於指針這個東西的使用,有利有弊。有利的地方在於若是咱們有了內存的偏移量,換句話說有了數據在內存中的存儲位置座標,就能夠直接針對內存的變量操做;

弊端就在於指針是語言中功能強大的組件,若是一個新手在編程時,沒有考慮指針的安全性,錯誤的操做指針把某塊不應修改的內存值修改,容易致使整個程序崩潰。

錯誤使用指針

對於 Java 語言,沒有直接的指針組件,通常也不能使用偏移量對某塊內存進行操做。這些操做相對來說是安全(safe)的。

但其實 Java 有個類叫 Unsafe 類,這個類類使 Java 擁有了像 C 語言的指針同樣操做內存空間的能力,同時也帶來了指針的問題。這個類能夠說是 Java 併發開發的基礎。

Unsafe 類中的 CAS

通常而言,你們接觸到的 CAS 函數都是 Unsafe 類提供的封裝。下面就是一些 CAS 函數。

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 型變量三個函數。

咱們以最好理解的 compareAndSwapInt 爲例,來看一下吧:

public final native boolean compareAndSwapInt(
    Object paramObject, 
    long paramLong, 
    int paramInt1, 
    int paramInt2)
;

能夠看到,該函數有四個參數:

  • 第一個是目標對象
  • 第二個參數用來表示咱們上文講的指針,這裏是一個 long 類型的數值,表示該成員變量在其對應對象屬性的偏移量。換句話說,函數就能夠利用這個參數,找到變量在內存的具體位置,從而進行 CAS 操做
  • 第三個參數就是預期的舊值,也就是示例中的 V。
  • 第四個參數就是修改出的新值,也就是示例中的 N。

有同窗會問了,Java 中只有整型的 CAS 函數嗎?有沒有針對 double 型和 boolean 型的 CAS 函數?

很惋惜的是, Java 中 CAS 操做和 UnSafe 類沒有提供對於 double 型和 boolean 型數據的操做方法。但咱們能夠利用現有方法進行包裝,自制 double 型和 boolean 型數據的操做方法。

  • 對於 boolean 類型,咱們能夠在入參的時候將 boolean 類型轉爲 int 類型,在返回值的時候,將 int 類型轉爲 boolean 類型。
  • 對於 double 類型,則依賴 long 類型了, double 類型提供了一種 double 類型和 long 類型互轉的函數。
public static native double longBitsToDouble(
    long bits)
;

public static native long doubleToRawLongBits(
    double value)
;

你們都知道,基礎數據類型在底層的存儲方式都是bit類型。所以不管是long類型仍是double類型在計算機底層存儲方式都是比特。因此就很好理解這兩個函數了:

  • longBitsToDouble 函數將 long 類型底層的實際二進制存儲數據,用 double 類型強行翻譯出來
  • doubleToRawLongBits 函數將 double 類型底層的實際二進制存儲數據,用 long 類型強行翻譯出來

CAS 在 Java 中的使用

一個比較常見的操做,使用變量 i 來爲程序計數,能夠對 i 自增來實現。

int i=0;
i++; 

但稍有經驗的同窗都知道這種寫法是線程不安全的。

若是 500 個線程同時執行一次 i++,獲得 i 的結果不必定爲 500,可能會比 500 小。

這是由於 i++ 其實並不僅是一行命令,它涉及如下幾個操做:(如下代碼爲 Java 代碼編譯後的字節碼)

getfield  #從內存中獲取變量 i 的值
iadd      #將 count 加 1
putfield  #將加 1 後的結果賦值給 i 變量

能夠看到,簡簡單單一個自增操做涉及這三個命令,並且這些命令並非一鼓作氣的,在多線程狀況下很容易被別的線程打斷。

自增操做

雖然兩個線程都進行了 i++ 的操做,i 的值本應是 2,可是按上圖的流程來講,i 的值就變爲 1 了

若是須要執行咱們想要的操做,代碼能夠這樣改寫。

int i=0;
synchronized{
    i++;
}

咱們知道,經過 synchronized 關鍵字修飾時代價很大,Java 提供了一個 atomic 類,若是變量 i 被聲明爲 atomic 類,並執行對應操做,就不會有以前所說的問題了,並且相較 synchronized 代價較小。

AtomicInteger i= new AtomicInteger(0);
i.getAndIncrement();

Java 的 Atomic 基礎數據類型類還提供

  • AtomicInteger 針對 int 類型的原子操做
  • AtomicLong 針對 long 類型的原子操做
  • AtomicBoolean 針對 boolean 類型的原子操做

Atomic基礎數據類型支持的方法以下圖所示:

Atomic基礎數據類型
  • getCurrentValue :獲取該基礎數據類型的當前值。
  • setValue :設置當前基礎數據類型的值爲目標值。
  • getAndSet :獲取該基礎數據類型的當前值並設置當前基礎數據類型的值爲目標值。
  • getAndIncrement :獲取該基礎數據類型的當前值並自增 1,相似於 i++。
  • getAndDecrement :獲取該基礎數據類型的當前值並自減 1,相似於 i--。
  • getAndAdd :獲取該基礎數據類型的當前值並自增給定參數的值。
  • IncrementAndGet :自增 1 並獲取增長後的該基礎數據類型的值,相似於 ++i。
  • decrementAndGet :自減 1 並獲取增長後的該基礎數據類型的值,相似於 --i。
  • AddAndGet :自增給定參數的值並獲取該基礎數據類型自增後的值。

這些基本數據類型的函數底層實現都有 CAS 的身影。

咱們來拿最簡單的 AtomicIntegergetAndIncrement 函數舉例吧:(源碼來源 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 函數:

public final compareAndSet(int expect, undate){
    return unsafe.compareAndSwapInt
    (this, valueOffset, expect, update);
}

也就回到了咱們剛剛講述的 unsafe 包下的 compareAndSwapInt 函數了。

自旋

除了 CAS 以外,Atomic 類還採用了一種方式優化拿到鎖的過程。

咱們知道,當一個線程拿不到對應的鎖的時候,能夠有兩種策略:

策略 1:放棄得到 CPU ,將線程置於阻塞狀態,等待後續被操做系統喚醒和調度。

固然這麼作的弊端很明顯,這種狀態的切換涉及到了用戶態到內核態的切換,開銷通常比較大,若是線程很快就把佔用的鎖釋放了,這麼作顯然是不合算的。

策略 2:不放棄 CPU ,不停的重試,這種操做也稱爲自旋。

固然這麼作也有弊端,若是某個線程持有鎖的時間過長,就會致使其它等待獲取鎖的線程一直在毫無心義的消耗 CPU 資源。使用不當會形成 CPU 使用率極高。在這種狀況下,策略 1 更合理一些。

咱們前文中所說的 AtomicIntegerAtomicLong 在執行相關操做的時候就採起策略 2。通常這種策略也被稱爲自旋鎖。

能夠看到在 AtomicIntegergetAndIncrement 函數中,函數外包了一個

for(;;)

其實就是一個不斷重試的死循環,也就是這裏說的自旋。

但如今大多采起的策略是開發者設置一個門限值,在門限值內進行不斷地自旋。

若是自旋失敗次數超過門限值了,那就採起進入阻塞狀態。

自旋

ABA 問題與 AtomicMarkable

CAS 算法自己有一個很大的缺陷,那就是 ABA 問題。

咱們能夠看到, CAS 算法是基於值來作比較的,若是當前有兩個線程,一個線程將變量值從 A 改成 B ,再由 B 改回爲 A ,當前線程開始執行 CAS 算法時,就很容易認爲值沒有變化,誤認爲讀取數據到執行 CAS 算法的期間,沒有線程修改過數據。

ABA 問題

咋一看好像這個缺陷不會引起什麼問題,實則否則,給你們舉個例子吧。

假設小艾銀行卡有 100 塊錢餘額,且假定銀行轉帳操做就是一個單純的 CAS 命令,對比餘額舊值是否與當前值相同,若是相同則發生扣減/增長,咱們將這個指令用 CAS(origin,expect) 表示。因而,咱們看看接下來發生了什麼:

銀行轉帳
  1. 小明欠小艾100塊錢,小艾欠小牛100塊錢,

  2. 小艾在 ATM 1號機上打算 轉帳 100 塊錢給小牛;假設銀行轉帳底層是用CAS算法實現的。因爲ATM 1號機忽然卡了,這時候小艾跑到旁邊的 ATM 2號機再次操做轉帳;

  3. ATM 2號機執行了 CAS(100,0),順順利利地完成了轉帳,此時小艾的帳戶餘額爲 0;

  4. 小明這時候又給小艾帳上轉了 100,此時小艾帳上餘額爲 100;

  5. 這時候 ATM 1 網絡恢復,繼續執行 CAS(100,0),竟然執行成功了,小艾帳戶上餘額又變爲了 0;

可憐的小艾,因爲 CAS 算法的缺陷,讓他損失了100塊錢。

解決 ABA 問題的方法也不復雜,對於這種 CAS 函數,不只要比較變量值,還須要比較版本號。

public boolean compareAndSet(V expectedReference,
                             V newReference, 
                             int expectedStamp,
                             int newStamp)

以前的 CAS 只有兩個參數,帶上版本號比較的 CAS 就有四個參數了,其中 expectedReference 指的是變量預期的舊值, newReference 指的是變量須要更改爲的新值, expectedStamp 指的是版本號的舊值, newStamp 指的是版本號新值。

修改後的 CAS 算法執行流程以下圖:

改正 CAS 算法

AtomicStampedReference

那如何能在 Java 中順暢的使用帶版本號比較的 CAS 函數呢?

Java 開發人員都幫咱們想好了,他們提供了一個類叫作 Java 的 AtomicStampedReference ,該類封裝了帶版本號比較的 CAS 函數,一塊兒來看看吧。

AtomicStampedReference 定義在 java.util.concurrent.atomic 包下。

下圖描述了該類對應的幾個經常使用方法:

AtomicStampedReference
  • attemptStamp :若是 expectReference 和目前值一致,設置當前對象的版本號戳爲 newStamp
  • compareAndSet :該方法就是前文所述的帶版本號的 CAS 方法。
  • get :該方法返回當前對象值和當前對象的版本號戳
  • getReference :該方法返回當前對象值
  • getStamp :該方法返回當前對象的版本號戳
  • set :直接設置當前對象值和對象的版本號戳

參考:

  1. Java併發實現原理:JDK源碼剖析
  2. https://mp.weixin.qq.com/s/Ad6ufmGSEiQpL38YrvO4mw
  3. https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/AtomicStampedReference.html
  4. 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
  5. https://mp.weixin.qq.com/s/kvuPxn-vc8dke093XSE5IQ

感謝各位少俠閱讀,咱們將會爲你們帶來更多精彩文章


本文分享自微信公衆號 - 程序IT圈(DeveloperIT)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索