1. 程式人生 > 實用技巧 >深入理解 Java 記憶體模型

深入理解 Java 記憶體模型

Java記憶體模型(Java Memory Model,JMM)是java虛擬機器規範定義的,用來遮蔽掉java程式在各種不同的硬體和作業系統對記憶體的訪問的差異,這樣就可以實現java程式在各種不同的平臺上都能達到記憶體訪問的一致性。可以避免像c++等直接使用物理硬體和作業系統的記憶體模型在不同作業系統和硬體平臺下表現不同,比如有些c/c++程式可能在windows平臺執行正常,而在linux平臺卻執行有問題。

物理硬體和記憶體

首先,在單核電腦中,處理問題要簡單的多。對記憶體和硬體的要求,各種方面的考慮沒有在多核的情況下複雜。電腦中,CPU的執行計算速度是非常快的,而其他硬體比如IO,網路、記憶體讀取等等,跟cpu的速度比起來是差幾個數量級的。而不管任何操作,幾乎是不可能都在cpu中完成而不借助於任何其他硬體操作。所以協調cpu和各個硬體之間的速度差異是非常重要的,要不然cpu就一直在等待,浪費資源。而在多核中,不僅面臨如上問題,還有如果多個核用到了同一個資料,如何保證資料的一致性、正確性等問題,也是必須要解決的。

目前基於快取記憶體的儲存互動很好的解決了cpu和記憶體等其他硬體之間的速度矛盾,多核情況下各個處理器(核)都要遵循一定的諸如MSI、MESI等協議來保證記憶體的各個處理器快取記憶體和主記憶體的資料的一致性。

除了增加快取記憶體,為了使處理器內部運算單元儘可能被充分利用,處理器還會對輸入的程式碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在亂序執行之後的結果進行重組,保證結果的正確性,也就是保證結果與順序執行的結果一致。但是在真正的執行過程中,程式碼執行的順序並不一定按照程式碼的書寫順序來執行,可能和程式碼的書寫順序不同。

Java記憶體模型

雖然java程式所有的執行都是在虛擬機器中,涉及到的記憶體等資訊都是虛擬機器的一部分,但實際也是物理機的,只不過是虛擬機器作為最外層的容器統一做了處理。虛擬機器的記憶體模型,以及多執行緒的場景下與物理機的情況是很相似的,可以類比參考。

Java記憶體模型的主要目標是定義程式中變數的訪問規則。即在虛擬機器中將變數儲存到主記憶體或者將變數從主記憶體取出這樣的底層細節。需要注意的是這裡的變數跟我們寫java程式中的變數不是完全等同的。這裡的變數是指例項欄位,靜態欄位,構成陣列物件的元素,但是不包括區域性變數和方法引數(因為這是執行緒私有的)。這裡可以簡單的認為主記憶體是java虛擬機器記憶體區域中的堆,區域性變數和方法引數是在虛擬機器棧中定義的。但是在堆中的變數如果在多執行緒中都使用,就涉及到了堆和不同虛擬機器棧中變數的值的一致性問題了。

Java記憶體模型中涉及到的概念有:

  • 主記憶體:Java 虛擬機器規定所有的變數(不是程式中的變數)都必須在主記憶體中產生,為了方便理解,可以認為是堆區。可以與前面說的物理機的主記憶體相比,只不過物理機的主記憶體是整個機器的記憶體,而虛擬機器的主記憶體是虛擬機器記憶體中的一部分。
  • 工作記憶體:Java 虛擬機器中每個執行緒都有自己的工作記憶體,該記憶體是執行緒私有的為了方便理解,可以認為是虛擬機器棧。可以與前面說的快取記憶體相比。執行緒的工作記憶體儲存了執行緒需要的變數在主記憶體中的副本。虛擬機器規定,執行緒對主記憶體變數的修改必須線上程的工作記憶體中進行,不能直接讀寫主記憶體中的變數。不同的執行緒之間也不能相互訪問對方的工作記憶體。如果執行緒之間需要傳遞變數的值,必須通過主記憶體來作為中介進行傳遞。

這裡需要說明一下:主記憶體、工作記憶體與java記憶體區域中的java堆、虛擬機器棧、方法區並不是一個層次的記憶體劃分。這兩者是基本上是沒有關係的,上文只是為了便於理解,做的類比

工作記憶體與主記憶體互動

