同步和Java記憶體模型

併發程式設計網發表於2013-01-14

 序言

  先來看如下這個簡單的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欄位的讀訪問,都要重新裝載欄位的值。

  一個執行緒首次訪問一個物件的欄位,它將讀到這個欄位的初始值或被某個執行緒寫入後的值。

  此外,把還未構造完成的物件的引用暴露給某個執行緒,這是一個錯誤的做法 (see ?.1.2)。在建構函式內部開始一個新執行緒也是危險的,特別是這個類可能被子類化時。Thread.start有如下的記憶體效果:呼叫start方法的執行緒釋放了鎖,隨後開始執行的新執行緒獲取了這個鎖。如果在子類建構函式執行之前,可執行的超類呼叫了new Thread(this).start(),當run方法執行時,物件很可能還沒有完全初始化。同樣,如果你建立且開始一個新執行緒T,這個執行緒使用了在執行start之後才建立的一個物件X。你不能確信X的欄位值將能對執行緒T可見。除非你把所有用到X的引用的方法都同步。如果可行的話,你可以在開始T執行緒之前建立X。

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

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

  記憶體模型確保上述操作最終會發生,一個執行緒對一個特定欄位的特定更新,最終將會對其他執行緒可見,但這個“最終”可能是很長一段時間。執行緒之間沒有同步時,很難保證對欄位的值能在多執行緒之間保持一致(指寫執行緒對欄位的寫入立即能對讀執行緒可見)。特別是,如果欄位不是volatile或沒有通過同步來訪問這個欄位,在一個迴圈中等待其他執行緒對這個欄位的寫入,這種情況總是錯誤的(see ?.2.6)。

  在缺乏同步的情況下,模型還允許不一致的可見性。比如,得到一個物件的一個欄位的最新值,同時得到這個物件的其他欄位的過期的值。同樣,可能讀到一個引用變數的最新值,但讀取到這個引用變數引用的物件的欄位的過期值。

  不管怎樣,執行緒之間的可見性並不總是失效(指執行緒即使沒有使用同步,仍然有可能讀取到欄位的最新值),記憶體模型僅僅是允許這種失效發生而已。因此,即使多個執行緒之間沒有使用同步,也不保證一定會發生記憶體可見性問題(指執行緒讀取到過期的值),java記憶體模型僅僅是允許記憶體可見性問題發生而已。在很多當前的JVM實現和java執行平臺中,甚至是在那些使用多處理器的JVM和平臺中,也很少出現記憶體可見性問題。共享同一個CPU的多個執行緒使用公共的快取,缺少強大的編譯器優化,以及存在強快取一致性的硬體,這些都會使執行緒更新後的值能夠立即在多執行緒之間傳遞。這使得測試基於記憶體可見性的錯誤是不切實際的,因為這樣的錯誤極難發生。或者這種錯誤僅僅在某個你沒有使用過的平臺上發生,或僅在未來的某個平臺上發生。這些類似的解釋對於多執行緒之間的記憶體可見性問題來說非常普遍。沒有同步的併發程式會出現很多問題,包括記憶體一致性問題。

 有序性 

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

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

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

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

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

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

  譯註:

  【1】JLS:Java Language Specification ,Java語言規範

  【2】互斥體:原文為structural exclusion,譯者認為意同 mutual exclusion ,詳見 互斥體

 Volatile

  從原子性,可見性和有序性的角度分析,宣告為volatile欄位的作用相當於一個類通過get/set同步方法保護普通欄位,如下:

final class VFloat {
    private float value;
 
    final synchronized void set(float f) { value = f; }
    final synchronized float get()       { return value; }
}

  與使用synchronized相比,宣告一個volatile欄位的區別在於沒有涉及到鎖操作。但特別的是對volatile欄位進行“++”這樣的讀寫操作不會被當做原子操作執行。

  另外,有序性和可見性僅對volatile欄位進行一次讀取或更新操作起作用。宣告一個引用變數為volatile,不能保證通過該引用變數訪問到的非volatile變數的可見性。同理,宣告一個陣列變數為volatile不能確保陣列內元素的可見性。volatile的特性不能在陣列內傳遞,因為陣列裡的元素不能被宣告為volatile。

  由於沒有涉及到鎖操作,宣告volatile欄位很可能比使用同步的開銷更低,至少不會更高。但如果在方法內頻繁訪問volatile欄位,很可能導致更低的效能,這時還不如鎖住整個方法。

  如果你不需要鎖,把欄位宣告為volatile是不錯的選擇,但仍需要確保多執行緒對該欄位的正確訪問。可以使用volatile的情況包括:

  • 該欄位不遵循其他欄位的不變式。
  • 對欄位的寫操作不依賴於當前值。
  • 沒有執行緒違反預期的語義寫入非法值。
  • 讀取操作不依賴於其它非volatile欄位的值。

  當只有一個執行緒可以修改欄位的值,其它執行緒可以隨時讀取,那麼把欄位宣告為volatile是合理的。例如,一個名叫Thermometer(中文:體溫計)的類,可以宣告temperature欄位為volatile。正如在3.4.2節所討論,一個volatile欄位很適合作為完成某些工作的標誌。另一個例子在4.4節有描述,通過使用輕量級的執行框架使某些同步工作自動化,但是仍需把結果欄位宣告為volatile,使其對各個任務都是可見的。

  英文原文:http://gee.cs.oswego.edu/dl/cpj/jmm.html 

  譯文:http://ifeve.com/syn-jmm/

相關文章