Java內存模型(JMM Java Memory Model)探究

Java內存模型

咱們常說的JVM內存模式指的是JVM的內存分區;而Java內存模式是一種虛擬機規範,真實並不存在 Java虛擬機規範中定義了Java內存模型(Java Memory Model,JMM),用於屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的併發效果,JMM規範了Java虛擬機與計算機內存是如何協同工做的:規定了一個線程如何和什麼時候能夠看到由其餘線程修改事後的共享變量的值,以及在必須時如何同步的訪問共享變量。html

原始的Java內存模型存在一些不足,所以Java內存模型在Java1.5時被從新修訂。這個版本的Java內存模型在Java8中仍然在使用。java

Java內存模型(不只僅是JVM內存分區):調用棧和本地變量存放在線程棧上,對象存放在堆上。程序員

內容模型示意圖 硬件角度示意圖 編程

  • 一個本地變量多是原始類型,在這種狀況下,它老是「呆在」線程棧上。
  • 一個本地變量也多是指向一個對象的一個引用。在這種狀況下,引用(這個本地變量)存放在線程棧上,可是對象自己存放在堆上。
  • 一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量仍然存放在線程棧上,即便這些方法所屬的對象存放在堆上
  • 一個對象的成員變量可能隨着這個對象自身存放在堆上。無論這個成員變量是原始類型仍是引用類型。
  • 靜態成員變量跟隨着類定義一塊兒也存放在堆上。
  • 存放在堆上的對象能夠被全部持有對這個對象引用的線程訪問。當一個線程能夠訪問一個對象時,它也能夠訪問這個對象的成員變量。若是兩個線程同時調用同一個對象上的同一個方法,它們將會都訪問這個對象的成員變量,可是每個線程都擁有這個成員變量的私有拷貝。

硬件內存架構

現代硬件內存模型與Java內存模型有一些不一樣,理解內存模型架構以及Java內存模型如何與它協同工做也是很是重要的。緩存

現代計算機硬件架構的簡單圖示: 多線程

  • 多CPU:一個現代計算機一般由兩個或者多個CPU。其中一些CPU還有多核。從這一點能夠看出,在一個有兩個或者多個CPU的現代計算機上同時運行多個線程是可能的。每一個CPU在某一時刻運行一個線程是沒有問題的。這意味着,若是你的Java程序是多線程的,在你的Java程序中每一個CPU上一個線程可能同時(併發)執行。
  • CPU寄存器:每一個CPU都包含一系列的寄存器,它們是CPU內內存的基礎。CPU在寄存器上執行操做的速度遠大於在主存上執行的速度。這是由於CPU訪問寄存器的速度遠大於主存。
  • 高速緩存cache:因爲計算機的存儲設備與處理器的運算速度之間有着幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存(Cache)來做爲內存與處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。CPU訪問緩存層的速度快於訪問主存的速度,但一般比訪問內部寄存器的速度還要慢一點。每一個CPU可能有一個CPU緩存層,一些CPU還有多層緩存。在某一時刻,一個或者多個緩存行(cache lines)可能被讀到緩存,一個或者多個緩存行可能再被刷新回主存。
  • 內存:一個計算機還包含一個主存。全部的CPU均可以訪問主存。主存一般比CPU中的緩存大得多。
  • 運做原理:一般狀況下,當一個CPU須要讀取主存時,它會將主存的部分讀到CPU緩存中。它甚至可能將緩存中的部份內容讀到它的內部寄存器中,而後在寄存器中執行操做。當CPU須要將結果寫回到主存中去時,它會將內部寄存器的值刷新到緩存中,而後在某個時間點將值刷新回主存。

一些問題:(尤爲是多線程環境下)架構

  • 緩存一致性問題:在多處理器系統中,每一個處理器都有本身的高速緩存,而它們又共享同一主內存(MainMemory)。基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,可是也引入了新的問題:緩存一致性(CacheCoherence)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致的狀況,若是真的發生這種狀況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做,這類協議有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等: 併發

  • 指令重排序問題:爲了使得處理器內部的運算單元能儘可能被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算以後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的前後順序與輸入代碼中的順序一致。所以,若是存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的前後順序來保證。與處理器的亂序執行優化相似,Java虛擬機的即時編譯器中也有相似的指令重排序(Instruction Reorder)優化app

