Java併發指南3:併發三大問題與volatile關鍵字,CAS操作

Java技術江湖發表於2019-11-08

本文轉載自網際網路,侵刪

本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫裡檢視

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star哈

文章同步發於我的個人部落格:

www.how2playlife.com

本文是微信公眾號【Java技術江湖】的《Java併發指南》其中一篇,本文大部分內容來源於網路,為了把本文主題講得清晰透徹,也整合了很多我認為不錯的技術部落格內容,引用其中了一些比較好的部落格文章,如有侵權,請聯絡作者。

該系列博文會告訴你如何全面深入地學習Java併發技術,從Java多執行緒基礎,再到併發程式設計的基礎知識,從Java併發包的入門和實戰,再到JUC的原始碼剖析,一步步地學習Java併發程式設計,並上手進行實戰,以便讓你更完整地瞭解整個Java併發程式設計知識體系,形成自己的知識框架。

為了更好地總結和檢驗你的學習成果,本系列文章也會提供一些對應的面試題以及參考答案。

如果對本系列文章有什麼建議,或者是有什麼疑問的話,也可以關注公眾號【Java技術江湖】聯絡作者,歡迎你參與本系列博文的創作和修訂。

序言

先來看如下這個簡單的Java類,該類中並沒有使用任何的同步。

final class SetCheck {
    private int a = 0;
    private long b = 0;
    void set() {
        a = 1;
        b = -1;
    }
    boolean check() {
        return ((b == 0) || (b == -1 && a == 1));
    }
}

如果是在一個序列執行的語言中,執行SetCheck類中的check方法永遠不會返回false,即使編譯器,執行時和計算機硬體並沒有按照你所期望的邏輯來處理這段程式,該方法依然不會返回false。在程式執行過程中,下面這些你所不能預料的行為都是可能發生的:

  • 編譯器可能會進行指令重排序,所以b變數的賦值操作可能先於a變數。如果是一個內聯方法,編譯器可能更甚一步將該方法的指令與其他語句進行重排序。
  • 處理器可能會對語句所對應的機器指令進行重排序之後再執行,甚至併發地去執行。
  • 記憶體系統(由快取記憶體控制單元組成)可能會對變數所對應的記憶體單元的寫操作指令進行重排序。重排之後的寫操作可能會對其他的計算/記憶體操作造成覆蓋。
  • 編譯器,處理器以及記憶體系統可能會讓兩條語句的機器指令交錯。比如在32位機器上,b變數的高位位元組先被寫入,然後是a變數,緊接著才會是b變數的低位位元組。
  • 編譯器,處理器以及記憶體系統可能會導致代表兩個變數的記憶體單元在(如果有的話)連續的check呼叫(如果有的話)之後的某個時刻才更新,而以這種方式儲存相應的值(如在CPU暫存器中)仍會得到預期的結果(check永遠不會返回false)。

在序列執行的語言中,只要程式執行遵循類似序列的語義,如上幾種行為就不會有任何的影響。在一段簡單的程式碼塊中,序列執行程式不會依賴於程式碼的內部執行細節,因此如上的幾種行為可以隨意控制程式碼。

這樣就為編譯器和計算機硬體提供了基本的靈活性。基於此,在過去的數十年內很多技術(CPU的流水線操作,多級快取,讀寫平衡,暫存器分配等等)應運而生,為計算機處理速度的大幅提升奠定了基礎。這些操作的類似序列執行的特性可以讓開發人員無須知道其內部發生了什麼。對於開發人員來說,如果不建立自己的執行緒,那麼這些行為也不會對其產生任何的影響。

然而這些情況在併發程式設計中就完全不一樣了,上面的程式碼在併發過程中,當一個執行緒呼叫check方法的時候完全有可能另一個執行緒正在執行set方法,這種情況下check方法就會將上面提到的優化操作過程暴露出來。

如果上述任意一個操作發生,那麼check方法就有可能返回false。例如,check方法讀取long型別的變數b的時候可能得到的既不是0也不是-1.而是一個被寫入一半的值。另一種情況,set方法中的語句的亂序執行有可能導致check方法讀取變數b的值的時候是-1,然而讀取變數a時卻依然是0。