物理機快取記憶體和主記憶體之間的互動有協議,同樣的,java記憶體中執行緒的工作記憶體和主記憶體的互動是由java虛擬機器定義瞭如下的8種操作來完成的,每種操作必須是原子性的(double和long型別在某些平臺有例外,參考volatile詳解和非原子性協定)

java虛擬機器中主記憶體和工作記憶體互動,就是一個變數如何從主記憶體傳輸到工作記憶體中,如何把修改後的變數從工作記憶體同步回主記憶體。

  • lock(鎖定): 作用於主記憶體的變數,一個變數在同一時間只能一個執行緒鎖定,該操作表示這條線成獨佔這個變數
  • unlock(解鎖): 作用於主記憶體的變數,表示這個變數的狀態由處於鎖定狀態被釋放,這樣其他執行緒才能對該變數進行鎖定
  • read(讀取): 作用於主記憶體變數,表示把一個主記憶體變數的值傳輸到執行緒的工作記憶體,以便隨後的load操作使用
  • load(載入): 作用於執行緒的工作記憶體的變數,表示把read操作從主記憶體中讀取的變數的值放到工作記憶體的變數副本中(副本是相對於主記憶體的變數而言的)
  • use(使用): 作用於執行緒的工作記憶體中的變數,表示把工作記憶體中的一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時就會執行該操作
  • assign(賦值): 作用於執行緒的工作記憶體的變數,表示把執行引擎返回的結果賦值給工作記憶體中的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時就會執行該操作
  • store(儲存): 作用於執行緒的工作記憶體中的變數,把工作記憶體中的一個變數的值傳遞給主記憶體,以便隨後的write操作使用
  • write(寫入): 作用於主記憶體的變數,把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中

如果要把一個變數從主記憶體傳輸到工作記憶體,那就要順序的執行read和load操作,如果要把一個變數從工作記憶體回寫到主記憶體,就要順序的執行store和write操作。對於普通變數,虛擬機器只是要求順序的執行,並沒有要求連續的執行,所以如下也是正確的。對於兩個執行緒,分別從主記憶體中讀取變數a和b的值,並不一樣要read a; load a; read b; load b; 也會出現如下執行順序:read a; read b; load b; load a; (對於volatile修飾的變數會有一些其他規則,後邊會詳細列出),對於這8中操作,虛擬機器也規定了一系列規則,在執行這8中操作的時候必須遵循如下的規則:

  • 不允許read和load、store和write操作之一單獨出現,也就是不允許從主記憶體讀取了變數的值但是工作記憶體不接收的情況,或者不允許從工作記憶體將變數的值回寫到主記憶體但是主記憶體不接收的情況
  • 不允許一個執行緒丟棄最近的assign操作,也就是不允許執行緒在自己的工作執行緒中修改了變數的值卻不同步/回寫到主記憶體
  • 不允許一個執行緒回寫沒有修改的變數到主記憶體,也就是如果執行緒工作記憶體中變數沒有發生過任何assign操作,是不允許將該變數的值回寫到主記憶體
  • 變數只能在主記憶體中產生,不允許在工作記憶體中直接使用一個未被初始化的變數,也就是沒有執行load或者assign操作。也就是說在執行use、store之前必須對相同的變數執行了load、assign操作
  • 一個變數在同一時刻只能被一個執行緒對其進行lock操作,也就是說一個執行緒一旦對一個變數加鎖後,在該執行緒沒有釋放掉鎖之前,其他執行緒是不能對其加鎖的,但是同一個執行緒對一個變數加鎖後,可以繼續加鎖,同時在釋放鎖的時候釋放鎖次數必須和加鎖次數相同。
  • 對變數執行lock操作,就會清空工作空間該變數的值,執行引擎使用這個變數之前,需要重新load或者assign操作初始化變數的值
  • 不允許對沒有lock的變數執行unlock操作,如果一個變數沒有被lock操作,那也不能對其執行unlock操作,當然一個執行緒也不能對被其他執行緒lock的變數執行unlock操作
  • 對一個變數執行unlock之前,必須先把變數同步回主記憶體中,也就是執行store和write操作

當然,最重要的還是如開始所說,這8個動作必須是原子的,不可分割的。
針對volatile修飾的變數,會有一些特殊規定。

Volatile修飾的變數的特殊規則