Java內存模型和硬件內存架構之間的橋接

Java內存模型與硬件內存架構之間存在差別。硬件內存架構沒有區分線程棧和堆。對於硬件,全部的線程棧和堆都分佈在主內存中。部分線程棧和堆可能有時候會出如今CPU緩存中和CPU內部的寄存器中。以下圖所示: 優化

  • 從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:
  • 線程之間的共享變量存儲在主內存(Main Memory)中
  • 每一個線程都有一個私有的本地內存(Local Memory),本地內存是JMM的一個抽象概念,並不真實存在,它涵蓋了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化。本地內存中存儲了該線程以讀/寫共享變量的拷貝副本。
  • 從更低的層次來講,主內存就是硬件的內存,而爲了獲取更好的運行速度,虛擬機及硬件系統可能會讓工做內存優先存儲於寄存器和高速緩存中。
  • Java內存模型中的線程的工做內存(working memory)是cpu的寄存器和高速緩存的抽象描述。而JVM的靜態內存儲模型(JVM內存模型)只是一種對內存的物理劃分而已,它只侷限在內存,並且只侷限在JVM的內存。

JMM模型下的線程間通訊:

線程間通訊必需要通過主內存。 以下,若是線程A與線程B之間要通訊的話,必需要經歷下面2個步驟:

1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。

2)線程B到主內存中去讀取線程A以前已更新過的共享變量。

關於主內存與工做內存之間的具體交互協議,即一個變量如何從主內存拷貝到工做內存、如何從工做內存同步到主內存之間的實現細節,Java內存模型定義瞭如下八種操做來完成:

  • lock(鎖定):做用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
  • unlock(解鎖):做用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定。
  • read(讀取):做用於主內存變量,把一個變量值從主內存傳輸到線程的工做內存中,以便隨後的load動做使用
  • load(載入):做用於工做內存的變量,它把read操做從主內存中獲得的變量值放入工做內存的變量副本中。
  • use(使用):做用於工做內存的變量,把工做內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個須要使用變量的值的字節碼指令時將會執行這個操做。
  • assign(賦值):做用於工做內存的變量,它把一個從執行引擎接收到的值賦值給工做內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操做。
  • store(存儲):做用於工做內存的變量,把工做內存中的一個變量的值傳送到主內存中,以便隨後的write的操做。
  • write(寫入):做用於主內存的變量,它把store操做從工做內存中一個變量的值傳送到主內存的變量中。

Java內存模型還規定了在執行上述八種基本操做時,必須知足以下規則:

  • 若是要把一個變量從主內存中複製到工做內存,就須要按順序地執行read和load操做, 若是把變量從工做內存中同步回主內存中,就要按順序地執行store和write操做。但Java內存模型只要求上述操做必須按順序執行,而沒有保證必須是連續執行。
  • 不容許read和load、store和write操做之一單獨出現
  • 不容許一個線程丟棄它的最近assign的操做,即變量在工做內存中改變了以後必須同步到主內存中。
  • 不容許一個線程無緣由地(沒有發生過任何assign操做)把數據從工做內存同步回主內存中。
  • 一個新的變量只能在主內存中誕生,不容許在工做內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操做以前,必須先執行過了assign和load操做。
  • 一個變量在同一時刻只容許一條線程對其進行lock操做,但lock操做能夠被同一條線程重複執行屢次,屢次執行lock後,只有執行相同次數的unlock操做,變量纔會被解鎖。即lock和unlock必須成對出現!
  • 若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量前須要從新執行load或assign操做初始化變量的值
  • 若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做;也不容許去unlock一個被其餘線程鎖定的變量。
  • 對一個變量執行unlock操做以前,必須先把此變量同步到主內存中(執行store和write操做)。

Java內存模型解決的問題

當對象和變量被存放在計算機中各類不一樣的內存區域中時,就可能會出現一些具體的問題。Java內存模型創建所圍繞的問題:在多線程併發過程當中,如何處理多線程讀同步問題與可見性(多線程緩存與指令重排序)、多線程寫同步問題與原子性(多線程競爭race condition)。

