1. 程式人生 > >【深入理解JVM】:Java記憶體模型JMM

【深入理解JVM】:Java記憶體模型JMM

多工和高併發的記憶體互動

多工和高併發是衡量一臺計算機處理器的能力重要指標之一。一般衡量一個伺服器效能的高低好壞,使用每秒事務處理數(Transactions Per Second,TPS)這個指標比較能說明問題,它代表著一秒內伺服器平均能響應的請求數,而TPS值與程式的併發能力有著非常密切的關係。物理機的併發問題與虛擬機器中的情況有很多相似之處,物理機對併發的處理方案對於虛擬機器的實現也有相當大的參考意義。

由於計算機的儲存裝置與處理器的運算能力之間有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無需等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而他們又共享同一主存,如下圖所示:多個處理器運算任務都涉及同一塊主存,需要一種協議可以保障資料的一致性,這類協議有MSI、MESI、MOSI及Dragon Protocol等。

物理機記憶體互動關係

除此之外,為了使得處理器內部的運算單元能儘可能被充分利用,處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將對亂序執行的程式碼進行結果重組,保證結果準確性。與處理器的亂序執行優化類似,Java虛擬機器的即時編譯器中也有類似的指令重排序(Instruction Recorder)

優化。

Java記憶體模型

記憶體模型可以理解為在特定的操作協議下,對特定的記憶體或者快取記憶體進行讀寫訪問的過程抽象,不同架構下的物理機擁有不一樣的記憶體模型,Java虛擬機器也有自己的記憶體模型,即Java記憶體模型(Java Memory Model, JMM)。在C/C++語言中直接使用物理硬體和作業系統記憶體模型,導致不同平臺下併發訪問出錯。而JMM的出現,能夠遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現平臺一致性,是的Java程式能夠“一次編寫,到處執行”。

主記憶體和工作記憶體

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣底層細節。此處的變數與Java程式設計時所說的變數不一樣,指包括了例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數與方法引數,後者是執行緒私有的,不會被共享。

Java記憶體模型中規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體(可以與前面講的處理器的快取記憶體類比),執行緒的工作記憶體中儲存了該執行緒使用到的變數到主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要在主記憶體來完成,執行緒、主記憶體和工作記憶體的互動關係如下圖所示,和上圖很類似。

虛擬機器記憶體互動關係

注意:這裡的主記憶體、工作記憶體與Java記憶體區域的Java堆、棧、方法區不是同一層次記憶體劃分,這兩者基本上沒有關係。

記憶體互動操作

由上面的互動關係可知,關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完成:

  • lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。
  • unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間,store和write之間是可以插入其他指令的,如對主記憶體中的變數a、b進行訪問時,可能的順序是read a,read b,load b, load a。Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個執行緒丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作,lock和unlock必須成對出現
  • 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值
  • 如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。

這8種記憶體訪問操作很繁瑣,後文會使用一個等效判斷原則,即先行發生(happens-before)原則來確定一個記憶體訪問在併發環境下是否安全。

volatile變數規則

關鍵字volatile是JVM中最輕量的同步機制。volatile變數具有2種特性:

  • 保證變數的可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入,這個新值對於其他執行緒來說是立即可見的。
  • 遮蔽指令重排序:指令重排序是編譯器和處理器為了高效對程式進行優化的手段,下文有詳細的分析。

volatile語義並不能保證變數的原子性。對任意單個volatile變數的讀/寫具有原子性,但類似於i++、i–這種複合操作不具有原子性,因為自增運算包括讀取i的值、i值增加1、重新賦值3步操作,並不具備原子性。

由於volatile只能保證變數的可見性和遮蔽指令重排序,只有滿足下面2條規則時,才能使用volatile來保證併發安全,否則就需要加鎖(使用synchronized、lock或者java.util.concurrent中的Atomic原子類)來保證併發中的原子性。

  • 運算結果不存在資料依賴(重排序的資料依賴性),或者只有單一的執行緒修改變數的值(重排序的as-if-serial語義)
  • 變數不需要與其他的狀態變數共同參與不變約束

因為需要在原生代碼中插入許多記憶體遮蔽指令在遮蔽特定條件下的重排序,volatile變數的寫操作與讀操作相比慢一些,但是其效能開銷比鎖低很多。

