1. 程式人生 > >【Java併發工具類】原子類

【Java併發工具類】原子類

前言

為保證計數器中count=+1的原子性,我們在前面使用的都是synchronized互斥鎖方案,加鎖獨佔訪問的方式未免太過霸道,於是我們來介紹另一種解決原子性問題的無鎖方案:原子變數。在正式介紹原子變數之前,我們先來總結下鎖的不足,然後深入介紹原子變數。

鎖的劣勢

通過對共享變數加鎖,使得獲取到鎖的執行緒可以採用獨佔方式來訪問共享變數,並且對變數的修改對隨後獲取這個鎖的其他執行緒都是可見的(Happens-Before規則)。

當多個執行緒同時請求鎖時,對於沒有獲取到鎖的執行緒將有可能被掛起並且在稍後才恢復執行(有時會選擇自旋等待)。當執行緒恢復執行時,必須等待其他執行緒執行完它們的時間片後,才能被排程執行。我們需要知道,在掛起和恢復執行緒等過程中會存在著很大的開銷,並且通常存在著較長時間的中斷。

加鎖方案在競爭激烈的情況下,其效能會由於上下文切換的開銷和排程延遲而降低。而如果鎖的持有時間非常短,那麼在不恰當的時間請求鎖時,執行緒休眠的代價將會不太划算。

加鎖還存在一些缺點:

  • 當一個執行緒正在等待鎖時,它不能做任何其他事情。
  • 如果一個執行緒在持有鎖的情況下被延遲執行(例如發生了缺頁錯誤、排程延遲、或者其他情況),那麼所有需要這個鎖的執行緒都無法執行下去。
  • 如果被阻塞的執行緒優先順序較高,而持有鎖的執行緒優先順序較低,那麼將會導致優先順序反轉問題。
    即使高優先順序的執行緒可以搶先執行,但仍然需要等待鎖被釋放,從而導致它的優先順序會降到低優先順序執行緒的級別。
  • 如果持有鎖的執行緒被永久地阻塞(例如優於出現了無限迴圈,死鎖,活鎖或者其他活躍性問題),所有等待這個鎖的執行緒就永遠無法執行下去。

與鎖相比,valatile變數是一種輕量級地同步機制,因為在使用這些變數時不會發生上下文切換或執行緒排程等操作。與鎖一樣雖然提供了可見性保證,但是volatile變數不能用於構建原子的複合操作。好訊息是,下面我們將介紹的原子變數不僅提供了與volatile變數相同的記憶體語義,還支援原子的更新操作,比基於鎖的方案有著更高的可伸縮性。

原子變數的實現原理: 硬體對併發的支援

硬體對併發的支援

獨佔鎖是一項悲觀技術,對於細粒度操作(例如計數器),還有另外一種更高效的方法,也是樂觀方法,通過這種方法也可以在不受其他執行緒干擾的情況下完成更新操作。這種方法通過藉助衝突檢查機制來判斷在更新過程中是否存在來自其他執行緒的干擾,如果存在,這個操作將失敗,並且可以重新嘗試(也可以不嘗試)。

這種方法也就是處理器中提供的一些特殊指令(這些特殊指令本身可以保證原子性)。這些指令用於管理對共享資料的併發訪問。
在早期的處理器中支援原子的測試並設定(Test-and-Set),獲取並遞增(Fetch-and-Increment)以及交換(Swap)等指令,這些指令足以實現各種互斥體,而這些互斥體又可以實現一些更加複雜的併發物件。
現在幾乎所有的現代處理器都包含了某種形式的原子讀-改-寫指令,例如比較並交換(CAS,Compare-and-Swap)或者關聯載入/條件儲存(Load-Linked/Store-Conditional)。

作業系統和JVM使用這些指令來實現鎖和併發的資料結構,在Java 5.0之前,Java類中還不能直接使用這些指令。

原子類由這些特殊的指令實現,所有其效能會比較高。

比較並交換

在大多數處理架構中,對上述方法的實現是實現一個比較並交換(CAS)之指令。(在其他處理器中,採用一對指令來實現相同的功能:關聯載入與條件儲存。)

CAS包含了3個運算元:需要讀寫的記憶體地址V,進行比較的值A和擬寫入的新值B。

當且僅當V中的值等於A時,CAS才會通過原子方式用新值B來更新V中的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。

CAS是一項樂觀的技術,它希望能成功地執行更新操作,並且如果有另一個執行緒在最近一次檢查後更新了該變數,那麼CAS能檢測到這個錯誤。

