Java併發:樂觀鎖

湯圓學Java 發表於 2021-06-17
Java

作者:湯圓

個人部落格:javalover.cc

簡介

悲觀鎖和樂觀鎖都屬於比較抽象的概念;

我們可以用擬人的手法來想象一下:

  • 悲觀鎖:像有些人,凡事都往壞的想,做最壞的打算;在java中就表現為,總是認為其他執行緒會去修改共享資料,所以每次操作共享資料時,都要加鎖(比如我們前面介紹過的內建鎖顯式鎖
  • 樂觀鎖:像樂天派,凡事都往好的想,做最好的打算;在Java中就表現為,總是認為其他執行緒都不會去修改共享資料,所以每次操作共享資料時,都不加鎖,而是通過判斷當前狀態和上一次的狀態,來進行下一步的操作;(比如這節要介紹的無鎖,其中最常見的實現就是CAS演算法)

目錄

  1. 樂觀鎖的簡單實現:CAS
  2. 樂觀鎖的優點&缺點
  3. 樂觀鎖的適用場景

正文

1. 樂觀鎖的簡單實現:CAS

CAS的實現原理是比較並交換,簡單點來說就是,更新資料之前,會先檢查資料是否有被修改過:

  • 如果沒有修改,則直接更新;
  • 如果有被修改過,則重試;

下面我們通過一個程式碼來看下CAS的應用,這裡舉的例子是原子類AtomicInteger

public class AtomicDemo {

    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(1);
        ExecutorService service = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            service.submit(()->{
              	// 這裡會先檢查AtomicInteger中的值是否被修改,如果沒被修改,才會更新,否則會自旋等待
                atomicInteger.getAndIncrement();
            });
        }
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(atomicInteger.get());
    }
}

可以看到,輸出的永遠都是101,說明結果符合預期;

這裡我們看下getAndIncrement的原始碼,如下所示:

// AtomicInteger.java
public final int getAndIncrement() {
  return unsafe.getAndAddInt(this, valueOffset, 1);
}
// UnSafe.java
public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  // 這裡就是上面的CAS演算法核心
  do {
    // 1. 先取出期望值 var5(var1為值所在的物件,var2為欄位在物件中的位移量)
    var5 = this.getIntVolatile(var1, var2);
    // 2. 然後賦值時,獲取當前值,跟剛才取出的期望值 var5作比較
    // 2.1 如果比較後發現值被修改了,則迴圈do while,直到當前值符合預期,才會進行更新操作(預設10次,超過10次還不符合預期,就會掛起執行緒,不再浪費CPU資源)
    // 2.2 如果比較後發現值沒被修改,則直接更新
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    // 3. 返回舊值,即期望值
  return var5;
}

這裡假設我們不是用的原子變數,而是普通的int來執行自增,那麼就有可能出現結果<預期的情況(因為自增不是原子操作),比如下面的程式碼

// 不要用這種方式來修改int值,不安全
public class AtomicDemo {
    static int m = 1;
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int j = i;
            service.submit(()->{
                m++;
            });
        }
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(m);
    }
}

多執行幾次,你會發現結果可能會小於預期,所以這就是原子類的好處:不用加鎖就可以實現自增等原子操作

2. 樂觀鎖的優點&缺點

它的優點很多,比如:

  1. 沒有鎖競爭,也就不會產生死鎖問題
  2. 不需要來回切換執行緒,降低了開銷(悲觀鎖需掛起和恢復執行緒,如果任務執行時間又很短,那麼這個操作就會很頻繁)

優點看起來還可以,那它有沒有缺點呢?也是有的:

  • ABA問題:比如執行緒1將共享資料A改為B,然後過一會又改為A,那麼此時執行緒2訪問資料時,會認為該資料沒被修改過(當前值符合預期值),這樣我們就無法得知資料中間是否真的被修改過,以及修改的次數
  • 開銷問題:如果自旋一直不符合預期值,那麼就會一直自旋,從而導致開銷很大(JDK6之前)
  • 原子操作的侷限性問題:雖然CAS可以保證原子操作,但是隻是針對單個資料而言的;如果有多個資料需要同
    步,CAS還是無能為力

下面我們就針對這幾個缺點來提出對於的解決方案

ABA問題

出現ABA問題,主要是因為我們沒有對修改過程進行記錄(就好比程式中的日誌記錄功能)

那麼我們可以通過版本號的方式來記錄每次修改,比如每修改一次,給物件的版本號屬性加1

不過現在有了AtomicStampedReference這個類,它幫我們封裝了所需的狀態值,拿來即用,如下所示:

public class AtomicStampedReference<V> {

    private static class Pair<T> {
        final T reference;
        // 這裡的stamp就是狀態值,每次CAS都會同時比較當前值T和狀態值stamp
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }
    // 下面就是同時比較當前值和狀態值
     public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
}

開銷問題

利用CAS進行自旋操作時,如果發現當前值一直都不等於期望值,就會一直迴圈(JDK6之前)

所以這裡就引出了一個適應性自旋鎖的概念:當嘗試過N次後,發現還是不成功,則退出迴圈,掛起執行緒(JDK6之後,有了適應性自旋鎖)

這裡的N是不固定的,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費處理器資源

---- 參考自《不可不說的Java“鎖”事

大致意思就是,如果一個執行緒之前自旋成功過,獲取過鎖,那麼後面就會讓這個執行緒多自旋一會,比如20次(信用高)

但是如果如果一個執行緒之前自旋沒成功過或者很少成功,那麼後面就會讓這個執行緒少自旋一會,比如5次(信用低)

這裡需要糾正一個觀點:自旋鎖的次數設定問題,從JDK6開始,-XX:PreBlockSpin這個VM引數已經沒有意義了,在JDK7中已經被移除了;JDK6版本之後,預設都是用適應性自旋鎖來動態設定自旋的次數

如下圖所示:

image

在IDEA中新增-XX:PreBlockSpin=1引數,執行會報錯如下:

image

原子操作的侷限性問題

CAS的原子操作只是針對單個共享變數而言的(就像前面介紹的同步容器一樣,雖然每個方法都有鎖,但是複合操作卻無法保證原子性)

不過AtomicReference這個類會有所幫助,它內部有一個V屬性,我們可以將多個共享變數封裝到這個V屬性中,然後再對V進行CAS操作

原始碼如下:

public class AtomicReference<V> implements java.io.Serializable {
    private static final long serialVersionUID = -1848883965231344442L;

    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicReference.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
	// 這裡的V我們可以自己定義一個類,然後將多個共享變數都封裝進去
    private volatile V value;
}

3. 樂觀鎖的適用場景

分析樂觀鎖的適用場景之前,我們可以先看下悲觀鎖的適用場景

悲觀鎖是一來就上鎖,所以比較適合寫多讀少的場景,因為上了鎖,可以保證資料的一致性

那麼樂觀鎖對應的,就是從來都不上鎖,所以比較適合讀多寫少的場景,因為讀不會修改資料,所以CAS時成功的概率很大,也就不會有額外的開銷

總結

  1. 樂觀鎖的簡單實現:CAS,比較並交換
  2. 樂觀鎖的優點&缺點:
優點 缺點
沒有鎖競爭,也就不會產生死鎖問題 ABA問題(加狀態值解決)
不需要來回切換執行緒,降低了開銷 自旋時間過長導致的開銷問題(舊版本JDK6之前才有的問題,JDK6之後預設用適應性自旋來動態設定自旋次數)
多個共享變數不能保證原子操作(用AtomicReference封裝多個共享變數)
  1. 樂觀鎖的適用場景:讀多寫少

參考