Java鎖與非阻塞演算法的效能比較與分析+原子變數類的應用

xuxh120發表於2021-12-28

15.原子變數與非阻塞同步機制

在java.util.concurrent包中的許多類,比如Semaphore和ConcurrentLinkedQueue,都提供了比使用Synchronized更好的效能和可伸縮性.本部分將介紹這種效能提升的利器:原子變數和非阻塞的同步機制.

 

近年來很多關於併發演算法的研究都聚焦在非阻塞演算法(nonblocking algorithms),這種演算法使用底層的原子機器指令取代鎖,比如比較並交換(compare-and-swap),從而保證資料在併發訪問下的一致性.非阻塞演算法廣泛應用於作業系統和JVM中的執行緒和程式排程、垃圾回收以及實現鎖和其他的併發資料結構。與基於鎖的方案相比,非阻塞演算法的設計和實現都要復雜得多,但是他們在可伸縮性和活躍度上佔有很大的優勢。因為非阻塞演算法可以讓多個執行緒在競爭相同資源時不會發生阻塞,所以它能在更精化的層面上調整粒度,並且極大的減少排程的開銷。

並且,在非阻塞演算法中埔村砸死鎖或其他活躍性問題。在基於鎖的演算法中,如果一個執行緒在持有鎖的時候休眠,或者停滯不前,那麼其他執行緒都無法執行下去,而非阻塞演算法不會受到單個執行緒失敗的影響.在Java 5.0中,使用原子變數(atomic variable classes),比如AtomicInteger和AtomicReference,能夠高效地構建非阻塞演算法.

 

即使你不使用原子變數開發阻塞演算法,它也可以當做更優的volatile變數來使用.原子變數提供了與volatile型別變數相同的記憶體語義,同時還支援原子更新--使它們能更加理想地用於計數器、序列發生器和統計資料收集等,另外也比基於鎖的方案具有更加出色的可伸縮性.

  

15.1 鎖的劣勢

通過使用一致的加鎖協議來協調對共享狀態的訪問,確保無論哪個執行緒持有守護變數的鎖,他們都能獨佔地訪問這些變數,並且對變數的任何修改對其他隨後獲得同一鎖的執行緒都是可見的.

 

現代JVM能夠對非競爭鎖的獲取和釋放進行優化,讓它們非常高效,但是如果有多個執行緒同時請求鎖JVM就需要向作業系統尋求幫助。但如果出現了這種情況,一些執行緒將會掛起,並在稍後恢復執行。當執行緒恢復執行時,必須等待其它執行緒執行完他們的時間片以後才能被排程執行。線上程的掛起和恢復等過程中存在著很大的開銷,並通常存在較長時間的中斷。JVM並不一定會掛起執行緒)。

如果基於鎖的類中包含細粒度的操作(比如同步容器類,大多數方法只包含很少的操作),那麼在鎖上存在激烈的的鎖競爭時,排程開銷與工作開銷的比值會非常高。

 

與鎖相比,volatile變數與鎖相比是更輕量的同步機制,因為它們不會引起上下文的切換和執行緒排程等操作。然而,volatile變數與鎖相比有一些侷限性:儘管他們提供了相似的可見性保證,但是它們不能用於構建原子化的複合操作。這意味著當一個變數依賴其他變數時,或者當變數的新值依賴於舊值時,是不能用volatile變數的.這些都限制了volatile變數的使用,因此它們不能用於實現可靠的通用工具,比如計數器.

 

加鎖還有其他的缺點.當一個執行緒正在等待鎖時,它不能做任何其他事情。如果一個執行緒在持有鎖的情況下發生了延(原因包括頁錯誤、排程延遲、或者類似情況),那麼其他所有需要該鎖的執行緒都不能執行下去。如果阻塞的執行緒是優先順序很高的執行緒,持有鎖的執行緒優先順序較低,那麼會造成嚴重問題--效能風險,被稱為優先順序反轉(priority inversion)。即使更高的優先順序佔先,它仍然需要等待鎖被釋放,這導致它的優先順序會降至與優先順序較低的執行緒相同的水平。如果持有鎖的執行緒發生了永久性的阻塞(因為無限迴圈、死鎖、活鎖和其它活躍度失敗),所有等待該鎖的執行緒永遠都不能執行下去。

 

