java內存模型以及valatile關鍵字

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

物理硬件和內存

首先,在單核電腦中,處理問題要簡單的多。對內存和硬件的要求,各種方面的考慮沒有在多核的情況下複雜。電腦中,CPU的運行計算速度是非常快的,而其他硬件比如IO,網絡、內存讀取等等,跟cpu的速度比起來是差幾個數量級的。而不管任何操作,幾乎是不可能都在cpu中完成而不藉助於任何其他硬件操作。所以協調cpu和各個硬件之間的速度差異是非常重要的,要不然cpu就一直在等待,浪費資源。而在多核中,不僅面臨如上問題,還有如果多個核用到了同一個數據,如何保證數據的一致性、正確性等問題,也是必須要解決的。
目前基於高速緩存的存儲交互很好的解決了cpu和內存等其他硬件之間的速度矛盾,多核情況下各個處理器(核)都要遵循一定的諸如MSI、MESI等協議來保證內存的各個處理器高速緩存和主內存的數據的一致性。

 

image.png

 

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

java內存模型

雖然java程序所有的運行都是在虛擬機中,涉及到的內存等信息都是虛擬機的一部分,但實際也是物理機的,只不過是虛擬機作爲最外層的容器統一做了處理。虛擬機的內存模型,以及多線程的場景下與物理機的情況是很相似的,可以類比參考。
Java內存模型的主要目標是定義程序中變量的訪問規則。即在虛擬機中將變量存儲到主內存或者將變量從主內存取出這樣的底層細節。需要注意的是這裏的變量跟我們寫java程序中的變量不是完全等同的。這裏的變量是指實例字段,靜態字段,構成數組對象的元素,但是不包括局部變量和方法參數(因爲這是線程私有的)。這裏可以簡單的認爲主內存是java虛擬機內存區域中的堆,局部變量和方法參數是在虛擬機棧中定義的。但是在堆中的變量如果在多線程中都使用,就涉及到了堆和不同虛擬機棧中變量的值的一致性問題了。
Java內存模型中涉及到的概念有:

  • 主內存:java虛擬機規定所有的變量(不是程序中的變量)都必須在主內存中產生,爲了方便理解,可以認爲是堆區。可以與前面說的物理機的主內存相比,只不過物理機的主內存是整個機器的內存,而虛擬機的主內存是虛擬機內存中的一部分。
  • 工作內存:java虛擬機中每個線程都有自己的工作內存,該內存是線程私有的爲了方便理解,可以認爲是虛擬機棧。可以與前面說的高速緩存相比。線程的工作內存保存了線程需要的變量在主內存中的副本。虛擬機規定,線程對主內存變量的修改必須在線程的工作內存中進行,不能直接讀寫主內存中的變量。不同的線程之間也不能相互訪問對方的工作內存。如果線程之間需要傳遞變量的值,必須通過主內存來作爲中介進行傳遞。
    這裏需要說明一下:主內存、工作內存與java內存區域中的java堆、虛擬機棧、方法區並不是一個層次的內存劃分。這兩者是基本上是沒有關係的,上文只是爲了便於理解,做的類比

    image.png

工作內存與主內存交互

物理機高速緩存和主內存之間的交互有協議,同樣的,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類型的變量在修改後會立即同步給主內存,在使用的時候會從主內存重新讀取,是依賴主內存爲中介來保證多線程下變量對其他線程的可見性的。
除了volatile,synchronized和final也可以實現可見性。synchronized關鍵字是通過unlock之前必須把變量同步回主內存來實現的,final則是在初始化後就不會更改,所以只要在初始化過程中沒有把this指針傳遞出去也能保證對其他線程的可見性。

有序性

有序性從不同的角度來看是不同的。單純單線程來看都是有序的,但到了多線程就會跟我們預想的不一樣。可以這麼說:如果在本線程內部觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句說的就是“線程內表現爲串行的語義”,後半句值得是“指令重排序”現象和主內存與工作內存之間同步存在延遲的現象。
保證有序性的關鍵字有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##volatile%E4%BF%AE%E9%A5%B0%E7%9A%84%E5%8F%98%E9%87%8F%E7%9A%84%E7%89%B9%E6%AE%8A%E8%A7%84%E5%88%99
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章