換句話說,不僅是併發執行會導致問題,而且在一些優化操作(比如指令重排序)進行之後也會導致程式碼執行結果和原始碼中的邏輯有所出入。由於編譯器和執行時技術的日趨成熟以及多處理器的逐漸普及,這種現象就變得越來越普遍。

對於那些一直從事序列程式設計背景的開發人員(其實,基本上所有的程式設計師)來說,這可能會導致令人詫異的結果,而這些結果可能從沒在序列程式設計中出現過。這可能就是那些微妙難解的併發程式設計錯誤的根本源頭吧。

在絕大部分的情況下,有一個很簡單易行的方法來避免那些在複雜的併發程式中因程式碼執行優化導致的問題:使用同步。例如,如果SetCheck類中所有的方法都被宣告為synchronized,那麼你就可以確保那麼內部處理細節都不會影響程式碼預期的結果了。

但是在有些情況下你卻不能或者不想去使用同步,抑或著你需要推斷別人未使用同步的程式碼。在這些情況下你只能依賴Java記憶體模型所闡述的結果語義所提供的最小保證。Java記憶體模型允許上面提到的所有操作,但是限制了它們在執行語義上潛在的結果,此外還提出了一些技術讓程式設計師可以用來控制這些語義的某些方面。

Java記憶體模型是Java語言規範的一部分,主要在JLS的第17章節介紹。這裡,我們只是討論一些基本的動機,屬性以及模型的程式一致性。這裡對JLS第一版中所缺少的部分進行了澄清。

我們假設Java記憶體模型可以被看作在1.2.4中描述的那種標準的SMP機器的理想化模型。

(1.2.4)

在這個模型中,每一個執行緒都可以被看作為執行在不同的CPU上,然而即使是在多處理器上,這種情況也是很罕見的。但是實際上,通過模型所具備的某些特性,這種CPU和執行緒單一對映能夠通過一些合理的方法去實現。例如,因為CPU的暫存器不能被另一個CPU直接訪問,這種模型必須考慮到某個執行緒無法得知被另一個執行緒操作變數的值的情況。這種情況不僅僅存在於多處理器環境上,在單核CPU環境裡,因為編譯器和處理器的不可預測的行為也可能導致同樣的情況。

Java記憶體模型沒有具體講述前面討論的執行策略是由編譯器,CPU,快取控制器還是其它機制促成的。甚至沒有用開發人員所熟悉的類,物件及方法來討論。取而代之,Java記憶體模型中僅僅定義了執行緒和記憶體之間那種抽象的關係。眾所周知,每個執行緒都擁有自己的工作儲存單元(快取和暫存器的抽象)來儲存執行緒當前使用的變數的值。Java記憶體模型僅僅保證了程式碼指令與變數操作的有序性,大多數規則都只是指出什麼時候變數值應該在記憶體和執行緒工作記憶體之間傳輸。這些規則主要是為了解決如下三個相互牽連的問題:

  1. 原子性:哪些指令必須是不可分割的。在Java記憶體模型中,這些規則需宣告僅適用於-—例項變數和靜態變數,也包括陣列元素,但不包括方法中的區域性變數-—的記憶體單元的簡單讀寫操作。
  2. 可見性:在哪些情況下,一個執行緒執行的結果對另一個執行緒是可見的。這裡需要關心的結果有,寫入的欄位以及讀取這個欄位所看到的值。
  3. 有序性:在什麼情況下,某個執行緒的操作結果對其它執行緒來看是無序的。最主要的亂序執行問題主要表現在讀寫操作和賦值語句的相互執行順序上。

原子性

當正確的使用了同步,上面屬性都會具有一個簡單的特性:一個同步方法或者程式碼塊中所做的修改對於使用了同一個鎖的同步方法或程式碼塊都具有原子性和可見性。同步方法或程式碼塊之間的執行過程都會和程式碼指定的執行順序保持一致。即使程式碼塊內部指令也許是亂序執行的,也不會對使用了同步的其它執行緒造成任何影響。