一、多線程讀同步與可見性

可見性(共享對象可見性):線程對共享變量修改的可見性。當一個線程修改了共享變量的值,其餘線程可以馬上得知這個修改

二、線程緩存致使的可見性問題:

若是兩個或者更多的線程在沒有正確的使用volatile聲明或者同步的狀況下共享一個對象,一個線程更新這個共享對象可能對其它線程來講是不可見的:共享對象被初始化在主存中。跑在CPU上的一個線程將這個共享對象讀到CPU緩存中,而後修改了這個對象。只要CPU緩存沒有被刷新會主存,對象修改後的版本對跑在其它CPU上的線程都是不可見的。這種方式可能致使每一個線程擁有這個共享對象的私有拷貝,每一個拷貝停留在不一樣的CPU緩存中。

下圖示意了這種情形。跑在左邊CPU的線程拷貝這個共享對象到它的CPU緩存中,而後將count變量的值修改成2。這個修改對跑在右邊CPU上的其它線程是不可見的,由於修改後的count的值尚未被刷新回主存中去。

解決這個內存可見性問題你可使用:

  • Java中的volatile關鍵字:volatile關鍵字能夠保證直接從主存中讀取一個變量,若是這個變量被修改後,老是會被寫回到主存中去。Java內存模型是經過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存做爲傳遞媒介的方式來實現可見性的,不管是普通變量仍是volatile變量都是如此,普通變量與volatile變量的區別是:volatile的特殊規則保證了新值能當即同步到主內存,以及每一個線程在每次使用volatile變量前都當即從主內存刷新。所以咱們能夠說volatile保證了多線程操做時變量的可見性,而普通變量則不能保證這一點,雖然它最終也會同步到主存,但這個時候可能有其餘線程使用的舊的「髒數據」。
  • Java中的synchronized關鍵字:同步塊的可見性是由「若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量前須要從新執行load或assign操做初始化變量的值」、「對一個變量執行unlock操做以前,必須先把此變量同步回主內存中(執行store和write操做)」這兩條規則得到的。
  • Java中的final關鍵字:final關鍵字的可見性是指,被final修飾的字段在構造器中一旦被初始化完成,而且構造器沒有把「this」的引用傳遞出去(this引用逃逸是一件很危險的事情,其餘線程有可能經過這個引用訪問到「初始化了一半」的對象),那麼在其餘線程就能看見final字段的值(無須同步)

三、重排序致使的可見性問題:

Java程序中自然的有序性能夠總結爲一句話:若是在本地線程內觀察,全部操做都是有序的(「線程內表現爲串行」(Within-Thread As-If-Serial Semantics));若是在一個線程中觀察另外一個線程,全部操做都是無序的(「指令重排序」現象和「線程工做內存與主內存同步延遲」現象)。

Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操做的有序性:

  • volatile關鍵字自己就包含了禁止指令重排序的語義
  • synchronized則是由「一個變量在同一個時刻只容許一條線程對其進行lock操做」這條規則得到的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入

指令序列的重排序:

1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。

2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。

3)內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。

每一個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這會致使處理器執行內存操做的順序可能會與內存實際的操做執行順序不一致。因爲現代的處理器都會使用寫緩衝區,所以現代的處理器都會容許對寫-讀操做進行重排序:

數據依賴: 編譯器和處理器在重排序時,會遵照數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操做的執行順序。(這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮)

指令重排序對內存可見性的影響

當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4相似)。這樣的結果就是:讀線程B執行4時,不必定能看到寫線程A在執行1時對共享變量的修改。

指令重排序改變多線程程序的執行結果例子:

flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操做4時,可否看到線程A在操做1對共享變量a的寫入呢?

答案是:不必定能看到。

因爲操做1和操做2沒有數據依賴關係,編譯器和處理器能夠對這兩個操做重排序;一樣,操做3和操做4沒有數據依賴關係,編譯器和處理器也能夠對這兩個操做重排序。

as-if-serial語義:

無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。(編譯器、runtime和處理器都必須遵照as-if-serial語義)

happens before:

從JDK 5開始,Java使用新的JSR-133內存模型,JSR-133使用happens-before的概念來闡述操做之間的內存可見性:在JMM中,若是一個操做執行的結果須要對另外一個操做可見(兩個操做既能夠是在一個線程以內,也能夠是在不一樣線程之間),那麼這兩個操做之間必需要存在happens-before關係:

  • 程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  • 傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。

一個happens-before規則對應於一個或多個編譯器和處理器重排序規則

內存屏障禁止特定類型的處理器重排序:

重排序可能會致使多線程程序出現內存可見性問題。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,經過內存屏障指令來禁止特定類型的處理器重排序。經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。

StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。現代的多處理器大多支持該屏障(其餘類型的屏障不必定被全部處理器支持)。執行該屏障開銷會很昂貴,由於當前處理器一般要把寫緩衝區中的數據所有刷新到內存中(Buffer Fully Flush)。

多線程寫同步與原子性

多線程競爭(Race Conditions)問題:當讀,寫和檢查共享變量時出現race conditions。

若是兩個或者更多的線程共享一個對象,多個線程在這個共享對象上更新變量,就有可能發生race conditions。

想象一下,若是線程A讀一個共享對象的變量count到它的CPU緩存中。再想象一下,線程B也作了一樣的事情,可是往一個不一樣的CPU緩存中。如今線程A將count加1,線程B也作了一樣的事情。如今count已經被增長了兩次,每一個CPU緩存中一次。若是這些增長操做被順序的執行,變量count應該被增長兩次,而後原值+2被寫回到主存中去。然而,兩次增長都是在沒有適當的同步下併發執行的。不管是線程A仍是線程B將count修改後的版本寫回到主存中取,修改後的值僅會被原值大1,儘管增長了兩次:

解決這個問題可使用Java同步塊。一個同步塊能夠保證在同一時刻僅有一個線程能夠進入代碼的臨界區。同步塊還能夠保證代碼塊中全部被訪問的變量將會從主存中讀入,當線程退出同步代碼塊時,全部被更新的變量都會被刷新回主存中去,無論這個變量是否被聲明爲volatile。

使用原子性保證多線程寫同步問題:

原子性:指一個操做是按原子的方式執行的。要麼該操做不被執行;要麼以原子方式執行,即執行過程當中不會被其它線程中斷。

實現原子性:

  • 由Java內存模型來直接保證的原子性變量操做包括read、load、assign、use、store、write,咱們大體能夠認爲基本數據類型變量、引用類型變量、聲明爲volatile的任何類型變量的訪問讀寫是具有原子性的(long和double的非原子性協定:對於64位的數據,如long和double,Java內存模型規範容許虛擬機將沒有被volatile修飾的64位數據的讀寫操做劃分爲兩次32位的操做來進行,即容許虛擬機實現選擇能夠不保證64位數據類型的load、store、read和write這四個操做的原子性,即若是有多個線程共享一個並未聲明爲volatile的long或double類型的變量,而且同時對它們進行讀取和修改操做,那麼某些線程可能會讀取到一個既非原值,也不是其餘線程修改值的表明了「半個變量」的數值。但因爲目前各類平臺下的商用虛擬機幾乎都選擇把64位數據的讀寫操做做爲原子操做來對待,所以在編寫代碼時通常也不須要將用到的long和double變量專門聲明爲volatile)。這些類型變量的讀、寫自然具備原子性,但相似於 「基本變量++」 / 「volatile++」 這種複合操做並無原子性。
  • 若是應用場景須要一個更大範圍的原子性保證,須要使用同步塊技術。Java內存模型提供了lock和unlock操做來知足這種需求。虛擬機提供了字節碼指令monitorenter和monitorexist來隱式地使用這兩個操做,這兩個字節碼指令反映到Java代碼中就是同步快——synchronized關鍵字。

JMM對特殊Java語義的特殊規則支持

volatile總結 (保證內存可見性:Lock前綴的指令、內存屏障禁止重排序)

synchronized總結 (保證內存可見性和操做原子性:互斥鎖;鎖優化)

參考資料:Java內存模型 《Java併發編程的藝術》 《深刻理解Java內存模型》 《深刻理解Java虛擬機》

相關文章
相關標籤/搜索