long/double非原子協定

JMM要求lock、unlock、read、load、assign、use、store、write這8個操作都必須具有原子性,但對於64為的資料型別(long和double,具有非原子協定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為2次32位操作進行。(與此類似的是,在棧幀結構的區域性變量表中,long和double型別的區域性變數可以使用2個能儲存32位變數的變數槽(Variable Slot)來儲存的,關於這一部分的詳細分析,詳見詳見周志明著《深入理解Java虛擬機器》8.2.1節)

如果多個執行緒共享一個沒有宣告為volatile的long或double變數,並且同時讀取和修改,某些執行緒可能會讀取到一個既非原值,也不是其他執行緒修改值的代表了“半個變數”的數值。不過這種情況十分罕見。因為非原子協議換句話說,同樣允許long和double的讀寫操作實現為原子操作,並且目前絕大多數的虛擬機器都是這樣做的。

原子性、可見性、有序性

原子性

JMM保證的原子性變數操作包括read、load、assign、use、store、write,而long、double非原子協定導致的非原子性操作基本可以忽略。如果需要對更大範圍的程式碼實行原子性操作,則需要JMM提供的lock、unlock、synchronized等來保證。

可見性

前面分析volatile語義時已經提到,可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。JMM在變數修改後將新值同步回主記憶體,依賴主記憶體作為媒介,在變數被執行緒讀取前從記憶體重新整理變數新值,保證變數的可見性。普通變數和volatile變數都是如此,只不過volatile的特殊規則保證了這種可見性是立即得知的,而普通變數並不具備這種嚴格的可見性。除了volatile外,synchronized和final也能保證可見性。

有序性

JMM的有序性表現為:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句指“執行緒內表現為序列的語義”(as-if-serial),後半句值“指令重排序”和普通變數的”工作記憶體與主記憶體同步延遲“的現象。

重排序

在執行程式時為了提高效能,編譯器和處理器經常會對指令進行重排序。從硬體架構上來說,指令重排序是指CPU採用了允許將多條指令不按照程式規定的順序,分開發送給各個相應電路單元處理,而不是指令任意重排。重排序分成三種類型:

  • 編譯器優化的重排序。編譯器在不改變單執行緒程式語義放入前提下,可以重新安排語句的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  • 記憶體系統的重排序。由於處理器使用快取和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

重排序

JMM的重排序屏障

從Java原始碼到最終實際執行的指令序列,會經過三種重排序。但是,為了保證記憶體的可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。對於編譯器的重排序,JMM會根據重排序規則禁止特定型別的編譯器重排序;對於處理器重排序,JMM會插入特定型別的記憶體屏障,通過記憶體的屏障指令禁止特定型別的處理器重排序。這裡討論JMM對處理器的重排序,為了更深理解JMM對處理器重排序的處理,先來認識一下常見處理器的重排序規則:

重排序屏障

其中的N標識處理器不允許兩個操作進行重排序,Y表示允許。其中Load-Load表示讀-讀操作、Load-Store表示讀-寫操作、Store-Store表示寫-寫操作、Store-Load表示寫-讀操作。可以看出:常見處理器對寫-讀操作都是允許重排序的,並且常見的處理器都不允許對存在資料依賴的操作進行重排序(對應上面資料轉換那一列,都是N,所以處理器不允許這種重排序)。

那麼這個結論對我們有什麼作用呢?比如第一點:處理器允許寫-讀操作兩者之間的重排序,那麼在併發程式設計中讀執行緒讀到可能是一個未被初始化或者是一個NULL等,出現不可預知的錯誤,基於這點,JMM會在適當的位置插入記憶體屏障指令來禁止特定型別的處理器的重排序。記憶體屏障指令一共有4類:

  • LoadLoad Barriers:確保Load1資料的裝載先於Load2以及所有後續裝載指令
  • StoreStore Barriers:確保Store1的資料對其他處理器可見(會使快取行無效,並重新整理到記憶體中)先於Store2及所有後續儲存指令的裝載
  • LoadStore Barriers:確保Load1資料裝載先於Store2及所有後續儲存指令重新整理到記憶體
  • StoreLoad Barriers:確保Store1資料對其他處理器可見(重新整理到記憶體,並且其他處理器的快取行無效)先於Load2及所有後續裝載指令的裝載。該指令會使得該屏障之前的所有記憶體訪問指令完成之後,才能執行該屏障之後的記憶體訪問指令。

資料依賴性

根據上面的表格,處理器不會對存在資料依賴的操作進行重排序。這裡資料依賴的準確定義是:如果兩個操作同時訪問一個變數,其中一個操作是寫操作,此時這兩個操作就構成了資料依賴。常見的具有這個特性的如i++、i—。如果改變了具有資料依賴的兩個操作的執行順序,那麼最後的執行結果就會被改變。這也是不能進行重排序的原因。例如:

  • 寫後讀:a = 1; b = a;
  • 寫後寫:a = 1; a = 2;
  • 讀後寫:a = b; b = 1;

重排序遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序。但是這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮。

as-if-serial語義

as-if-serial語義的意思指:管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。

as-if-serial語義把單執行緒程式保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單執行緒程式的程式設計師建立了一個幻覺:單執行緒程式是按程式的順序來執行的。as-if-serial語義使單執行緒程式設計師無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題。

重排序對多執行緒的影響

如果程式碼中存在控制依賴的時候,會影響指令序列執行的並行度(因為高效)。也是為此,編譯器和處理器會採用猜測(Speculation)執行來克服控制的相關性。所以重排序破壞了程式順序規則(該規則是說指令執行順序與實際程式碼的執行順序是一致的,但是處理器和編譯器會進行重排序,只要最後的結果不會改變,該重排序就是合理的)。

在單執行緒程式中,由於as-ifserial語義的存在,對存在控制依賴的操作重排序,不會改變執行結果;但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

先行發生原則(happens-before)

前面所述的記憶體互動操作必須要滿足一定的規則,而happens-before就是定義這些規則的一個等效判斷原則。happens-before是JMM定義的2個操作之間的偏序關係:如果操作A線性發生於操作B,則A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。如果兩個操作滿足happens-before原則,那麼不需要進行同步操作,JVM能夠保證操作具有順序性,此時不能夠隨意的重排序。否則,無法保證順序性,就能進行指令的重排序。

happens-before原則主要包括:

  • 程式次序規則(Program Order Rule):在同一個執行緒中,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操縱。準確的說是程式的控制流順序,考慮分支和迴圈等。
  • 管理鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面(時間上的順序)對同一個鎖的lock操作。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面(時間上的順序)對該變數的讀操作。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則(Thread Termination Rule):執行緒的所有操作都先行發生於對此執行緒的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷時事件的發生。Thread.interrupted()可以檢測是否有中斷髮生。
  • 物件終結規則(Finilizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()的開始。
  • 傳遞性(Transitivity):如果操作A 先行發生於操作B,操作B 先行發生於操作C,那麼可以得出A 先行發生於操作C。

注意:不同操作時間先後順序與先行發生原則之間沒有關係,二者不能相互推斷,衡量併發安全問題不能受到時間順序的干擾,一切都要以happens-before原則為準

示例程式碼1:

private int value = 0;

public void setValue(int value) {
    this.value = value;
}

public int getValue() {
    return this.value;
}

對於上面的程式碼,假設執行緒A在時間上先呼叫setValue(1),然後執行緒B呼叫getValue()方法,那麼執行緒B收到的返回值一定是1嗎?

按照happens-before原則,兩個操作不在同一個執行緒、沒有通道鎖同步、執行緒的相關啟動、終止和中斷以及物件終結和傳遞性等規則都與此處沒有關係,因此這兩個操作是不符合happens-before原則的,這裡的併發操作是不安全的,返回值並不一定是1。

對於該問題的修復,可以使用lock或者synchronized套用“管程鎖定規則”實現先行發生關係;或者將value定義為volatile變數(兩個方法的呼叫都不存在資料依賴性),套用“volatile變數規則”實現先行發生關係。如此一來,就能保證併發安全性。

示例程式碼2

// 以下操作在同一個執行緒中
int i = 1;
int j = 2;

上面的程式碼符合“程式次序規則”,滿足先行發生關係,但是第2條語句完全可能由於重排序而被處理器先執行,時間上先於第1條語句。