當沒有使用同步或者使用的不一致的時候,情況就會變得複雜。Java記憶體模型所提供的保障要比大多數開發人員所期望的弱,也遠不及目前業界所實現的任意一款Java虛擬機器。這樣,開發人員就必須負起額外的義務去保證物件的一致性關係:物件間若有能被多個執行緒看到的某種恆定關係,所有依賴這種關係的執行緒就必須一直維持這種關係,而不僅僅由執行狀態修改的執行緒來維持。

除了long型欄位和double型欄位外,java記憶體模型確保訪問任意型別欄位所對應的記憶體單元都是原子的。這包括引用其它物件的引用型別的欄位。此外,volatile long 和volatile double也具有原子性 。(雖然java記憶體模型不保證non-volatile long 和 non-volatile double的原子性,當然它們在某些場合也具有原子性。)(譯註:non-volatile long在64位JVM,OS,CPU下具有原子性)

當在一個表示式中使用一個non-long或者non-double型欄位時,原子性可以確保你將獲得這個欄位的初始值或者某個執行緒對這個欄位寫入之後的值;但不會是兩個或更多執行緒在同一時間對這個欄位寫入之後產生混亂的結果值(即原子性可以確保,獲取到的結果值所對應的所有bit位,全部都是由單個執行緒寫入的)。但是,如下面(譯註:指可見性章節)將要看到的,原子性不能確保你獲得的是任意執行緒寫入之後的最新值。 因此,原子性保證通常對併發程式設計的影響很小。

可見性

只有在下列情況時,一個執行緒對欄位的修改才能確保對另一個執行緒可見:

一個寫執行緒釋放一個鎖之後,另一個讀執行緒隨後獲取了同一個鎖。本質上,執行緒釋放鎖時會將強制重新整理工作記憶體中的髒資料到主記憶體中,獲取一個鎖將強制執行緒裝載(或重新裝載)欄位的值。鎖提供對一個同步方法或塊的互斥性執行,執行緒執行獲取鎖和釋放鎖時,所有對欄位的訪問的記憶體效果都是已定義的。

注意同步的雙重含義:鎖提供高階同步協議,同時線上程執行同步方法或塊時,記憶體系統(有時通過記憶體屏障指令)保證值的一致性。這說明,與順序程式設計相比較,併發程式設計與分散式程式設計更加類似。同步的第二個特性可以視為一種機制:一個執行緒在執行已同步方法時,它將傳送和/或接收其他執行緒在同步方法中對變數所做的修改。從這一點來說,使用鎖和傳送訊息僅僅是語法不同而已。

如果把一個欄位宣告為volatile型,執行緒對這個欄位寫入後,在執行後續的記憶體訪問之前,執行緒必須重新整理這個欄位且讓這個欄位對其他執行緒可見(即該欄位立即重新整理)。每次對volatile欄位的讀訪問,都要重新裝載欄位的值。

一個執行緒首次訪問一個物件的欄位,它將讀到這個欄位的初始值或被某個執行緒寫入後的值。
此外,把還未構造完成的物件的引用暴露給某個執行緒,這是一個錯誤的做法,在建構函式內部開始一個新執行緒也是危險的,特別是這個類可能被子類化時。Thread.start有如下的記憶體效果:呼叫start方法的執行緒釋放了鎖,隨後開始執行的新執行緒獲取了這個鎖。

如果在子類建構函式執行之前,可執行的超類呼叫了new Thread(this).start(),當run方法執行時,物件很可能還沒有完全初始化。同樣,如果你建立且開始一個新執行緒T,這個執行緒使用了在執行start之後才建立的一個物件X。你不能確信X的欄位值將能對執行緒T可見。除非你把所有用到X的引用的方法都同步。如果可行的話,你可以在開始T執行緒之前建立X。

執行緒終止時,所有寫過的變數值都要重新整理到主記憶體中。比如,一個執行緒使用Thread.join來終止另一個執行緒,那麼第一個執行緒肯定能看到第二個執行緒對變數值得修改。