即使忽略些的風險,加鎖對於細分的操作而言,仍是一種高開銷的機制,比如遞增計數器.在管理執行緒之間的競爭時,應該有一種力度更細的技術,類似於volatile變數的機制,但同時還要支援原子更新操作。幸運的是,現代處理器為我們提供了這樣的機制。

 

15.2 硬體對併發的支援

獨佔鎖是一種悲觀技術——它假設最壞(如果你不鎖門,那麼搗蛋鬼就會闖入並搞得一團遭)的情況,並且只有在確保其他執行緒不會造成干擾(通過獲取正確的鎖)的情況下才能執行下去。

 

對於細粒度的操作,還有一種更高效的方法,也是一種樂觀的方法,通過這種方法可以在不發生干擾的情況下完成更新操作。這種方法需要藉助衝突檢測機制來判斷在更新過程中是否存在來自其他的執行緒干擾,如果存在這個操作將失敗,並且可以選擇重試或者不重試。

 

在針對多處理器操作而設計的處理器中提供了一些特殊指令,用於管理對共享資料的併發訪問。現在,幾乎所有的現代處理器都包含了某種形式的原子讀-改-寫指令,如比較並交換(Compare-and-Swap)或者關聯載入/條件儲存(Load-Linked/Store-Conditional)。作業系統和JVM使用這些指令來實現鎖和併發的資料結構,但在Java 5.0之前,在Java類中還不能直接使用這些指令。

 

2.1 比較並交換 CAS

在大多數處理器架構中採用的方法是實現一種比較並交換指令。CAS包含3個運算元——需要讀寫的記憶體位置V、進行比較的值A和擬寫入的新值B。當且僅當V的值等於A時,CAS才會通過原子的方式用新值B來更新V的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。CAS的含義是:“我認為V的值應該是A,如果是,那麼將V的值更新成B,否則不修改並告訴V的值實際是多少”。CAS是一項樂觀技術,它希望能成功地執行操作,並且如果有另一個執行緒在最近一次檢查後更新了該變數,那麼CAS能檢測到這個錯誤。程式清單 15-1說明了CAS的語義。

程式清單 15-1 模擬CAS操作
@ThreadSafepublic
class SimulatedCAS {
    @GuardedBy("this")
    private int value;
 
    public synchronized int get() {
        return value;
    }
 
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue)
            value = newValue;
        return oldValue;
    }
 
    public synchronized boolean compareAndSet(int expectedValue, int newValue) {
        return (expectedValue == compareAndSwap(expectedValue, newValue));
    }
}

當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其他執行緒都將失敗。然而,失敗的執行緒並不會被掛起(注意,這與鎖不同),而是被告知在這次競爭中失敗,並可再次嘗試。由於一個執行緒在競爭CAS時失敗不會阻塞,因此它可以決定是否重新嘗試,或者執行一些恢復操作,或者不執行任何操作。這種靈活性大大減少了與鎖相關的活躍性風險。

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

 

2.2 非阻塞的計數器

程式清單 15-2 基於CAS實現了一個執行緒安全的計數器。自增操作遵循了經典形式—取得舊值,根據它計算出新值(加1),並使用CAS設定新值。如果CAS失敗,立即重試操作.儘管在競爭十分激烈的情況下,更希望等待或者回退,以避免重試造成的活鎖,但是,通常反覆重試都是合理的策略。但在一些競爭很激烈的情況下,更好的方式時在重試之前首先等待一段時間或者回退,從而避免造成或鎖問題。

程式清單 15-2 基於CAS實現的非阻塞計數器
@ThreadSafepublic
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;
    }
}

初看起來,雖然Java語言的鎖定語法比較簡潔,基於CAS的計數器看起來也比基於鎖的計數器效能差一些: 它具有更多的操作和更復雜的控制流,表面看來還依賴於復雜的CAS的操作。但是,實際上基於CAS的計數器,效能上遠遠勝過了基於鎖的計數器,而在沒有競爭時甚至更高。原因有二:

 

i。JVM和OS管理鎖的工作卻並不簡單.加鎖需要遍歷JVM中整個復雜的程式碼路徑,並可能引起系統級的加鎖、執行緒掛起以及上下文切換。在最優的情況下,加鎖需要至少一個CAS,所以使用鎖時沒有用到CAS,但實際上也不能節省任何執行開銷。

ii。另一方面,程式內部執行CAS不會呼叫到JVM的程式碼、系統呼叫或者排程活動。在應用級看起來越長的程式碼路徑,在考慮到JVM和OS的時候,事實上會變成更短的程式碼。