關鍵字volatile可以說是java虛擬機器中提供的最輕量級的同步機制。java記憶體模型對volatile專門定義了一些特殊的訪問規則。這些規則有些晦澀拗口,先列出規則,然後用更加通俗易懂的語言來解釋:

假定T表示一個執行緒,V和W分別表示兩個volatile修飾的變數,那麼在進行read、load、use、assign、store和write操作的時候需要滿足如下規則:

  • 只有當執行緒T對變數V執行的前一個動作是load,執行緒T對變數V才能執行use動作;同時只有當執行緒T對變數V執行的後一個動作是use的時候執行緒T對變數V才能執行load操作。所以,執行緒T對變數V的use動作和執行緒T對變數V的read、load動作相關聯,必須是連續一起出現。也就是線上程T的工作記憶體中,每次使用變數V之前必須從主記憶體去重新獲取最新的值,用於保證執行緒T能看得見其他執行緒對變數V的最新的修改後的值。
  • 只有當執行緒T對變數V執行的前一個動作是assign的時候,執行緒T對變數V才能執行store動作;同時只有當執行緒T對變數V執行的後一個動作是store的時候,執行緒T對變數V才能執行assign動作。所以,執行緒T對變數V的assign操作和執行緒T對變數V的store、write動作相關聯,必須一起連續出現。也即是線上程T的工作記憶體中,每次修改變數V之後必須立刻同步回主記憶體,用於保證執行緒T對變數V的修改能立刻被其他執行緒看到。
  • 假定動作A是執行緒T對變數V實施的use或assign動作,動作F是和動作A相關聯的load或store動作,動作P是和動作F相對應的對變數V的read或write動作;類似的,假定動作B是執行緒T對變數W實施的use或assign動作,動作G是和動作B相關聯的load或store動作,動作Q是和動作G相對應的對變數W的read或write動作。如果動作A先於B,那麼P先於Q。也就是說在同一個執行緒內部,被volatile修飾的變數不會被指令重排序,保證程式碼的執行順序和程式的順序相同。

總結上面三條規則,前面兩條可以概括為:Volatile型別的變數保證對所有執行緒的可見性。第三條為:*Volatile型別的變數禁止指令重排序優化。

  • valatile型別的變數保證對所有執行緒的可見性

可見性是指當一個執行緒修改了這個變數的值,新值(修改後的值)對於其他執行緒來說是立即可以得知的。正如上面的前兩條規則規定,volatile型別的變數每次值被修改了就立即同步回主記憶體,每次使用時就需要從主記憶體重新讀取值。返回到前面對普通變數的規則中,並沒有要求這一點,所以普通變數的值是不會立即對所有執行緒可見的。

誤解 :volatile變數對所有執行緒是立即可見的,所以對volatile變數的所有修改(寫操作)都立刻能反應到其他執行緒中。或者換句話說:volatile變數在各個執行緒中是一致的,所以基於volatile變數的運算在併發下是執行緒安全的。

這個觀點的論據是正確的,但是根據論據得出的結論是錯誤的,並不能得出這樣的結論。

volatile的規則,保證了read、load、use的順序和連續行,同理assign、store、write也是順序和連續的。也就是這幾個動作是原子性的,但是對變數的修改,或者對變數的運算,卻不能保證是原子性的。如果對變數的修改是分為多個步驟的,那麼多個執行緒同時從主記憶體拿到的值是最新的,但是經過多步運算後回寫到主記憶體的值是有可能存在覆蓋情況發生的。如下程式碼的例子:

public class VolatileTest {
  public static volatile int race = 0;
  public static void increase() {
    race++
  }

  private static final int THREADS_COUNT = 20;

  public void static main(String[] args) {
      Thread[] threads = new Thread[THREADS_COUNT);
      for (int = 0; i < THREADS_COUNT; i++) {
          threads[i] = new Thread(new Runnable(){
              @Override
              public void run() {
                  for (int j = 0; j < 10000; j++) {
                     increase();
                  }
              }
          });
          threads[i].start();
      }
      while (Thread.activeCount() > 1) {
         Thread.yield();
      }
      System.out.println(race);
  }
}