注意,在同一個執行緒的不同方法之間傳遞物件的引用,永遠也不會出現記憶體可見性問題。

記憶體模型確保上述操作最終會發生,一個執行緒對一個特定欄位的特定更新,最終將會對其他執行緒可見,但這個“最終”可能是很長一段時間。執行緒之間沒有同步時,很難保證對欄位的值能在多執行緒之間保持一致(指寫執行緒對欄位的寫入立即能對讀執行緒可見)。

特別是,如果欄位不是volatile或沒有通過同步來訪問這個欄位,在一個迴圈中等待其他執行緒對這個欄位的寫入,這種情況總是錯誤的。

在缺乏同步的情況下,模型還允許不一致的可見性。比如,得到一個物件的一個欄位的最新值,同時得到這個物件的其他欄位的過期的值。同樣,可能讀到一個引用變數的最新值,但讀取到這個引用變數引用的物件的欄位的過期值。
不管怎樣,執行緒之間的可見性並不總是失效(指執行緒即使沒有使用同步,仍然有可能讀取到欄位的最新值),記憶體模型僅僅是允許這種失效發生而已。因此,即使多個執行緒之間沒有使用同步,也不保證一定會發生記憶體可見性問題(指執行緒讀取到過期的值),java記憶體模型僅僅是允許記憶體可見性問題發生而已。

在很多當前的JVM實現和java執行平臺中,甚至是在那些使用多處理器的JVM和平臺中,也很少出現記憶體可見性問題。共享同一個CPU的多個執行緒使用公共的快取,缺少強大的編譯器優化,以及存在強快取一致性的硬體,這些都會使執行緒更新後的值能夠立即在多執行緒之間傳遞。

這使得測試基於記憶體可見性的錯誤是不切實際的,因為這樣的錯誤極難發生。或者這種錯誤僅僅在某個你沒有使用過的平臺上發生,或僅在未來的某個平臺上發生。這些類似的解釋對於多執行緒之間的記憶體可見性問題來說非常普遍。沒有同步的併發程式會出現很多問題,包括記憶體一致性問題。

有序性

有序性規則表現在以下兩種場景: 執行緒內和執行緒間

  • 從某個執行緒的角度看方法的執行,指令會按照一種叫“序列”(as-if-serial)的方式執行,此種方式已經應用於順序程式語言。
  • 這個執行緒“觀察”到其他執行緒併發地執行非同步的程式碼時,任何程式碼都有可能交叉執行。唯一起作用的約束是:對於同步方法,同步塊以及volatile欄位的操作仍維持相對有序。

再次提醒,這些僅是最小特性的規則。具體到任何一個程式或平臺上,可能存在更嚴格的有序性規則。所以你不能依賴它們,因為即使你的程式碼遵循了這些更嚴格的規則,仍可能在不同特性的JVM上執行失敗,而且測試非常困難。

需要注意的是,執行緒內部的觀察視角被JLS [1] 中其他的語義的討論所採用。例如,算術表示式的計算線上程內看來是從左到右地執行操作(JLS 15.6章節),而這種執行效果是沒有必要被其他執行緒觀察到的。

僅當某一時刻只有一個執行緒操作變數時,執行緒內的執行表現為序列。出現上述情景,可能是因為使用了同步,互斥體 [2] 或者純屬巧合。當多執行緒同時執行在非同步的程式碼裡進行公用欄位的讀寫時,會形成一種執行模式。在這種模式下,程式碼會任意交叉執行,原子性和可見性會失效,以及產生競態條件。這時執行緒執行不再表現為序列。

儘管JLS列出了一些特定的合法和非法的重排序,如果碰到所列範圍之外的問題,會降低以下這條實踐保證 :執行結果反映了幾乎所有的重排序產生的程式碼交叉執行的情況。所以,沒必要去探究這些程式碼的有序性。

volatile關鍵字詳解:在JMM中volatile的記憶體語義是鎖

volatile的特性

