JSR133提案-修復Java記憶體模型

元思發表於2020-06-17

1. 什麼是記憶體模型?

在多處理器系統中,為了提高訪問資料的速度,通常會增加一層或多層快取記憶體(越靠近處理器的快取速度越快)。
但是快取同時也帶來了許多新的挑戰。比如,當兩個處理器同時讀取同一個記憶體位置時,看到的結果可能會不一樣?
在處理器維度上,記憶體模型定義了一些規則來保證當前處理器可以立即看到其他處理器的寫入,以及當前處理器的寫入對其他處理器立即可見。這些規則被稱為快取一致性協議
有些多處理器架構實現了強一致性,所有的處理器在同一時刻看到的同一記憶體位置的值是一樣的。
而其他處理器實現的則是較弱的一致性,需要使用被稱為記憶體屏障的特殊機器指令使來實現最終一致性(通過重新整理快取或使快取失效)。
這些記憶體屏障通常在釋放鎖和獲取鎖時被執行;對於高階語言(如Java)的程式設計師來說,它們是不可見的。

在強一致性的處理器上,由於減少了對記憶體屏障的依賴,編寫併發程式會更容易一些。
但是,相反的,近年來處理器設計的趨勢是使用較弱的記憶體模型,因為放寬對快取一致性的要求可以使得多處理器系統有更好的伸縮性和更大的記憶體。

此外,編譯器、快取或執行時還被允許通過指令重排序改變記憶體的操作順序(相對於程式所表現的順序)。
例如,編譯器可能會往後移動一個寫入操作,只要移動操作不改變程式的原本語義(as-if-serial語義),就可以自由進行更改。
再比如,快取可能會推遲把資料刷回到主記憶體中,直到它認為時機合適了。
這種靈活的設計,目的都是為了獲得得最佳的效能,
但是在多執行緒環境下,指令重排會使得跨執行緒可見性的問題變的更復雜。

為了方便理解,我們來看個程式碼示例:

Class Reordering {
  int x = 0, y = 0;
  //thread A
   public void writer() {
        x = 1;
        y = 2;
    }

    //thread B
    public void reader() {
        int r1 = y;
        int r2 = x;
     }
}

假設這段程式碼被兩個執行緒併發執行,執行緒A執行writer(),執行緒B執行reader()。
如果執行緒B在reader()中看到了y=2,那麼直覺上我們會認為它看到的x肯定是1,因為在writer()中x=1y=2之前 。
然而,發生重排序時y=2會早於x=1執行,此時,實際的執行順序會是這樣的:

y=2;
int r1=y;
int r2=x;
x=1;

結果就是,r1的值是2,r2的值是0。
從執行緒A的角度看,x=1與y=2哪個先執行結果是一樣的(或者說沒有違反as-if-serial語義),但是在多執行緒環境下,這種重排序會產生混亂的結果。

我們可以看到,快取記憶體指令重排序提高了效率的同時也引出了新的問題,這顯然使得編寫併發程式變得更加困難。
Java記憶體模型就是為了解決這類問題,它對多執行緒之間如何通過記憶體進行互動做了明確的說明。
更具體點,Java記憶體模型描述了程式中的變數與實際計算機的儲存裝置(包括記憶體、快取、暫存器)之間互動的底層細節。
例如,Java提供了volatile、final和 synchronized等工具,用於幫助程式設計師向編譯器表明對併發程式的要求。
更重要的是,Java記憶體模型保證這些同步工具可以正確的執行在任何處理器架構上,使Java併發應用做到“Write Once, Run Anywhere”。

相比之下,大多數其他語言(例如C/C++)都沒有提供顯示的記憶體模型。
C程式繼承了處理器的記憶體模型,這意味著,C語言的併發程式在一個處理器架構中可以正確執行,在另外一個架構中則不一定。

2. JSR 133是關於什麼的?

Java提供的跨平臺記憶體模型是一個雄心勃勃的計劃,在當時是具有開創性的。
但不幸的是,定義一個即直觀又一致的記憶體模型比預期的要困難得多。
自1997年以來,在《Java語言規範》的第17章關於Java記憶體模型的定義中發現了一些嚴重的缺陷。
這些缺陷使一些同步工具產生混亂的結果,例如final欄位可以被更改。
JSR 133為Java語言定義了一個新的記憶體模型,修復了舊版記憶體模型的缺陷(修改了final和volatile的語義)
JSR的主要目標包括不限於這些:

  1. 正確同步的語義應該更直觀更簡單。
  2. 應該定義不完整或不正確同步的語義,以最小化潛在的安全隱患
  3. 程式設計師應該有足夠的自信推斷出多執行緒程式如何與記憶體互動的。
  4. 提供一個新的初始化安全性保證(initialization safety)。
    如果一個物件被正確初始化了(初始化期間,物件的引用沒有逃逸,比如建構函式裡把this賦值給變數),那麼所有可以看到該物件引用的執行緒,都可以看到在建構函式中被賦值的final變數。這不需要使用synchronized或volatile。