程式碼就是對volatile型別的變數啟動了20個執行緒,每個執行緒對變數執行1w次加1操作,如果volatile變數併發操作沒有問題的話,那麼結果應該是輸出20w,但是結果執行的時候每次都是小於20w,這就是因為race++操作不是原子性的,是分多個步驟完成的。假設兩個執行緒a、b同時取到了主記憶體的值,是0,這是沒有問題的,在進行++操作的時候假設執行緒a執行到一半,執行緒b執行完了,這時執行緒b立即同步給了主記憶體,主記憶體的值為1,而執行緒a此時也執行完了,同步給了主記憶體,此時的值仍然是1,執行緒b的結果被覆蓋掉了。

  • volatile變數禁止指令重排序優化

普通的變數僅僅會保證在該方法執行的過程中,所有依賴賦值結果的地方都能獲取到正確的結果,但不能保證變數賦值的操作順序和程式程式碼的順序一致。因為在一個執行緒的方法執行過程中無法感知到這一點,這也就是java記憶體模型中描述的所謂的“執行緒內部表現為序列的語義”。

也就是在單執行緒內部,我們看到的或者感知到的結果和程式碼順序是一致的,即使程式碼的執行順序和程式碼順序不一致,但是在需要賦值的時候結果也是正確的,所以看起來就是序列的。但實際結果有可能程式碼的執行順序和程式碼順序是不一致的。這在多執行緒中就會出現問題。
看下面的虛擬碼舉例:

Map configOptions;
char[] configText;
//volatile型別bianliang
volatile boolean initialized = false;

//假設以下程式碼線上程A中執行
//模擬讀取配置資訊,讀取完成後認為是初始化完成
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

//假設以下程式碼線上程B中執行
//等待initialized為true後,讀取配置資訊進行操作
while ( !initialized) {
  sleep();
}
doSomethingWithConfig();

如果initialiezd是普通變數,沒有被volatile修飾,那麼執行緒A執行的程式碼的修改初始化完成的結果initialized = true就有可能先於之前的三行程式碼執行,而此時執行緒B發現initialized為true了,就執行doSomethingWithConfig()方法,但是裡面的配置資訊都是null的,就會出現問題了。

現在initialized是volatile型別變數,保證禁止程式碼重排序優化,那麼就可以保證initialized = true執行的時候,前邊的三行程式碼一定執行完成了,那麼執行緒B讀取的配置檔案資訊就是正確的。

跟其他保證併發安全的工具相比,volatile的效能確實會好一些。在某些情況下,volatile的同步機制效能要優於鎖(使用synchronized關鍵字或者java.util.concurrent包中的鎖)。但是現在由於虛擬機器對鎖的不斷優化和實行的許多消除動作,很難有一個量化的比較。

與自己相比,就可以確定一個原則:volatile變數的讀操作和普通變數的讀操作幾乎沒有差異,但是寫操作會效能差一些,慢一些,因為要在原生代碼中插入許多記憶體屏障指令來禁止指令重排序,保證處理器不發生程式碼亂序執行行為。

long和double變數的特殊規則

Java記憶體模型要求對主記憶體和工作記憶體交換的八個動作是原子的,正如章節開頭所講,對long和double有一些特殊規則。八個動作中lock、unlock、read、load、use、assign、store、write對待32位的基本資料型別都是原子操作,對待long和double這兩個64位的資料,java虛擬機器規範對java記憶體模型的規定中特別定義了一條相對寬鬆的規則:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,也就是允許虛擬機器不保證對64位資料的read、load、store和write這4個動作的操作是原子的。這也就是我們常說的long和double的非原子性協定(Nonautomic Treatment of double and long Variables)。

併發記憶體模型的實質

Java記憶體模型圍繞著併發過程中如何處理原子性、可見性和順序性這三個特徵來設計的。

原子性(Automicity)

由Java記憶體模型來直接保證原子性的變數操作包括read、load、use、assign、store、write這6個動作,雖然存在long和double的特例,但基本可以忽律不計,目前虛擬機器基本都對其實現了原子性。如果需要更大範圍的控制,lock和unlock也可以滿足需求。lock和unlock雖然沒有被虛擬機器直接開給使用者使用,但是提供了位元組碼層次的指令monitorenter和monitorexit對應這兩個操作,對應到java程式碼就是synchronized關鍵字,因此在synchronized塊之間的程式碼都具有原子性。

可見性

有序性從不同的角度來看是不同的。單純單執行緒來看都是有序的,但到了多執行緒就會跟我們預想的不一樣。可以這麼說:如果在本執行緒內部觀察,所有操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句說的就是“執行緒內表現為序列的語義”,後半句值得是“指令重排序”現象和主記憶體與工作記憶體之間同步存在延遲的現象。