當我們宣告共享變數為volatile後,對這個變數的讀/寫將會很特別。理解volatile特性的一個好方法是:把對volatile變數的單個讀/寫,看成是使用同一個監視器鎖對這些單個讀/寫操作做了同步。下面我們通過具體的示例來說明,請看下面的示例程式碼:

class VolatileFeaturesExample {
    volatile long vl = 0L; // 使用volatile宣告64位的long型變數
    public void set(long l) {
        vl = l; // 單個volatile變數的寫
    }
    public void getAndIncrement() {
        vl++; // 複合(多個)volatile變數的讀/寫
    }
    public long get() {
        return vl; // 單個volatile變數的讀
    }
}

假設有多個執行緒分別呼叫上面程式的三個方法,這個程式在語意上和下面程式等價:
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通變數

    public synchronized void set(long l) {     //對單個的普通 變數的寫用同一個監視器同步
        vl = l;
    }
    public void getAndIncrement () { //普通方法呼叫
        long temp = get();           //呼叫已同步的讀方法
        temp += 1L;                  //普通寫操作
        set(temp);                   //呼叫已同步的寫方法
    }
    public synchronized long get() { 
    //對單個的普通變數的讀用同一個監視器同步
        return vl;
    }
}

如上面示例程式所示,對一個volatile變數的單個讀/寫操作,與對一個普通變數的讀/寫操作使用同一個監視器鎖來同步,它們之間的執行效果相同。

監視器鎖的happens-before規則保證釋放監視器和獲取監視器的兩個執行緒之間的記憶體可見性,這意味著對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。

簡而言之,volatile變數自身具有下列特性:監視器鎖的語義決定了臨界區程式碼的執行具有原子性。這意味著即使是64位的long型和double型變數,只要它是volatile變數,對該變數的讀寫就將具有原子性。如果是多個volatile操作或類似於volatile++這種複合操作,這些操作整體上不具有原子性。

  • 可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。
  • 原子性:對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

volatile寫-讀建立的happens before關係

上面講的是volatile變數自身的特性,對程式設計師來說,volatile對執行緒的記憶體可見性的影響比volatile自身的特性更為重要,也更需要我們去關注。

從JSR-133開始,volatile變數的寫-讀可以實現執行緒之間的通訊。

從記憶體語義的角度來說,volatile與監視器鎖有相同的效果:volatile寫和監視器的釋放有相同的記憶體語義;volatile讀與監視器的獲取有相同的記憶體語義。

請看下面使用volatile變數的示例程式碼:
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
    a = 1; // 1
    flag = true; // 2
}
public void reader() {
    if (flag) {                //3
        int i =  a;           //4
        ……
    }
}

}

假設執行緒A執行writer()方法之後,執行緒B執行reader()方法。根據happens before規則,這個過程建立的happens before 關係可以分為兩類:

  1. 根據程式次序規則,1 happens before 2; 3 happens before 4。
  2. 根據volatile規則,2 happens before 3。
  3. 根據happens before 的傳遞性規則,1 happens before 4。

上述happens before 關係的圖形化表現形式如下:

在上圖中,每一個箭頭連結的兩個節點,代表了一個happens before 關係。黑色箭頭表示程式順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens before保證。

這裡A執行緒寫一個volatile變數後,B執行緒讀同一個volatile變數。A執行緒在寫volatile變數之前所有可見的共享變數,在B執行緒讀同一個volatile變數後,將立即變得對B執行緒可見。

volatile寫-讀的記憶體語義

volatile寫的記憶體語義如下:

  • 當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體。

以上面示例程式VolatileExample為例,假設執行緒A首先執行writer()方法,隨後執行緒B執行reader()方法,初始時兩個執行緒的本地記憶體中的flag和a都是初始狀態。下圖是執行緒A執行volatile寫後,共享變數的狀態示意圖:

如上圖所示,執行緒A在寫flag變數後,本地記憶體A中被執行緒A更新過的兩個共享變數的值被重新整理到主記憶體中。此時,本地記憶體A和主記憶體中的共享變數的值是一致的。

volatile讀的記憶體語義如下:

  • 當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

