鎖與CAS機制

鎖與CAS機制

(一)鎖的代價和無鎖的優勢

鎖是用來做併發最簡單的方式,當然其代價也是最高的。內核態的鎖的時候需要操作系統進行一次上下文切換,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,等待鎖的線程會被掛起直至鎖釋放。在上下文切換的時候,cpu之前緩存的指令和數據都將失效,對性能有很大的損失。操作系統對多線程的鎖進行判斷就像兩姐妹在爲一個玩具在爭吵,操作系統就是能決定他們誰能拿到玩具的父母,這是很慢的。用戶態的鎖雖然避免了這些問題,但是其實它們只是在沒有真實的競爭時纔有效。

JDK1.5之前都是靠synchronized關鍵字保證同步的,這種通過使用一致的鎖定協議來協調對共享狀態的訪問,可以確保無論哪個線程持有守護變量的鎖,都採用獨佔的方式來訪問這些變量,如果出現多個線程同時訪問鎖,那麼一些線程將被掛起,當線程恢復執行時,必須等待其它線程執行完他們的時間片以後才能被調度執行,在掛起和恢復執行過程中存在着很大的開銷。鎖還存在着其它一些缺點,當一個線程正在等待鎖時,它不能做任何事。如果一個線程在持有鎖的情況下被延遲執行,那麼所有需要這個鎖的線程都無法執行下去。如果被阻塞的線程優先級高,而持有鎖的線程優先級低,將會導致優先級反轉。下圖展現了synchronized的複雜流程:

在這裏插入圖片描述

CAS可以解決這一類弊端,那麼CAS是什麼呢?對於併發控制而言,鎖是一種悲觀策略,會阻塞線程執行,而無鎖是一種樂觀策略,它會假設對資源的訪問時沒有衝突,既然沒有衝突就不需要等待,線程也就不需要阻塞。那多個線程共同訪問臨界區的資源怎麼辦呢,無鎖的策略採用一種比較並交換技術CAS(compare and swap)來鑑別線程衝突,一旦檢測到衝突,就重複當前操作直到沒有衝突爲止。與鎖相比,CAS會使得程序設計比較複雜,但是由於其天生免疫死鎖(根本就沒有鎖,當然就不會有線程一直阻塞了),更爲重要的是,使用無鎖的方式沒有所競爭帶來的開銷,也沒有線程間頻繁調度帶來的開銷,他比基於鎖的方式有更優越的性能,所以在目前已經被廣泛應用。

(二)樂觀鎖與悲觀鎖

剛剛提到了悲觀策略和樂觀策略,所以我們來看看什麼是樂觀鎖和悲觀鎖:

樂觀鎖(也被稱爲無鎖,實際上不是一種鎖,而是一種思想):樂觀地認爲別的線程不會修改值,如果發現值被修改了,可以再次重試,直到成功爲止。我們要講的CAS機制(Compare And Swap)就是一種樂觀鎖。

悲觀鎖:悲觀地認爲別的線程會修改值。獨佔鎖是悲觀鎖的一種,加鎖後就能夠確保程序執行時不會被其它線程干擾,從而得到正確的結果。

(三)CAS機制

CAS機制全稱compare and swap,翻譯爲比較並交換,是一種有名的無鎖(lock-free)算法。也是一種現代 CPU 廣泛支持的CPU指令級的操作,只有一步原子操作,所以非常快。而且CAS避免了請求操作系統來裁定鎖的問題,直接在CPU內部就完成了。

在這裏插入圖片描述

CAS有三個操作參數:

1. 內存位置M(它的值是我們想要去更新的)
2. 預期原值E(上一次從內存中讀取的值)
3. 新值V(應該寫入的新值)

CAS的操作過程:首先讀取內存位置M的原值,記爲E,然後計算新值V,將當前內存位置M的值與E比較(compare),如果相等,則在此過程中說明沒有其它線程來修改過這個值,所以把內存位置M的值更新成V(swap),當然這得在沒有ABA問題的情況下(ABA問題會在後面講到)。如果不相等,說明內存位置M上的值被其他線程修改過了,於是不更新,重新回到操作的開頭再次執行(自旋)。

我們可以看一下用C來表示的CAS算法:

int cas(long *addr, long old, long new) {
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

所以,當多個線程嘗試使用CAS同時更新同一個變量時,其中一個線程會成功更新變量的值,剩下的會失敗,失敗的線程可以不斷重試直到成功。簡單來說,CAS的含義是“我認爲原有的值應該是什麼,如果是,則將原有的值更新爲新值,否則不做修改,並告訴我這個值現在是多少”。

有人可能會很好奇,CAS操作,先讀再比較,然後設置值,步驟這麼多,會不會在步驟之間被其它線程干擾導致衝突?其實是不會的,因爲在底層彙編代碼中CAS操作並不是用三條指令實現的,而是僅僅是一條指令:lock cmpxchg(x86架構),因此不會出現在CAS執行過程中時間片被搶走的情況。但是這就涉及到另一個問題,CAS操作過分的依賴CPU的設計,也就是說CAS本質是CPU中的一條指令。如果CPU不支持CAS操作,CAS就無法實現。

既然在CAS中存在不斷嘗試的過程,那麼會不會造成很大的資源浪費呢?答案是有可能的,這也是CAS的缺陷之一。但是,既然CAS是一個樂觀鎖,那麼設計者在設計時就應該抱着的是樂觀的態度,換句話說,CAS認爲自己有非常大的概率是能夠成功完成當前操作的,所以在CAS看來,完不成便重試(自旋)是一個小概率事件。

(四)CAS存在的問題

  1. ABA問題
    因爲CAS會檢查舊值有沒有變化,這裏存在這樣一個有意思的問題。比如一箇舊值A變爲了成B,然後再變成A,剛好在做CAS時檢查發現舊值並沒有變化依然爲A,但是實際上的確發生了變化。解決方案可以沿襲數據庫中常用的樂觀鎖方式,添加一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。Java 1.5後的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。

  2. 自旋時間過長
    使用CAS時非阻塞同步,也就是說不會將線程掛起,會自旋(無非就是一個死循環)進行下一次嘗試,如果這裏自旋時間過長對性能是很大的消耗。如果JVM能支持處理器提供的pause指令,那麼在效率上會有一定的提升。

  3. 只能保證一個共享變量的原子操作
    當對一個共享變量執行操作時CAS能保證其原子性,如果對多個共享變量進行操作,CAS就不能保證其原子性。有一個解決方案是利用對象整合多個共享變量,即一個類中的成員變量就是這幾個共享變量。然後將這個對象做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用對象之間的原子性。

由此可見,CAS雖然存在一些問題,但也在不斷優化和解決之中。

(五)CAS的一些應用

在jdk中有個java.util.concurrent.atomic包,裏面的類都是基於CAS實現的無鎖操作,這些原子類都是線程安全的。Atomic包裏一共提供了13個類,屬於4種類型的原子更新方式,分別是原子更新基本類型、原子更新數組、原子更新引用和原子更新屬性(字段)。Atomic包裏的類基本都是使用Unsafe實現的包裝類。我們可以挑一個經典的類來講一講(聽說好多人都是從這個類接觸到CAS的),就是AtomicInteger類。

我們可以先看看AtomicInteger是如何初始化的:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // 很顯然AtomicInteger是基於Unsafe類實現的
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    
    // 屬性value值在內存中偏移量
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    // AtomicInteger本身是個整型,所以屬性就是int,被volatile修飾保證線程可見性
    private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
    }
}   

我們可以看出幾點:Unsafe類(類似C語言中的指針)是CAS的核心,也就是AtomicInteger的核心。valueOffset是value在內存中的偏移地址,而Unsafe提供了相應的操作方法。value被volatile關鍵字修飾,保證了線程可見性,這是真正存儲值的變量。

我們來看看AtomicInteger中最常用的getAndIncrement方法是如何實現的:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

很顯然他調用了unsafe中的getAndAddInt方法,那我們就跳轉到這個方法中:

/**
 * Atomically adds the given value to the current value of a field
 * or array element within the given object <code>o</code>
 * at the given <code>offset</code>.
 *
 * @param o object/array to update the field/element in
 * @param offset field/element offset
 * @param delta the value to add
 * @return the previous value
 * @since 1.8
 */
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

 /**
  * Atomically update Java variable to <tt>x</tt> if it is currently
  * holding <tt>expected</tt>.
  * @return <tt>true</tt> if successful
  */
 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

我們可以看到,getAndAddInt方法中的do-while循環相當於CAS中的自旋部分,如果無法替換成功就不斷地嘗試,直到成功爲止。而真正的CAS的核心代碼(比較並交換的過程)在compareAndSwapInt這個native方法中,調用了本地的C++代碼:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
	UnsafeWrapper("Unsafe_CompareAndSwapInt");
	oop p = JNIHandles::resolve(obj);
	//獲取對象的變量的地址
	jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
	//調用Atomic操作
	//先去獲取一次結果,如果結果和現在不同,就直接返回,因爲有其他人修改了;否則會一直嘗試去修改。直到成功。
	return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

我們終於看見了我們熟悉的指令:cmpxchg。也就是說,我們之前講的內容已經完全串起來了。CAS的本質就是一個CPU指令,而所有的原子類的實現都是在一層層的調用這個指令而已,從而實現我們需要的無鎖操作。

假如現有一個new AtomicInteger(0);現在有線程1和線程2同時要對其執行getAndAddInt操作。
1)線程1先拿到值0,此時線程切換;
2)線程2拿到值也爲0,此時調用Unsafe比較內存中的值也是0,比較成功,即進行+1的更新操作,即現在的值爲1。線程切換;
3)線程1恢復運行,利用CAS發現自己的值是0,而內存中則是1。得到:此時值被另外一個線程修改,我不能進行修改;
4)線程1判斷失敗,繼續循環取值,判斷。因爲volatile修飾value,所以再取到的值也是1。這是在執行CAS操作,發現expect和此時內存的值相等,修改成功,值爲2;
5)在第四步中的過程中,即使在CAS操作時有線程3來搶佔資源,但是也是無法搶佔成功的,因爲compareAndSwapInt是一個原子操作。

2020年5月30日

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章