保證有序性的關鍵字有volatile和synchronized,volatile禁止了指令重排序,而synchronized則由“一個變數在同一時刻只能被一個執行緒對其進行lock操作”來保證。

總體來看,synchronized對三種特性都有支援,雖然簡單,但是如果無控制的濫用對效能就會產生較大影響。

先行發生原則

如果Java記憶體模型中所有的有序性都要依靠volatile和synchronized來實現,那是不是非常繁瑣。Java語言中有一個“先行發生原則”,是判斷資料是否存在競爭、執行緒是否安全的主要依據。

什麼是先行發生原則

先行發生原則是Java記憶體模型中定義的兩個操作之間的偏序關係。比如說操作A先行發生於操作B,那麼在B操作發生之前,A操作產生的“影響”都會被操作B感知到。這裡的影響是指修改了記憶體中的共享變數、傳送了訊息、呼叫了方法等。個人覺得更直白一些就是有可能對操作B的結果有影響的都會被B感知到,對B操作的結果沒有影響的是否感知到沒有太大關係。

Java記憶體模型自帶先行發生原則有哪些

  • 程式次序原則
    在一個執行緒內部,按照程式碼的順序,書寫在前面的先行發生與後邊的。或者更準確的說是在控制流順序前面的先行發生與控制流後面的,而不是程式碼順序,因為會有分支、跳轉、迴圈等。
  • 管程鎖定規則
    一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須注意的是對同一個鎖,後面是指時間上的後面
  • volatile變數規則
    對一個volatile變數的寫操作先行發生與後面對這個變數的讀操作,這裡的後面是指時間上的先後順序
  • 執行緒啟動規則
    Thread物件的start()方法先行發生與該執行緒的每個動作。當然如果你錯誤的使用了執行緒,建立執行緒後沒有執行start方法,而是執行run方法,那此句話是不成立的,但是如果這樣其實也不是執行緒了
  • 執行緒終止規則
    執行緒中的所有操作都先行發生與對此執行緒的終止檢測,可以通過Thread.join()和Thread.isAlive()的返回值等手段檢測執行緒是否已經終止執行
  • 執行緒中斷規則
    對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則
    一個物件的初始化完成先行發生於他的finalize方法的執行,也就是初始化方法先行發生於finalize方法
  • 傳遞性
    如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A先行發生於操作C。
    看一個例子:
private int value = 0;
public void setValue(int value) {
  this.value = value;
}
public int getValue() {
  return this.value;
}

如果有兩個執行緒A和B,A先呼叫setValue方法,然後B呼叫getValue方法,那麼B執行緒執行方法返回的結果是什麼?

我們去對照先行發生原則一個一個對比。首先是程式次序規則,這裡是多執行緒,不在一個執行緒中,不適用;然後是管程鎖定規則,這裡沒有synchronized,自然不會發生lock和unlock,不適用;後面對於執行緒啟動規則、執行緒終止規則、執行緒中斷規則也不適用,這裡與物件終結規則、傳遞性規則也沒有關係。所以說B返回的結果是不確定的,也就是說在多執行緒環境下該操作不是執行緒安全的。

如何修改呢,一個是對get/set方法加入synchronized 關鍵字,可以使用管程鎖定規則;要麼對value加volatile修飾,可以使用volatile變數規則。

通過上面的例子可知,一個操作時間上先發生並不代表這個操作先行發生,那麼一個操作先行發生是不是代表這個操作在時間上先發生?也不是,如下面的例子:

int i = 2;
int j = 1;

在同一個執行緒內,對i的賦值先行發生於對j賦值的操作,但是程式碼重排序優化,也有可能是j的賦值先發生,我們無法感知到這一變化。

所以,綜上所述,時間先後順序與先行發生原則之間基本沒有太大關係。我們衡量併發安全的問題的時候不要受到時間先後順序的干擾,一切以先行發生原則為準。

作者:_fan凡
https://www.jianshu.com/p/15106e9c4bf3

歡迎關注我的微信公眾號「碼農突圍」,分享Python、Java、大資料、機器學習、人工智慧等技術,關注碼農技術提升•職場突圍•思維躍遷,20萬+碼農成長充電第一站,陪有夢想的你一起成長