像寶石一樣的Java原子類

元思發表於2020-05-27

十五年前,多處理器系統是高度專業化的系統,通常耗資數十萬美元(其中大多數具有兩到四個處理器)。
如今,多處理器系統既便宜又豐富,幾乎主流的微處理器都內建了對多處理器的支援,很多能夠支援數十或數百個處理器。
為了充分利用多處理器系統的效能,通常使用多個執行緒來構建應用程式。
但是,任何一個寫併發應用的人都會告訴你,僅僅把工作分散在多個執行緒中處理不足以充分利用硬體的效能,你必須保證你的執行緒大部分時間都在工作,而不是在等待工作,或者在等待共享資料上的鎖。

問題:執行緒之間的協作

很少有應用可以不依賴執行緒協作而實現真正的並行化。
例如一個執行緒池,其中的任務通常是彼此獨立的被執行,互不干擾。一般會使用一個工作佇列來維護這些任務,那麼從工作佇列中刪除任務或向其中新增任務的過程必須是執行緒安全的,這意味著需要協調佇列頭部、尾部、以及節點之間的連結指標。這種協調工作是麻煩的根源。

標準的處理方法:上鎖

在Java中,協調多執行緒訪問共享變數的傳統方式是同步
通過同步(synchronized關鍵字)可以保證只有持有鎖的執行緒才可以訪問共享變數,此外可以確保持有鎖的執行緒對這些變數的訪問具有獨佔訪問權,且執行緒對共享變數的改變對於其他後來的執行緒是可見的。
同步的缺點是,當鎖的競爭激烈時(多個執行緒頻繁的嘗試獲取鎖),吞吐量會受到影響,同步的代價會非常高。
基於鎖的演算法另一個問題是如果一個持有鎖的執行緒被延遲(由於page fault、排程延遲、或其他異常),那麼其他正在等待該鎖的執行緒都將無法執行。
volatile變數也可以用於儲存共享變數,其成本比synchronized要低。但是它有侷限性,雖然volatile變數的修改對其他執行緒是立即可見的,但是它無法呈現原子操作的read-modify-write操作序列,這意味著,volatile變數無法實現可靠的互斥鎖或計數器。

用鎖實現計數器和互斥體

考慮開發一個執行緒安全的計數器類,該類公開get()、increment()和decrement()操作。清單1展示了使用同步鎖實現此類。
請注意,所有方法,甚至get(),都是同步的,以保證不會丟失任何更新,並且所有執行緒都可以看到計數器的最新值。

Listing 1. A synchronized counter class
public class SynchronizedCounter {
    private int value;
    public synchronized int getValue() { return value; }
    public synchronized int increment() { return ++value; }
    public synchronized int decrement() { return --value; }
}

increment() 和 decrement()都是原子的read-modify-write操作,為了安全的遞增計數器,你必須取出當前值,然後對它加1,最後再把新值寫回。所有這些操作都將作為一個單獨的操作完成,中途不能被其他執行緒打斷。否則,如果兩個執行緒同時進行increment操作,意外的操作交錯會導致計數器只被遞增了一次,而不是兩次。(請注意,通過把變數設定為volatile,不能可靠的實現以上操作)
原子的read-modify-write組合操作出現在很多併發演算法中。下面清單2中的程式碼實現了一個簡單的互斥體(Mutex,Mutual exclusion的簡寫)。acquire()方法就是原子的read-modify-write操作。
要獲取這個互斥體,你必須確保沒有其他執行緒佔用它(curOwner==null),成功獲取後標識你已經持有該鎖(curOwner = Thread.currentThread()),這樣其他執行緒就不可能再進入並修改curOwner變數。

Listing 2. A synchronized mutex class
public class SynchronizedMutex {
    private Thread curOwner = null; 
    public synchronized void acquire() throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        while (curOwner != null) 
            wait();
        curOwner = Thread.currentThread();
    }
    public synchronized void release() {
        if (curOwner == Thread.currentThread()) {
            curOwner = null;
            notify();
        } else
            throw new IllegalStateException("not owner of mutex");
    }
}

清單1中的計數器類在沒有競爭或競爭很少的情況下可以可靠的工作。
然而,在競爭激烈時效能將大幅下降,因為JVM將花費更多的時間處理執行緒排程以及管理競爭,而花費較少的時間進行實際工作,如增加計數器。

鎖的問題