3. 再談指令重排序

在許多情況下,出於優化執行效率的目的,資料(例項變數、靜態欄位、陣列元素等)可以在暫存器、快取和記憶體之間以不同於程式中宣告的順序被移動。
例如,執行緒先寫入欄位a,再寫入欄位b,並且b的值不依賴a,那麼編譯器就可以自由的對這些操作重新排序,在寫入a之前把b的寫入刷回到記憶體。
除了編譯器,重排序還可能發生在JIT、快取、處理器上。
無論發生在哪裡,重排序都必須遵循as-if-serial語義,這意味著在單執行緒程式中,程式不會覺察到重排序的存在,或者說給單執行緒程式一種沒有發生過重排序的錯覺。
但是,重排序在沒有同步的多執行緒程式中會產生影響。在這種程式中,一個執行緒能夠觀察到其他執行緒的執行情況,並且可能檢測到變數訪問順序與程式碼中指定的順序不一致。
大多數情況下,一個執行緒不會在乎另一個執行緒在做什麼,但是,如果有,就是同步的用武之地。

4.同步都做了什麼?

同步有很多面,最為程式設計師熟知的是它的互斥性,同一時刻只能有一個執行緒持有monitor。
但是,同步不僅僅是互斥性。同步還能保證一個執行緒在同步塊中的寫記憶體操作對其他持有相同monitor的執行緒立即可見。
當執行緒退出同步塊時(釋放monitor),會把快取中的資料刷回到主記憶體,使主記憶體中保持最新的資料。
當執行緒進入同步塊時(獲取monitor),會使本地處理器快取失效,使得變數必須從主記憶體中重新載入。
我們可以看到,之前的所有寫操作對後來的執行緒都是可見的。

5. final欄位在舊的記憶體模型中為什麼可以改變?

證明final欄位可以改變的最佳示例是String類的實現(JDK 1.4版本)。
String物件包含三個欄位:一個字串陣列的引用value、一個記錄陣列中開始位置的offset、字串長度length。
通過這種方式,可以實現多個String/StringBuffer物件共享一個相同的字串陣列,從而避免為每個物件分配額外的空間。
例如,String.substring()通過與原String物件共享一個陣列來產生一個新的物件,唯一的不同是length和offset欄位。

String s1 = "/usr/tmp";
String s2 = s1.substring(4); 

s2和s1共享一個字串陣列"/usr/tmp",不同的是s2的offset=4,length=4,s1的offset=0,length=8。
在String的建構函式執行之前,根類Object的建構函式會先初始化所有欄位為預設值,包括final的length和offset欄位。
當String的建構函式執行時,再把length和offset賦值為期望的值。
但是這一過程,在舊的記憶體模型中,如果沒有使用同步,另一個執行緒可能會看到offset的預設值0,然後在看到正確的值4.
結果導致一個迷幻的現象,開始看到字串s2的內容是'/usr',然後再看到'/tmp'。
這不符合我們對final語義的認識,但是在舊記憶體模型中確實存在這樣的問題。
(JDK7開始,改變了substring的實現方式,每次都會建立一個新的物件)

6.“初始化安全”與final欄位?

新的記憶體模型提供一個新初始化安全( initialization safety)保障。
意味著,只要一個物件被正確的構造,那麼所有的執行緒都會看到這些在建構函式中被賦值的final欄位。
“正確”的構造是指在建構函式執行期間,物件的引用沒有發生逃逸。或者說,在建構函式中沒有把該物件的引用賦值給任何變數。

class FinalFieldExample {
  final int x;
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
    y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

示例中,初始化安全保證執行reader()方法的執行緒看到的f.x=3,因為它是final欄位,但是不保證能看到y=4,因為它不是final的。
但是如果建構函式像這樣:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  global.obj = this;  //  allowing this to escape
}

初始化安全不能保證讀取global.obj的執行緒看到的x的值是3,因為物件引用this發生了逃逸。

不僅如此,任何通過final欄位(建構函式中被賦值的)可以觸達的變數都可以保證對其他執行緒可見。
這意味著如果一個final欄位包含一個引用,例如ArrayList,除了該欄位的引用對其他執行緒可見,ArrayList中的元素對其他執行緒也是可見的。

初始化安全增強了final的語義,使其更符合我們對final的直觀感受,任何情況下都不會改變。

7. 增強volatile語義