一個很管用的經驗法則是,在大多數處理器上,在無競爭的鎖獲取和釋放上的開銷,大約是CAS開銷的兩倍。

 

CAS最大的缺點:它強迫呼叫者處理競爭問題(通過重試、回退,或者放棄);然而在鎖中可以通過阻塞自動處理競爭問題,CAS最大的缺陷在於難以正確地構建外圍演算法。

 

2.3 JVMCAS的支援

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

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

 

15.3 原子變數類

原子變數比鎖的粒度更細,量級更輕,並且對於在多處理器系統上實現高效能的併發程式碼來說是非常關鍵的。原子變數將發生競爭的範圍縮小到單個變數上,這是獲得粒度最細的情況。更新原子變數的快速(非競爭)路徑,並不會比獲取鎖的快速路徑差,並且通常會更快;而慢速路徑肯定比鎖的慢速路徑快,因為它不會引起執行緒的掛起和重新排程。在使用基於原子變數而非鎖的演算法中,執行緒在執行時更不易出現延遲,並且如果遇到競爭,也更容易恢復過來。

 

原子變數類相當於一個泛化的volatile變數,能夠支援原子的和有條件的讀-改-寫操作。以AtomicInteger為例,該原子類表示一個int型別的值,並提供get和set方法,這些volatile型別的int變數在讀取和寫入上有著相同的語義。它還提供了一原子的compareAndSet方法,以及原子的新增、遞增和遞減等方法。AtomicInteger在發生競爭的情況下能提供更高的可伸縮性,因為它直接利用了硬體對併發的支援。

 

共有12個原子變數類,可分為四組:標量類(Scalar)、更新器類、陣列類及複合變數類。最常用的原子變數就是標量類:AtomicInteger、AtomicLong和AtomicBoolean以及AtomicReference。所有這些類都支援CAS,此外AtomicInteger和AtomicLong還支援算術運算。

原子陣列類中的元素可以實現原子更新。原子陣列類為陣列的元素提供了volatile型別的訪問語義,這是普通陣列所不具備的特性。volatile型別的陣列僅在陣列引用上具有volatile語義,而在其元素上沒有。

 

儘管原子的標量類擴充套件了Number類,但並沒有擴充套件一些基本的包裝類,這是因為:基本型別的包裝類是不可修改的,而原子變數類是可修改的。在原子變數類中同樣沒有重新定義hashCode或equals方法,每個例項都是不同的。與其他可變物件相同,他們也不宜用做基於雜湊的容器中的鍵值對。

  

3.1 效能比較:鎖與原子變數

為了說明鎖和原子變數之間的可伸縮性差異,我們構造了一個測試基準,其中將比較偽隨機數生成器(PRNG)的幾種不同的實現,在PRNG中,在生成一個隨機數時需要用到一個數字,所以在PRNG中必須記錄前一個數值並將其作為狀態的一部分。

在程式清單15-4和15-5中給出了執行緒安全的PRNG的兩種實現,一種使用ReentrantLock,另一種使用AtomicInteger。測試程式反覆呼叫他們,在每次迭代中將隨機生成一個數字,並執行一些僅線上程本地資料上執行的“繁忙”迭代。這是一種典型的操作模式,以及在一些共享狀態以及執行緒本地狀態上的操作。

程式清單 15-4  基於ReentrantLock實現的隨機數生成器
@ThreadSafe
public class ReentrantLockPseudoRandom {
   
    private final Lock lock = new ReentrantLock();
    
    private int seed;
 
    public ReentrantLockPseudoRandom(int seed) {
        this.seed = seed;
    }
 
    public int nextInt(int m) {
        lock.lock();
        try {
            int s = seed;
            seed = calculateNext(s);
            int remainder = s % n;
            return remainder > 0 ? remainder : remainder + n;
        } finally {
            lock.unlock();
        }
    }
}

 

程式清單 15-5  基於AtomicInteger實現的隨機數生成器
@ThreadSafe
public class AtomicPseudoRandom {
    private AtomicInteger seed;
 
    public AtomicPseudoRandom(int seed) {
        this.seed = new AtomicInteger(seed);
    }
 
    public int nextInt(int m) {
       while (true) {
           int s = seed;
           int nextSeed = calculateNext(s);
           if (seed.compareAndSet(s, nextSeed)) {
               int remainder = s % n;
               return remainder > 0 ? remainder : remainder + n;
           }
       }
    }
}