以下的SimulatedCAS模擬程式碼說明CAS的語義,用來理解CAS的工作原理\(^{[2]}\)。

public class SimulatedCAS {
    private int value;

    public synchronized int get() {
        return value;
    }

    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        // 讀取目前的value值
        int oldValue = value;
        // 比較目前value值是否等於期望值
        if (oldValue == expectedValue)
            value = newValue; // 如果相等,則更新value的值
        return oldValue; // 返回寫入之前的值
    }

    public synchronized boolean compareAndSet(int expectedValue, int newValue) {
        return (expectedValue == compareAndSwap(expectedValue, newValue));
    }
}

當多個執行緒嘗試使用CAS同時更新一個變數時,只有其中一個執行緒能夠更新變數的值,而其他執行緒都將失敗。但是,與獲取鎖實現的執行緒不一樣,這裡失敗的執行緒並不會被掛起,而是被告知在這次競爭中失敗,並可以再次嘗試(或者選擇不嘗試,做一些其他恢復操作等)。這種方式的靈活性大大減少與鎖相關的活躍性風險。

CAS的典型使用模式:首先從V中讀取值A,並根據A計算新值B,然後在通過CAS以原子方式將V中的值由A變為B,前提是這個期間沒有其他執行緒將V的值修改為其他值。
由於CAS能檢測到來自其他執行緒的干擾,因此即使不用鎖也能夠實現原子的讀-改-寫操作。

非阻塞的計數器

下面我們使用CAS來實現一個執行緒安全的計數器。遞增操作採用標準方式:讀取舊值,根據它計算新值,並使用CAS來設定這個新值。如果CAS失敗,那麼該操作將立即重試。反覆的重試也稱為自旋。通常,反覆地重試是一種合理的策略,但是在一些競爭激烈的情況下,最好的方式實在重試之前先等待一段時間或者回退,從而避免造成活鎖問題。

下面基於“CAS+自旋”實現計數器\(^{[2]}\):

public class CasCounter {
    private SimulatedCAS value;

    public int getValue() {
        return value.get();
    }

    public int increment() {
        int v;
        do {
            v = value.get(); // 獲取舊值
        } while (v != value.compareAndSwap(v, v + 1)); // 自旋嘗試
        return v + 1;
    }
}

CasCounter不會阻塞,但是如果其他執行緒同時更新計數器,那麼會多次執行重試操作。

這樣看來,似乎基於CAS的計數器比基於鎖的計數器效能要差一點,因為它需要執行更多的操作和更復雜的控制流。然而實際情況卻並非如此。雖然Java語言的鎖定語法簡潔,但是JVM和操作在管理鎖時需要完成的工作卻並不簡單,即使用鎖簡單,鎖背後要做的工作卻複雜。
在實現鎖定時需要遍歷JVM中一條非常複雜的程式碼路徑,並可能導致作業系統級的鎖定、執行緒掛起以及上下文切換等操作。並且,在最好情況下(獲取無競爭的鎖),在鎖定時至少需要一次CAS,因此雖然在使用鎖時沒有用到CAS,但實際上也無法節約任何執行開銷,也就是說會比CAS執行更多的操作。
在程式內部執行CAS時是不需要執行JVM程式碼、系統呼叫或者執行緒排程操作。在應用級別看起來越長的程式碼路徑,如果加上JVM和作業系統中的程式碼呼叫,那麼事實上卻變得更短。

而CAS的主要缺點在於:它將使呼叫者來處理競爭問題(通過重試、回退和放棄),而在鎖中能自動處理競爭問題(執行緒在獲取鎖之間將一直被阻塞)。
CAS的效能也會隨著處理器數量的不同、體系架構的不同甚至處理器版本的不同而產生變化。

ABA問題

ABA問題是指:如果V的值首先由A變成了B,再由B變成了A,雖然V中的值A好像沒有變,但是在某些演算法中,A的屬性卻是變了。對於保護的變數是數值型別是不需要關心ABA問題,但是如果是物件,就需要注意。

一個相對簡單的解決辦法是:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值由A變成了B,然後又變成了A,版本號也將是不同的。AtomicStampedReferenceAtomicMarkableReference支援在兩個變數上執行原子的條件更新。
AtomicStampedReference將更新一個“物件-引用”二元組,通過在引用上加上“版本號”,從而避免了ABA問題。
同樣,AtomicMarkableReference將更新一個“物件引用-布林值”二元組。

JVM對CAS的支援