如果一個執行緒嘗試獲取一個正在被其他執行緒佔用的鎖,該執行緒會一直阻塞直到鎖被其他執行緒釋放。
這種方式有明顯的缺點,當執行緒被阻塞時它不能做任何事情。如果被阻塞的執行緒是較高優先順序的任務,那麼後果是災難性的(這種危險被稱為優先順序倒置,priority inversion)。使用鎖還有其他一些風險,例如死鎖(當以不一致的順序獲取多個鎖時可能會發生死鎖)。
即使沒有這樣的危險,鎖也只是相對粗粒度的協調機制。因此,對於管理簡單的操作(例如計數器或互斥體)來說,鎖是相當“重”的。
如果有一個更細粒度的機制能夠可靠地管理對變數的併發更新,那將是極好的。
幸運的是,大多數現代處理器都有這種輕量級的機制。

硬體同步原語

如前所述,大多數現代處理都支援多處理器,這種支援除了基本的多個處理器共享外設和主儲存器的能力,它通常還包括對指令集的增強,以支援多處理的特殊要求。特別是,幾乎每個現代處理器都具有用於更新共享變數的指令,該指令可以檢測或阻止來自其他處理器的併發訪問。

Compare and swap (CAS)

第一批支援併發的處理器提供了原子的test-and-set操作,這些操作通常在一個bit上進行。但是當前主流的處理器(包括Intel和Sparc處理器)最常用的方法是實現一個被稱為compare-and-swap(CAS)的原語。(在Intel處理器上,CAS是由cmpxchg指令系列實現的。PowerPC處理器有一對"load and reserve" 和 "store conditional"的指令達到同樣的效果)
CAS包括三個操作物件-記憶體位置(V)預期的舊值(A)新的值(B)
如果該位置的值V與預期的舊值A匹配,則處理器將原子地將該位置更新為新值B,否則它將不執行任何操作。
無論哪種情況,它都會返回CAS指令之前該位置的值V。 (CAS的某些版本會簡單地返回CAS是否成功,而不獲取當前值。)
CAS表示:“我認為位置V應該有值A;如果有,則將B放入其中,否則,不要改變它,但要告訴我現在有什麼值。”
CAS通常的使用方法是,從地址V讀取值A,然後對A執行多次計算得到新值B,最後使用CAS指令將位置V的值從A變為B。
如果該位置V同時沒有被其他處理器更新,那麼CAS就會成功。
像CAS這樣的指令允許程式執行 read-modify-write序列,而不必擔心同時有另一個執行緒修改變數,因為如果另一個執行緒確實修改了變數,則CAS會檢測到該變數(並失敗),並且程式可以重試該操作。
清單3,通過synchronized模擬了CAS的內部邏輯。(不包括效能模擬,也沒辦法模擬,因為CAS的價值就在於它是在硬體中實現的,非常輕量級。)

Listing 3. the behavior (but not performance) of compare-and-swap
public class SimulatedCAS {
     private int value;
     public synchronized int getValue() { return value; }
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
         int oldValue = value;
         if (value == expectedValue)
             value = newValue;
         return oldValue;
     }
}

使用CAS實現計數器

基於CAS的併發演算法稱為lock-free,因為執行緒不必等待鎖(有時稱為互斥體或臨界區,術語因實現平臺而異)。
無論CAS操作成功還是失敗,它都可以在預期的時間內完成。如果CAS失敗,則呼叫者可以重試CAS操作或採取其他合適措施。
清單4中使用CAS重寫了計數器類:

Listing 4. Implementing a counter with compare-and-swap
public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
     public int increment() {
        int oldValue = value.getValue();
        while (value.compareAndSwap(oldValue, oldValue + 1)  !=  oldValue){ //不斷重試
            oldValue = value.getValue();
        }
        return oldValue + 1;
    }
}

Lock-free和 wait-free 演算法

wait-free演算法保證每個執行緒都在執行(make progress)。相反的,lock-free演算法要求至少有一個執行緒能取得進展(make progress)。
可見,wait-freelock-free的要求更苛刻。
在過去的15年中,人們對wait-free和lock-free演算法(也稱為非阻塞演算法)進行了大量研究,並且發現了許多常見資料結構的非阻塞演算法實現。
非阻塞演算法在作業系統和JVM級別廣泛用於諸如執行緒和程式排程之類的任務。
儘管實現起來較為複雜,但與基於鎖的替代方法相比,它們具有許多優點,
避免了諸如優先順序反轉死鎖之類的危險,競爭成本更低,並且協調發生在更細粒度級別,從而實現了更高程度的並行性。

原子變數類