15-1和圖15-2給出了在媒體迭代中工作量較低以及適中情況下的吞吐量。如果執行緒本地的計算較少,那麼在鎖和原子變數上的競爭將非常激烈。如果執行緒本地的計算量較多,那麼在鎖和原子變數上的競爭就會降低,因為線上程中訪問鎖和原子變數的頻率將降低。

從圖中可以看出,在高度競爭的情況下,鎖的效能將超過原子變數的效能。原因是,使用原子變數時,CAS演算法在遇到競爭時將立即重試,通常這是一種正確的方法,但是在競爭激烈的環境下卻導致了更多的競爭。

而在競爭適中的情況下,原子變數的效能將遠超過鎖的效能,這是因為鎖在發生競爭時會掛起執行緒,從而降低了CPU的使用率和共享記憶體匯流排上的同步通訊量。

注意,我們應該意識到,圖15-1中的競爭級別過高而有些不切實際:任何一個真實程式都不會出了競爭鎖或原子變數,其他設麼工作都不做。

 

 

 

 

鎖與原子變數在不同競爭程度上的效能差異很好的說明了各自的優勢和劣勢。在中低程度的競爭下,原子變數能提供更高的可伸縮性,而在高強度的競爭下,鎖能更有效的避免競爭。

在圖15-1和圖15-2中都包含了第三掉曲線,他是一個使用ThreadLocal來儲存PRNG狀態的PseudoRandom。這種實現方法改變類的行為,即每個執行緒都只能看到自己私有的,而不是共享的偽隨機數字序列,這說明了能夠避免使用共享狀態,開銷將會更小。

 

15.4 非阻塞演算法

基於鎖的演算法會帶來一些活躍度的風險. 如果執行緒在持有鎖的時候因為阻塞I/O,頁面錯誤,或其他原因發生延,很可能所有執行緒都不能繼續執行下去。如果在某種演算法中,一個執行緒的失敗或掛起不應該影響其他執行緒的失敗或掛起,這樣的演算法被稱為非阻塞(nonblocking)演算法。如果在演算法的每一步驟中都有一些執行緒能夠繼續執行,那麼這樣的演算法稱為無鎖(lock-free)演算法。

如果在演算法中僅將CAS用於協調執行緒之間的操作,並且能構建正確的話,那麼它既是非阻塞的,又是無鎖的。

 

在非阻塞演算法中,通常不會出現死鎖和優先順序反轉問題(但可能會出現飢餓和活鎖,因為他們允許重進入)。在許多常見的資料結構中都可以使用非阻塞演算法,包括棧、佇列、優先順序佇列以及雜湊表等,而要設計一些新的這種資料結構,最好還是由專家們來完成。

 

15.5 ABA問題

ABA問題是一種異常現象:如果在演算法中的節點可以被迴圈使用,那麼使用“比較並交換”指令就可能會出現這種問題。在某些演算法中,如果V的值首先由A變成B,再由B變成A,那麼仍然被認為是發生了變化,並需要重新執行演算法中的某些步驟。

 

如果在演算法中採用自己的方式來管理節點物件的記憶體,那麼可能出現ABA問題。一種相對簡單的解決方案是:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值由A變為B,然後又變為A,版本號也將是不同的。

 

AtomicStampedReference(以及AtomicMarkableReference)支援在兩個變數上執行原子的條件更新。AtomicStampedReference將更新一個“物件-引用”二元組,通過在引用上加上“版本號”,從而避免ABA問題。類似的,AtomicMarkableReference將更新一個“物件引用-布林值”二元組,,在某些演算法中將通過它將節點儲存在連結串列中同時又將其標記為“已刪除的節點”

 

小結

非阻塞演算法通過底層的併發原語來保證執行緒的安全性,如CAS比較交換而不是使用鎖。這些底層原語通過原子變數類向外公開,這些類也用做一種“更好的volatile變數”,從而為整數和物件引用提供原子的更新操作。

非阻塞演算法在設計和實現中很困難,但是通常情況下能夠提供更好的可伸縮性,並能更好地預防活躍度失敗。從JVM的一個版本到下一個版本間併發性的提升很大程度上來源於非阻塞演算法的使用,包括在JVM內部以及平臺類庫.

 

 


瞭解更多知識,關注我。  ???

相關文章