下面是執行緒B讀同一個volatile變數後,共享變數的狀態示意圖:

如上圖所示,在讀flag變數後,本地記憶體B已經被置為無效。此時,執行緒B必須從主記憶體中讀取共享變數。執行緒B的讀取操作將導致本地記憶體B與主記憶體中的共享變數的值也變成一致的了。

如果我們把volatile寫和volatile讀這兩個步驟綜合起來看的話,在讀執行緒B讀一個volatile變數後,寫執行緒A在寫這個volatile變數之前所有可見的共享變數的值都將立即變得對讀執行緒B可見。

下面對volatile寫和volatile讀的記憶體語義做個總結:

  • 執行緒A寫一個volatile變數,實質上是執行緒A向接下來將要讀這個volatile變數的某個執行緒發出了(其對共享變數所在修改的)訊息。
  • 執行緒B讀一個volatile變數,實質上是執行緒B接收了之前某個執行緒發出的(在寫這個volatile變數之前對共享變數所做修改的)訊息。
  • 執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。

volatile記憶體語義的實現

下面,讓我們來看看JMM如何實現volatile寫/讀的記憶體語義。

前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現volatile記憶體語義,JMM會分別限制這兩種型別的重排序型別。下面是JMM針對編譯器制定的volatile重排序規則表:

是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 NO
volatile讀 NO NO NO
volatile寫 NO NO

舉例來說,第三行最後一個單元格的意思是:在程式順序中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。

從上表我們可以看出:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadStore屏障。

上述記憶體屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程式中都能得到正確的volatile記憶體語義。

下面是保守策略下,volatile寫插入記憶體屏障後生成的指令序列示意圖:

上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前重新整理到主記憶體。

這裡比較有意思的是volatile寫後面的StoreLoad屏障。這個屏障的作用是避免volatile寫與後面可能有的volatile讀/寫操作重排序。因為編譯器常常無法準確判斷在一個volatile寫的後面,是否需要插入一個StoreLoad屏障(比如,一個volatile寫之後方法立即return)。為了保證能正確實現volatile的記憶體語義,JMM在這裡採取了保守策略:在每個volatile寫的後面或在每個volatile讀的前面插入一個StoreLoad屏障。從整體執行效率的角度考慮,JMM選擇了在每個volatile寫的後面插入一個StoreLoad屏障。因為volatile寫-讀記憶體語義的常見使用模式是:一個寫執行緒寫volatile變數,多個讀執行緒讀同一個volatile變數。當讀執行緒的數量大大超過寫執行緒時,選擇在volatile寫之後插入StoreLoad屏障將帶來可觀的執行效率的提升。從這裡我們可以看到JMM在實現上的一個特點:首先確保正確性,然後再去追求執行效率。

下面是在保守策略下,volatile讀插入記憶體屏障後生成的指令序列示意圖:

上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

上述volatile寫和volatile讀的記憶體屏障插入策略非常保守。在實際執行時,只要不改變volatile寫-讀的記憶體語義,編譯器可以根據具體情況省略不必要的屏障。下面我們通過具體的示例程式碼來說明:
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;

    void readAndWrite() {
        int i = v1; // 第一個volatile讀
        int j = v2; // 第二個volatile讀
        a = i + j; // 普通寫
        v1 = i + 1; // 第一個volatile寫
        v2 = j * 2; // 第二個 volatile寫
    }
}

針對readAndWrite()方法,編譯器在生成位元組碼時可以做如下的優化:

注意,最後的StoreLoad屏障不能省略。因為第二個volatile寫之後,方法立即return。此時編譯器可能無法準確斷定後面是否會有volatile讀或寫,為了安全起見,編譯器常常會在這裡插入一個StoreLoad屏障。

上面的優化是針對任意處理器平臺,由於不同的處理器有不同“鬆緊度”的處理器記憶體模型,記憶體屏障的插入還可以根據具體的處理器記憶體模型繼續優化。以x86處理器為例,上圖中除最後的StoreLoad屏障外,其它的屏障都會被省略。

