什麼是Java記憶體模型

佔小狼發表於2018-03-13

在知識星球中,有個小夥伴提了一個問題: 有一個關於JVM名詞定義的問題,說”JVM記憶體模型“,有人會說是關於JVM記憶體分佈(堆疊,方法區等)這些介紹,也有地方說(深入理解JVM虛擬機器)上說Java記憶體模型是JVM的抽象模型(主記憶體,本地記憶體)。這兩個到底怎麼區分啊?有必然關係嗎?比如主記憶體就是堆,本地記憶體就是棧,這種說法對嗎?

時間久了,我也把記憶體模型和記憶體結構給搞混了,所以抽了時間把JSR133規範中關於記憶體模型的部分重新看了下。

後來聽了好多人反饋:在面試的時候,有面試官會讓你解釋一下Java的記憶體模型,有些人解釋對了,結果面試官說不對,應該是堆啊、棧啊、方法區什麼的(這不是半吊子面試麼,自己概念都不清楚)

JVM中的堆啊、棧啊、方法區什麼的,是Java虛擬機器的記憶體結構,Java程式啟動後,會初始化這些記憶體的資料。

什麼是Java記憶體模型

記憶體結構就是上圖中記憶體空間這些東西,而Java記憶體模型,完全是另外的一個東西。

什麼是記憶體模型

在多CPU的系統中,每個CPU都有多級快取,一般分為L1、L2、L3快取,因為這些快取的存在,提供了資料的訪問效能,也減輕了資料匯流排上資料傳輸的壓力,同時也帶來了很多新的挑戰,比如兩個CPU同時去操作同一個記憶體地址,會發生什麼?在什麼條件下,它們可以看到相同的結果?這些都是需要解決的。

所以在CPU的層面,記憶體模型定義了一個充分必要條件,保證其它CPU的寫入動作對該CPU是可見的,而且該CPU的寫入動作對其它CPU也是可見的,那這種可見性,應該如何實現呢?

有些處理器提供了強記憶體模型,所有CPU在任何時候都能看到記憶體中任意位置相同的值,這種完全是硬體提供的支援。

其它處理器,提供了弱記憶體模型,需要執行一些特殊指令(就是經常看到或者聽到的,memory barriers記憶體屏障),重新整理CPU快取的資料到記憶體中,保證這個寫操作能夠被其它CPU可見,或者將CPU快取的資料設定為無效狀態,保證其它CPU的寫操作對本CPU可見。通常這些記憶體屏障的行為由底層實現,對於上層語言的程式設計師來說是透明的(不需要太關心具體的記憶體屏障如何實現)。

什麼是Java記憶體模型

前面說到的記憶體屏障,除了實現CPU之前的資料可見性之外,還有一個重要的職責,可以禁止指令的重排序。

這裡說的重排序可以發生在好幾個地方:編譯器、執行時、JIT等,比如編譯器會覺得把一個變數的寫操作放在最後會更有效率,編譯後,這個指令就在最後了(前提是隻要不改變程式的語義,編譯器、執行器就可以這樣自由的隨意優化),一旦編譯器對某個變數的寫操作進行優化(放到最後),那麼在執行之前,另一個執行緒將不會看到這個執行結果。

當然了,寫入動作可能被移到後面,那也有可能被挪到了前面,這樣的“優化”有什麼影響呢?這種情況下,其它執行緒可能會在程式實現“發生”之前,看到這個寫入動作(這裡怎麼理解,指令已經執行了,但是在程式碼層面還沒執行到)。通過記憶體屏障的功能,我們可以禁止一些不必要、或者會帶來負面影響的重排序優化,在記憶體模型的範圍內,實現更高的效能,同時保證程式的正確性。

下面看一個重排序的例子:

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

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}
複製程式碼

假設這段程式碼有2個執行緒併發執行,執行緒A執行writer方法,執行緒B執行reader方法,執行緒B看到y的值為2,因為把y設定成2發生在變數x的寫入之後(程式碼層面),所以能斷定執行緒B這時看到的x就是1嗎?

當然不行! 因為在writer方法中,可能發生了重排序,y的寫入動作可能發在x寫入之前,這種情況下,執行緒B就有可能看到x的值還是0。

在Java記憶體模型中,描述了在多執行緒程式碼中,哪些行為是正確的、合法的,以及多執行緒之間如何進行通訊,程式碼中變數的讀寫行為如何反應到記憶體、CPU快取的底層細節。

在Java中包含了幾個關鍵字:volatile、final和synchronized,幫助程式設計師把程式碼中的併發需求描述給編譯器。Java記憶體模型中定義了它們的行為,確保正確同步的Java程式碼在所有的處理器架構上都能正確執行。

synchronization 可以實現什麼

Synchronization有多種語義,其中最容易理解的是互斥,對於一個monitor物件,只能夠被一個執行緒持有,意味著一旦有執行緒進入了同步程式碼塊,那麼其它執行緒就不能進入直到第一個進入的執行緒退出程式碼塊(這因為都能理解)。

