深刻理解 Java 內存模型,這篇文章就夠了!

Java內存模型(Java Memory Model,JMM)是java虛擬機規範定義的,用來屏蔽掉java程序在各類不一樣的硬件和操做系統對內存的訪問的差別,這樣就能夠實現java程序在各類不一樣的平臺上都能達到內存訪問的一致性。能夠避免像c++等直接使用物理硬件和操做系統的內存模型在不一樣操做系統和硬件平臺下表現不一樣,好比有些c/c++程序可能在windows平臺運行正常,而在linux平臺卻運行有問題。
java

物理硬件和內存

首先,在單核電腦中,處理問題要簡單的多。對內存和硬件的要求,各類方面的考慮沒有在多核的狀況下複雜。電腦中,CPU的運行計算速度是很是快的,而其餘硬件好比IO,網絡、內存讀取等等,跟cpu的速度比起來是差幾個數量級的。而無論任何操做,幾乎是不可能都在cpu中完成而不借助於任何其餘硬件操做。因此協調cpu和各個硬件之間的速度差別是很是重要的,要否則cpu就一直在等待,浪費資源。而在多核中,不只面臨如上問題,還有若是多個核用到了同一個數據,如何保證數據的一致性、正確性等問題,也是必需要解決的。linux

目前基於高速緩存的存儲交互很好的解決了cpu和內存等其餘硬件之間的速度矛盾,多核狀況下各個處理器(核)都要遵循必定的諸如MSI、MESI等協議來保證內存的各個處理器高速緩存和主內存的數據的一致性。c++

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

除了增長高速緩存,爲了使處理器內部運算單元儘量被充分利用,處理器還會對輸入的代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在亂序執行以後的結果進行重組,保證結果的正確性,也就是保證結果與順序執行的結果一致。可是在真正的執行過程當中,代碼執行的順序並不必定按照代碼的書寫順序來執行,可能和代碼的書寫順序不一樣。程序員

Java內存模型

雖然java程序全部的運行都是在虛擬機中,涉及到的內存等信息都是虛擬機的一部分,但實際也是物理機的,只不過是虛擬機做爲最外層的容器統一作了處理。虛擬機的內存模型,以及多線程的場景下與物理機的狀況是很類似的,能夠類比參考。面試

Java內存模型的主要目標是定義程序中變量的訪問規則。即在虛擬機中將變量存儲到主內存或者將變量從主內存取出這樣的底層細節。須要注意的是這裏的變量跟咱們寫java程序中的變量不是徹底等同的。這裏的變量是指實例字段,靜態字段,構成數組對象的元素,可是不包括局部變量和方法參數(由於這是線程私有的)。這裏能夠簡單的認爲主內存是java虛擬機內存區域中的堆,局部變量和方法參數是在虛擬機棧中定義的。可是在堆中的變量若是在多線程中都使用,就涉及到了堆和不一樣虛擬機棧中變量的值的一致性問題了。
Java內存模型中涉及到的概念有:算法

  • 主內存:Java 虛擬機規定全部的變量(不是程序中的變量)都必須在主內存中產生,爲了方便理解,能夠認爲是堆區。能夠與前面說的物理機的主內存相比,只不過物理機的主內存是整個機器的內存,而虛擬機的主內存是虛擬機內存中的一部分。windows

  • 工做內存:Java 虛擬機中每一個線程都有本身的工做內存,該內存是線程私有的爲了方便理解,能夠認爲是虛擬機棧。能夠與前面說的高速緩存相比。線程的工做內存保存了線程須要的變量在主內存中的副本。虛擬機規定,線程對主內存變量的修改必須在線程的工做內存中進行,不能直接讀寫主內存中的變量。不一樣的線程之間也不能相互訪問對方的工做內存。若是線程之間須要傳遞變量的值,必須經過主內存來做爲中介進行傳遞。數組

這裏須要說明一下:主內存、工做內存與java內存區域中的java堆、虛擬機棧、方法區並非一個層次的內存劃分。這二者是基本上是沒有關係的,上文只是爲了便於理解,作的類比緩存

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

工做內存與主內存交互

物理機高速緩存和主內存之間的交互有協議,一樣的,java內存中線程的工做內存和主內存的交互是由java虛擬機定義了以下的8種操做來完成的,每種操做必須是原子性的(double和long類型在某些平臺有例外,參考volatile詳解和非原子性協定)安全

java虛擬機中主內存和工做內存交互,就是一個變量如何從主內存傳輸到工做內存中,如何把修改後的變量從工做內存同步回主內存。

搜索公衆號程序員小樂回覆關鍵字「offer」獲取算法面試題和答案。

  • 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包中的鎖)。可是如今因爲虛擬機對鎖的不斷優化和實行的許多消除動做,很難有一個量化的比較。

搜索公衆號程序員小樂回覆關鍵字「Java」獲取Java面試題和答案。

與本身相比,就能夠肯定一個原則: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的賦值先發生,咱們沒法感知到這一變化。

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

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

相關文章
相關標籤/搜索