前面保守策略下的volatile讀和寫,在 x86處理器平臺可以優化成:

前文提到過,x86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀,讀-寫和寫-寫操作做重排序,因此在x86處理器中會省略掉這三種操作型別對應的記憶體屏障。在x86中,JMM僅需在volatile寫後面插入一個StoreLoad屏障即可正確實現volatile寫-讀的記憶體語義。這意味著在x86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執行StoreLoad屏障開銷會比較大)。

JSR-133為什麼要增強volatile的記憶體語義

在JSR-133之前的舊Java記憶體模型中,雖然不允許volatile變數之間重排序,但舊的Java記憶體模型允許volatile變數與普通變數之間重排序。在舊的記憶體模型中,VolatileExample示例程式可能被重排序成下列時序來執行:

在舊的記憶體模型中,當1和2之間沒有資料依賴關係時,1和2之間就可能被重排序(3和4類似)。其結果就是:讀執行緒B執行4時,不一定能看到寫執行緒A在執行1時對共享變數的修改。

因此在舊的記憶體模型中 ,volatile的寫-讀沒有監視器的釋放-獲所具有的記憶體語義。為了提供一種比監視器鎖更輕量級的執行緒之間通訊的機制,JSR-133專家組決定增強volatile的記憶體語義:嚴格限制編譯器和處理器對volatile變數與普通變數的重排序,確保volatile的寫-讀和監視器的釋放-獲取一樣,具有相同的記憶體語義。從編譯器重排序規則和處理器記憶體屏障插入策略來看,只要volatile變數與普通變數之間的重排序可能會破壞volatile的記憶體語意,這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止。

由於volatile僅僅保證對單個volatile變數的讀/寫具有原子性,而監視器鎖的互斥執行的特性可以確保對整個臨界區程式碼的執行具有原子性。在功能上,監視器鎖比volatile更強大;在可伸縮性和執行效能上,volatile更有優勢。如果讀者想在程式中用volatile代替監視器鎖,請一定謹慎。

CAS操作詳解

本文屬於作者原創,原文發表於InfoQ: http://www.infoq.com/cn/articles/atomic-operation

引言

原子(atom)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為”不可被中斷的一個或一系列操作” 。在多處理器上實現原子操作就變得有點複雜。本文讓我們一起來聊一聊在Inter處理器和Java裡是如何實現原子操作的。

術語定義

術語名稱 英文 解釋
快取行 Cache line 快取的最小操作單位
比較並交換 Compare and Swap CAS操作需要輸入兩個數值,一箇舊值(期望操作前的值)和一個新值,在操作期間先比較下在舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。
CPU流水線 CPU pipeline CPU流水線的工作方式就象工業生產上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然後將一條X86指令分成5~6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘週期完成一條指令,因此提高CPU的運算速度。
記憶體順序衝突 Memory order violation 記憶體順序衝突一般是由假共享引起,假共享是指多個CPU同時修改同一個快取行的不同部分而引起其中一個CPU的操作無效,當出現這個記憶體順序衝突時,CPU必須清空流水線。

3 處理器如何實現原子操作

32位IA-32處理器使用基於對快取加鎖或匯流排加鎖的方式來實現多處理器之間的原子操作。

3.1 處理器自動保證基本記憶體操作的原子性

首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體當中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的記憶體地址。奔騰6和最新的處理器能自動保證單處理器對同一個快取行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器不能自動保證其原子性,比如跨匯流排寬度,跨多個快取行,跨頁表的訪問。但是處理器提供匯流排鎖定和快取鎖定兩個機制來保證複雜記憶體操作的原子性。

3.2 使用匯流排鎖保證原子性

第一個機制是通過匯流排鎖保證原子性。如果多個處理器同時對共享變數進行讀改寫(i++就是經典的讀改寫操作)操作,那麼共享變數就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變數的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。如下圖
1

(例1)

原因是有可能多個處理器同時從各自的快取中讀取變數i,分別進行加一操作,然後分別寫入系統記憶體當中。那麼想要保證讀改寫共享變數的操作是原子的,就必須保證CPU1讀改寫共享變數的時候,CPU2不能操作快取了該共享變數記憶體地址的快取。