但是更多的時候,使用synchronization並非單單互斥功能,Synchronization保證了執行緒在同步塊之前或者期間寫入動作,對於後續進入該程式碼塊的執行緒是可見的(又是可見性,不過這裡需要注意是對同一個monitor物件而言)。在一個執行緒退出同步塊時,執行緒釋放monitor物件,它的作用是把CPU快取資料(本地快取資料)重新整理到主記憶體中,從而實現該執行緒的行為可以被其它執行緒看到。在其它執行緒進入到該程式碼塊時,需要獲得monitor物件,它在作用是使CPU快取失效,從而使變數從主記憶體中重新載入,然後就可以看到之前執行緒對該變數的修改。

但從快取的角度看,似乎這個問題只會影響多處理器的機器,對於單核來說沒什麼問題,但是別忘了,它還有一個語義是禁止指令的重排序,對於編譯器來說,同步塊中的程式碼不會移動到獲取和釋放monitor外面。

下面這種程式碼,千萬不要寫,會讓人笑掉大牙:

synchronized (new Object()) {
}
複製程式碼

這實際上是沒有操作的操作,編譯器完成可以刪除這個同步語義,因為編譯知道沒有其它執行緒會在同一個monitor物件上同步。

所以,請注意:對於兩個執行緒來說,在相同的monitor物件上同步是很重要的,以便正確的設定happens-before關係。

final 可以影響什麼

如果一個類包含final欄位,且在建構函式中初始化,那麼正確的構造一個物件後,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;
    }
  }
}
複製程式碼

上面這個例子描述了應該如何使用final欄位,一個執行緒A執行reader方法,如果f已經線上程B初始化好,那麼可以確保執行緒A看到x值是3,因為它是final修飾的,而不能確保看到y的值是4。 如果建構函式是下面這樣的:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  global.obj = this;
}
複製程式碼

這樣通過global.obj拿到物件後,並不能保證x的值是3.

###volatile可以做什麼 Volatile欄位主要用於執行緒之間進行通訊,volatile欄位的每次讀行為都能看到其它執行緒最後一次對該欄位的寫行為,通過它就可以避免拿到快取中陳舊資料。它們必須保證在被寫入之後,會被重新整理到主記憶體中,這樣就可以立即對其它執行緒可以見。類似的,在讀取volatile欄位之前,快取必須是無效的,以保證每次拿到的都是主記憶體的值,都是最新的值。volatile的記憶體語義和sychronize獲取和釋放monitor的實現目的是差不多的。

對於重新排序,volatile也有額外的限制。

下面看一個例子:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}
複製程式碼

同樣的,假設一個執行緒A執行writer,另一個執行緒B執行reader,writer中對變數v的寫入把x的寫入也重新整理到主記憶體中。reader方法中會從主記憶體重新獲取v的值,所以如果執行緒B看到v的值為true,就能保證拿到的x是42.(因為把x設定成42發生在把v設定成true之前,volatile禁止這兩個寫入行為的重排序)。

如果變數v不是volatile,那麼以上的描述就不成立了,因為執行順序可能是v=true, x=42,或者對於執行緒B來說,根本看不到v被設定成了true。

double-checked locking的問題

臭名昭著的雙重檢查(其中一種單例模式),是一種延遲初始化的實現技巧,避免了同步的開銷,因為在早期的JVM,同步操作效能很差,所以才出現了這樣的小技巧。

private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}
複製程式碼

這個技巧看起來很聰明,避免了同步的開銷,但是有一個問題,它可能不起作用,為什麼呢?因為例項的初始化和例項欄位的寫入可能被編譯器重排序,這樣就可能返回部門構造的物件,結果就是讀到了一個未初始化完成的物件。

當然,這種bug可以通過使用volatile修飾instance欄位進行fix,但是我覺得這種程式碼格式實在太醜陋了,如果真要延遲初始化例項,不妨使用下面這種方式:

private static class LazySomethingHolder {
  public static Something something = new Something();
}

public static Something getInstance() {
  return LazySomethingHolder.something;
}
複製程式碼

由於是靜態欄位的初始化,可以確保對訪問該類的所以執行緒都是可見的。

對於這些,我們需要關心什麼

併發產生的bug非常難以除錯,通常在測試程式碼中難以復現,當系統負載上來之後,一旦發生,又很難去捕捉,為了確保程式能夠在任意環境正確的執行,最好是提前花點時間好好思考,雖然很難,但還是比除錯一個線上bug來得容易的多。

什麼是Java記憶體模型
知識星球可以幹什麼? 1、【分享】高質量的技術文章 2、【沉澱】「戰狼群」高質量問題&解決方案 3、【成長】專案經驗,生活隨筆,學習心得 4、【覆盤】實戰經驗,故障總結 5、【面經】面試經驗分享與總結 6、【推薦】技術書籍,崗位招聘

相關文章