在Java 5.0之前,如果不編寫明確的程式碼,那麼就無法執行CAS。在Java 5.0中引入了底層的支援,在int、long和物件的引用等型別上都公開了CAS操作,並且JVM把它們編譯為底層硬體提供的最有效方法。在支援CAS的平臺上,執行時把它們編譯為相應的(多條)機器指令。在最壞情況下,如果不支援CAS指令,那麼JVM將使用自旋鎖。

在原子變數類(例如java.util.concurrent.atomic中的AtomicXxx)中使用了這些底層的JVM支援為數字型別和引用型別提供的一種高效的CAS操作,而在java.util.concurrent中的大多數類在實現時則直接或間接地使用了這些原子變數類。

原子類概覽

Java JDK併發包中提供的原子類很豐富,可以分為五個類別:標量類(基本資料類)、物件引用類、陣列類、物件屬性更新器類和累加器類。

Java提供的原子類裡面CAS一般被實現為compareAndSet(),compareAndSet()的語義和CAS指令語義的差別僅僅是返回值的不同,compareAndSet()裡面如果更新成功,則會返回true,否則返回false。

do {
    // 獲取當前值
    oldV = xxxx;
    // 根據當前值計算新值
    newV = ...oldV...
}while(!compareAndSet(oldV,newV);

原子標量類(原子基本資料類)

相關實現類有AtomicBoolean、AtomicInteger和AtomicLong,提供的方法主如下:

getAndIncrement() // 原子化 i++
getAndDecrement() // 原子化的 i--
incrementAndGet() // 原子化的 ++i
decrementAndGet() // 原子化的 --i
// 當前值 +=delta,返回 += 前的值
getAndAdd(delta)
// 當前值 +=delta,返回 += 後的值
addAndGet(delta)
//CAS 操作,返回是否成功
compareAndSet(expect, update)
// 以下四個方法
// 新值可以通過傳入 func 函式來計算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)

原子物件引用類

相關實現有AtomicReference、AtomicStampedReference和AtomicMarkableReference,利用它們可以實現物件引用的原子化更新。

AtomicReference提供的方法和原子化的基本資料型別差不多。不過,需要注意,物件引用的更新需要重點關注ABA問題,正如前面提過,AtomicStampedReference和AtomicMarkableReference這兩個原子類可以解決ABA問題。 

AtomicStampedReference實現的CAS方法增加的版本號引數,方法簽名如下:

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

AtomicMarkableReference的實現機制更簡單,將版本號簡化成了一個Boolean值,方法簽名如下:

boolean compareAndSet(
    V expectedReference,
    V newReference,
    boolean expectedMark,
    boolean newMark)

原子陣列類

相關實現有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,利用這些原子類,我們可以原子化地更新陣列中的每一個元素。

這些類提供的方法和原子化基本資料型別的區別僅僅是:每個方法多了一個數組的索引引數。

原子物件屬性更新器類

相關實現有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater。利用它們可以原子化地更新物件的屬性,這三個方法都是利用反射機制實現的,建立更新器的方法如下:

public static <U> AtomicXXXFieldUpdater<U> newUpdater(Class<U> tclass, String fieldName)

需要注意,物件屬性必須是volatile型別的,只有這樣才能保證可見性;如果物件屬性不是volatile型別的,newUpdater()方法會丟擲IllegalArgumentException這個執行時異常。

newUpdater()方法的引數中只有類的資訊沒有物件的引用,而更新物件的屬性,需要物件的引用,那麼這個引數是再哪裡傳入的呢?
是在原子類操作的方法引數中傳入的。例如,compareAndSet()這個原子操作,相比原子化的基本資料型別引數多了一個物件引用obj。

boolean compareAndSet(
    T obj,
    int expect,
    int update)

原子化物件屬性更新器相關方法,相比原子類的基本資料型別僅僅是多了物件引用引數。

原子累加器類

DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder,這四個類僅僅用來執行累加計數操作,相比原子化的基本資料型別,速度更快,但是不支援compareAndSet()方法。

在實際情況中,如果僅需一個計數器或者序列生成器,那麼可以直接使用AtomicInteger或者AtomicLong,它們能提供原子的遞增方法以及其他算術方法。

小結

原子變數比鎖的粒度更細,量級更輕。原子變數類也相當於一種泛化的volatile變數。原子變數較於鎖來說,在效能和降低活躍性方面都表現很好,但是原子變數是將發生競爭的範圍縮小到單個變數上,當需要解決多個共享變數的原子性問題,還是建議使用鎖。

參考:
[1] 極客時間專欄王寶令《Java併發程式設計實戰》
[2] Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2