深刻理解JVM:Java內存模型JMM

多任務和高併發的內存交互

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

因爲計算機的存儲設備與處理器的運算能力之間有幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存(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值增長一、從新賦值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:歡迎點擊連接加入羣【Java併發編程交流組】:https://jq.qq.com/?_wv=1027&k=5mOvK7L

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條語句,歡迎點擊連接加入羣【Java併發編程交流組】:https://jq.qq.com/?_wv=1027&k=5mOvK7L

相關文章
相關標籤/搜索