volatile變數是用於執行緒之間傳遞狀態的特殊變數,這要求任何執行緒看到的都是volatile變數的最新值。
為實現可見性,禁止在暫存器中分配它們,還必須確保修改volatile後,要把最新值從快取刷到記憶體中。
類似的,在讀取volatile變數之前,必須使快取記憶體失效,這樣其他執行緒會直接讀取主記憶體中的資料。
在舊的記憶體模型中,多個volatile變數之間不能互相重排序,但是它們被允許可以與非volatile變數一起重排序,這消弱了volatile作為執行緒間交流訊號的作用。
我們來看個示例:

Map configs;
volatile boolean initialized = false;
. . .
 
// In thread A
configs  =  readConfigFile(fileName);
processConfigOptions( configs);
initialized = true;
. . .
 
// In thread B
while (initialized) {
    // use configs
}

示例中,執行緒A負責配置資料初始化工作,初始化完成後執行緒B開始執行。
實際上,volatile變數initialized扮演者守衛者的角色,它表示前置工作已經完成,依賴這些資料的其他執行緒可以執行了。
但是,當volatile變數與非volatile變數被編譯器放到一起重新排序時,“守衛者”就形同虛設了。
重排序發生時,可能會使readConfigFile()中某個動作在initialized = true之後執行,
那麼,執行緒B在看到initialized的值為true後,在使用configs物件時,會讀取到沒有被正確初始化的資料。
這是volatile很典型的應用場景,但是在舊的記憶體模型中卻不能正確的工作。

JSR 133專家組決定在新的記憶體模型中,不再允許volatile變數與其他任務記憶體操作一起重排序
這意味著,volatile變數之前的記憶體操作不會在其後執行,volatile變數之後的記憶體操作不會在其前執行。
volatile變數相當於一個屏障,重排序不能越過對volatile的記憶體操作。(實際上,jvm確實使用了記憶體屏障指令)
增強volatile語義的副作用也很明顯,禁止重排序會有一定的效能損失。

8. 修復“double-checked locking”的問題

double-checked locking是單例模式的其中一種實現,它支援懶載入且是執行緒安全的。
大概長這個樣子:

private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();//
    }
  }
  return instance;
}

它通過兩次檢查巧妙的避開了在公共程式碼路徑上使用同步,從而避免了同步所帶來的效能開銷。
它唯一的問題就是——不起作用。為什麼呢?
instance的賦值操作會與SomeThing()建構函式中的變數初始化一起被編譯器或快取重排序,這可能會導致把未完全初始化的物件引用賦值給instance。
現在很多人知道把instance宣告為volatile可以修復這個問題,但是在舊的記憶體模型(JDK 1.5之前)中並不可行,原因前面有提到,volatile可以與非volatile欄位一起重排序。

儘管,新的記憶體模型修復了double-checked locking的問題,但仍不鼓勵這種實現方式,因為volatile並不是免費的。
相比之下,Initialization On Demand Holder Class更值得被推薦,
它不僅實現了懶載入和執行緒安全,還提供了更好的效能和更清晰的程式碼邏輯。大概長這個樣子:

public class Something {
    private Something() {}
    //static innner class
    private static class LazyHolder {
        static final Something INSTANCE = new Something(); //static  field
    }

    public static Something getInstance() {
        return LazyHolder.INSTANCE;
    }
}

這種實現完全沒有使用同步工具,而是利用了Java語言規範的兩個基本原則,
其一,JVM保證靜態變數的初始化對所有使用該類的執行緒立即可見;
其二,內部類首次被使用時才會觸發類的初始化,這實現了懶載入。

9. 我什麼我要關心這些問題?

併發問題一般不會在測試環境出現,生成環境的併發問題又不容易復現,這兩個特點使得併發問題通常比較棘手。
所以你最好提前花點時間學習併發知識,以確保寫出正確的併發程式。我知道這很困難,但是應該比排查生產環境的併發問題容易的多。

參考文獻

1.JSR 133 (Java Memory Model) FAQ,2004
https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#oldmm
2.volatile關鍵字: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
3.Double-checked問題:https://en.wikipedia.org/wiki/Double-checked_locking
4.記憶體屏障和volatile語義: https://en.wikipedia.org/wiki/Memory_barrier
5.修復Java記憶體模型:https://www.ibm.com/developerworks/java/library/j-jtp03304/index.html
6.String substring 在jdk7中會建立新的陣列
https://www.programcreek.com/2013/09/the-substring-method-in-jdk-6-and-jdk-7/
7.Memory Ordering : https://en.wikipedia.org/wiki/Memory_ordering
8.有MESI協議為什麼還需要volatile? https://www.zhihu.com/question/296949412
9.Initialization On Demand Holder Class:
https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom

相關文章