處理器使用匯流排鎖就是來解決這個問題的。所謂匯流排鎖就是使用處理器提供的一個LOCK#訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享記憶體。

3.3 使用快取鎖保證原子性

第二個機制是通過快取鎖定保證原子性。在同一時刻我們只需保證對某個記憶體地址的操作是原子性即可,但匯流排鎖定把CPU和記憶體之間通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖定的開銷比較大,最近的處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。

頻繁使用的記憶體會快取在處理器的L1,L2和L3快取記憶體裡,那麼原子操作就可以直接在處理器內部快取中進行,並不需要宣告匯流排鎖,在奔騰6和最近的處理器中可以使用“快取鎖定”的方式來實現複雜的原子性。所謂“快取鎖定”就是如果快取在處理器快取行中記憶體區域在LOCK操作期間被鎖定,當它執行鎖操作回寫記憶體時,處理器不在匯流排上聲言LOCK#訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時會起快取行無效,在例1中,當CPU1修改快取行中的i時使用快取鎖定,那麼CPU2就不能同時快取了i的快取行。

但是有兩種情況下處理器不會使用快取鎖定。第一種情況是:當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行(cache line),則處理器會呼叫匯流排鎖定。第二種情況是:有些處理器不支援快取鎖定。對於Inter486和奔騰處理器,就算鎖定的記憶體區域在處理器的快取行中也會呼叫匯流排鎖定。

以上兩個機制我們可以通過Inter處理器提供了很多LOCK字首的指令來實現。比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些運算元和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。

4 JAVA如何實現原子操作

在java中可以通過鎖和迴圈CAS的方式來實現原子操作。

4.1 使用迴圈CAS實現原子操作

JVM中的CAS操作正是利用了上一節中提到的處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是迴圈進行CAS操作直到成功為止,以下程式碼實現了一個基於CAS執行緒安全的計數器方法safeCount和一個非執行緒安全的計數器count。

package Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
    private AtomicInteger atomicI = new AtomicInteger();
    private int i = 0;
    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<Thread>();
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts) {
            t.start();
        }
        // 等待所有執行緒執行完成
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }
    /**
     * 
     * 使用CAS實現執行緒安全計數器
     * 
     */
    private void safeCount() {
        for (;;) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }
    /**
     * 
     * 非執行緒安全計數器
     * 
     */
    private void count() {
        i++;
    }
}
結果
992362
1000000
75

從Java1.5開始JDK的併發包裡提供了一些類來支援原子操作,如 AtomicBoolean(用原子方式更新的 boolean 值), AtomicInteger(用原子方式更新的 int 值), AtomicLong(用原子方式更新的 long 值),這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。

在Java併發包中有一些併發框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,迴圈時間長開銷大和只能保證一個共享變數的原子操作。

  1. ABA問題。因為CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

public boolean compareAndSet(
   V      expectedReference,//預期引用
   V      newReference,//更新後的引用
  int    expectedStamp, //預期標誌
  int    newStamp //更新後的標誌
)
  1. 迴圈時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。
  1. 只能保證一個共享變數的原子操作。當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行CAS操作。

4.2 使用鎖機制實現原子操作

鎖機制保證了只有獲得鎖的執行緒能夠操作鎖定的記憶體區域。JVM內部實現了很多種鎖機制,有偏向鎖,輕量級鎖和互斥鎖,有意思的是除了偏向鎖,JVM實現鎖的方式都用到的迴圈CAS,當一個執行緒想進入同步塊的時候使用迴圈CAS的方式來獲取鎖,當它退出同步塊的時候使用迴圈CAS釋放鎖。詳細說明可以參見文章 Java SE1.6中的Synchronized

5 參考資料

  1. Java SE1.6中的Synchronized
  2. Intel 64和IA-32架構軟體開發人員手冊
  3. 深入分析Volatile的實現原理

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69951287/viewspace-2663303/,如需轉載,請註明出處,否則將追究法律責任。

相關文章