在JDK5之前,要實現wait-free、lock-free的演算法必須通過native方法。但是,在JDK5中增加了java.util.concurrent.atomic原子包後,情況發生了變化。
atomic包提供了多種原子變數類(AtomicInteger; AtomicLong; AtomicReference; AtomicBoolean等)。
原子變數類都暴露了一個compare-and-set原語(類似compare-and-swap),它使用了平臺上可用的最快的原生結構,具體實現方案因平臺而異(可能是compare-and-swap, load linked/store conditional, 或者最壞的情況使用 spin locks)。
可以將原子變數類視為volatile變數的泛化,它擴充套件了volatile變數的概念以支援原子的compare-and-set更新。
原子變數的讀寫與volatile變數的讀寫具有相同的記憶體語義。
儘管原子變數類或許看起來像清單1中的示例,但是他們的相似只是表面上的。在幕後,對原子變數的操作變成了平臺提供的硬體原語,例如compare-and-swap。

細粒度意味著更輕量

優化併發應用的一個常用技術是減少鎖物件的粒度,可以讓更多的鎖獲取從競爭的變成非競爭的。
把鎖變成原子變數也達到了同樣的效果,通過切換到更小粒度的協調機制,減少有競爭的操作,以提升系統吞吐量。

java.util.concurrent包中的原子變數

juc包中幾乎所有的類都直接或間接的使用了原子變數,而不是synchronized。例如ConcurrentLinkedQueue類直接使用原子變數類實現了wait-free演算法,
再比如ConcurrentHashMap類在需要的地方使用ReentrantLock上鎖,而ReentrantLock使用原子變數類維護等待鎖的執行緒佇列。
如果沒有JDK5的改進,這些類就無法實現,JDK5暴露了一個介面讓類庫可以使用硬體級的同步原語。而原子變數類以及juc中的其他類又把這些特性暴露給了使用者類。

使用原子變數實現更高的吞吐量

清單5中分別使用同步和CAS實現了偽隨機數生成器(PRNG)。要注意的是CAS必須在迴圈中執行,因為它在成功之前可能會失敗一次或多次,這幾乎是CAS的使用正規化。

Listing 5. Implementing a thread-safe PRNG with synchronization and atomic variables
public class PseudoRandomUsingSynch implements PseudoRandom {
    private int seed;
    public PseudoRandomUsingSynch(int s) { seed = s; }
    public synchronized int nextInt(int n) {
        int s = seed;
        seed = Util.calculateNext(seed);
        return s % n;
    }
}
public class PseudoRandomUsingAtomic implements PseudoRandom {
    private final AtomicInteger seed;
    public PseudoRandomUsingAtomic(int s) {
        seed = new AtomicInteger(s);
    }
    public int nextInt(int n) {
        for (;;) {
            int s = seed.get();
            int nexts = Util.calculateNext(s);
            if (seed.compareAndSet(s, nexts))
                return s % n;
        }
    }
}

下面的兩張圖分別顯示了在8路Ultrasparc3和單核的Pentium 4上的執行緒數與隨機數生成器的吞吐量關係。
你會看到,原子變數(ATOMIC曲線)相對於ReentrantLock(LOCK曲線)有了進一步改進,後者相比同步(SYNC曲線)已經取得了很大改進。
由於每個工作單元的工作量很少,因此下面的圖形可能低估了原子變數與ReentrantLock相比在伸縮性方便的優勢。

大多數使用者不大可能使用原子變數自己實現非阻塞演算法,他們更應該使用java.util.concurrent中提供的版本,例如ConcurrentLinkedQueue。
如果你想知道與之前的JDK中的類相比juc中的類的效能提升來自何處?那就是使用了原子變數類開放的更細粒度、硬體級併發原語。
另外,開發人員可以直接將原子變數用作共享計數器、序列號生成器以及其他獨立共享變數的高效能替代品,否則必須通過同步來保護它們。

總結

JDK 5.0在高效能併發的開發上邁出了一大步。它在內部暴露新的低層協調原語,並提供了一組公共的原子變數類。現在,你可以使用Java語言開發第一個wait-free,lock-free的演算法了。
不過,java.util.concurrent中的類都是基於這些原子變數工具構建的,與之前類似功能的類相比,在效能上有了質的飛躍,你可以直接使用他們。
儘管你可能永遠不會直接使用原子變數,但是他們仍然值得我們為其歡呼。

譯者注

這篇文章是Brian Goetz發表於2004年,即JDK5剛剛釋出之後,作為Java佈道者第一時間對JDK5的新特性做了很透徹的說明。
Brian Goetz是Java語言的架構師,是Lambda專案的主導者,也是《Java Concurrency in Practice》作者。

參考:

  1. 原文: https://www.ibm.com/developerworks/library/j-jtp11234/index.html
  2. More flexible, scalable locking in JDK 5.0
    https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html
  3. No-Blocking algorythm 維基百科: https://en.m.wikipedia.org/wiki/Non-blocking_algorithm
  4. wait-free和lock-free: https://www.zhihu.com/question/295904223

相關文章