JAVA內存模型與線程(一)

前言:

《深入理解jvm》差不多看完了重點的部分,揭祕下下一本書是《java
併發藝術》,繼續沖沖衝。

JAVA內存模型(JMM)

img

  • Java虛擬機規範中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。 C/C++等則直接使用物理硬件和操作系統的內存模型,因此,會由於不同平臺上內存模型的差異而導致程序的移植性比較差。

  • Java內存模型必須定義得足夠嚴謹,才能讓Java的併發內存訪問操作不會產生歧義;但是,也必須定義得足夠寬鬆,使得虛擬機的實現有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執行速度。

  • java內存模型規定了所有的變量都存儲在主內存(Main Memory,類比物理內存)。每條線程還有自己的工作內存(Working Memory,類比處理器高速緩存),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關係如圖12-2所示。

  • 主內存主要對應於Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。 從更低層次上說,主內存就直接對應於物理硬件的內存,而爲了獲取更好的運行速度,虛擬機(甚至是硬件系統本身的優化措施)可能會讓工作內存優先存儲於寄存器和高速緩存中,因爲程序運行時主要訪問讀寫的是工作內存。

需要注意的是:

  • 如果局部變量是一個reference類型,它引用的對象在Java堆中可被各個線程共享,但是reference本身在Java棧的局部變量表中,它是線程私有的
  • 如“假設線程中訪問一個10MB的對象,也會把這10MB的內存複製一份拷貝出來嗎?”,事實上並不會如此,這個對象的引用、對象中某個在線程訪問到的字段是有可能存在拷貝的,但不會有虛擬機實現成把整個對象拷貝一次。
  • Java虛擬機規範的規定,volatile變量依然有工作內存的拷貝,但是由於它特殊的操作順序性規定(後文會講到),所以看起來如同直接在主內存中讀寫訪問一般。
  • 除了實例數據,Java堆還保存了對象的其他信息,對於HotSpot虛擬機來講,有Mark Word(存儲對象哈希碼、 GC標誌、 GC年齡、 同步鎖等信息)、class Point(指向存儲類型元數據的指針)及一些用於字節對齊補白的填充數據(如果實例數據剛好滿足8字節對齊的話,則可以不存在補白)。

內存間的交互作用

一個變量

1、如何從主內存拷貝到工作內存?

2、如何從工作內存同步回主內存之類的實現細節?

Java內存模型中定義了以下8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子的、 不可再分的。

8種操作

  • lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔的狀態。
  • unclock(解鎖):作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
  • 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。

操作的規定

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存中讀取read了但工作內存不接受load,或者工作內存發起了回寫store但主內存不接受write的情況出現。
  • 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變assign了之後必須把該變化同步到主內存。
  • 不允許一個線程無原因地(沒有發生任何assign操作)把數據從線程的工作內存同步到主內存。
  • 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變量use、store操作之前,必須先執行過了assign和load操作。
  • 一個變量在同一時刻只允許一個線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unclock操作,變量纔會被解鎖。
  • 如果對一個變量執行lock操作,那麼會清空工作內存次變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
  • 如果對一個變量執行lock操作,那麼會清空工作內存次變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
    對一個變量執行unclock之前,必須先把此變量同步回主內存中(執行store、write操作)。

volatile

作用:

  • 保證此變量對於所有線程的可見性
  • 禁止指令衝排序優化
  • 保證了可見性,不保證原子性

實現原理:

  • 保證此變量對於所有線程的可見性

    • 每次用於變量的use動作 都必須和read、load關聯,就是每次將工作內存中的變量複製給執行引擎時候都從主存中獲取最新的值
    • 每次用於變量assign動作時候,都必須與store、write動作相關聯,就是每次將執行引擎中的變量複製到工作內存中是都把其刷新到主內存中
    • 上述亮點保證了每次使用工作內存中變量都是最新的值、變量賦值到工作內存的時候都刷新到主存中。
  • 禁止指令重排序優化

    • 何爲指令重排序?

      1.Java語言規範JVM線程內部維持順序花語義,即只要程序的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與代碼邏輯順序不一致,這個過程就叫做指令的重排序。

      2.指令重排序的意義:使指令更加符合CPU的執行特性,最大限度的發揮機器的性能,提高程序的執行效率。

      3.不管怎麼進行指令重排序,單線程內程序的執行結果不能被改變(但多線程中就不是這樣子了噢)

      int i = 0;               
      boolean flag = false;
      i = 1;                //語句1   
      flag = true;          //語句2
      

      上述代碼中 語句1 語句2誰先執行,在單線程中都沒有影響。那麼就有可能在執行過程中指令重排序,語句2先執行而語句1後執行。

      但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:

      int a = 10;    //語句1
      int r = 2;    //語句2
      a = a + 3;    //語句3
      r = a*a;	 //語句4
      

      執行的順序是 1234或者2134

      那麼可不可能是這個執行順序呢: 43

      那麼可不可能是這個執行順序呢: 語句2 語句1 語句4 語句3

      不可能,因爲處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。

      雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:

      //線程1:
      context = loadContext();   //語句1
      inited = true;             //語句2
       
      //線程2:
      while(!inited ){
        sleep() 
      }
      doSomethingwithconfig(context);
      

      上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以爲初始化工作已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

      從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性.

      當然上面是用大的語句來分析,其實指令重排序針對的並不僅僅語句,而是語句分解後的指令

      例如int a=300;

      這裏面就包含了 分配對象空間指令-1、對象初始化指令-2、賦值給引用變量指令-3

      正常來說是1 2 3 執行,但是1 3 2也是可以的,不影響最後的工作結果,在單線程這貌似沒什麼問題,但是在多線程就有大問題了,這在於單例模式中的雙重檢測裏面有重要體現,容我下篇博客再單獨分析。

    • 如何禁止?

      通過內存屏障,即在彙編代碼上加入lock前綴指令。

      ​ 何爲內存屏障

      ​ 觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令,此lock非jmm交互操作的lock

      ​ lock指令的作用是使本cpu的cache寫入內存,該寫入動作會引起其他cpu的cache無效化(緩存一致性)。通過這樣一個操作讓對於volatile變量的修改對於其他cpu可變。

      ​ lock指令把之前的cache都同步到內存中,等同於讓lock指令後面的指令依賴於lock指令前面的指令,根據處理器在進行重排序時是會考慮指令之間的數據依賴性,所以lock指令之前的指令不會跑到lock指令之後,之後的也不會跑到之前。

      這樣子lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

      ​ 1)它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;相當於分割線

      2)它會強制將對緩存的修改操作立即寫入主存;

      3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。

對於long和double型變量的特殊規則

​ 允許虛擬機將沒有被volatile修飾的64位數據類型(long和double)的讀取操作劃分爲兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性,就點就是long和double的非原子協定(Nonatomic Treatment of double and long Variables)。

​ 不過這種讀取帶“半個變量”的情況非常罕見(在目前商用虛擬機中不會出現),因爲Java內存模型雖然允許虛擬機不把long和double變量的讀寫實現成原子操作,但允許虛擬機選擇把這些操作實現爲具有原子性的操作,而且還“強烈建議”虛擬機這樣實現。默認都實現。

原子性、可見性、有序性

原子性:

原子性(Atomicity):由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write,我們大致可以認爲基本數據類型的訪問具備原子性(long和double例外)。總結起來就是要麼不執行,要麼就全部執行完。

​ 如果應用場景需要一個更大範圍的原子性保證,Java內存模型還提供了lock和unlock操作來滿足需求,儘管虛擬機未把lock和unlock操作直接開放給用戶,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反應到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性

可見性:

**可見性(Visibility):**指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。

除了volatile,Java還有兩個關鍵字能實現可見性,synchronized和final同步塊的可見性是由“對一個變量執行unlock操作之前,必須把此變量同步回主內存中(執行store和write操作)”這條規則獲得的,而final關鍵字的可見性是指:變成了常量了,每個線程中的都是一樣的,且不可修改

有序性:

**有序性(Ordering):**Java程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,所有的操作都是無序的。前半句是指“線程內表現爲串行的語義”(Within-Thread As-if-Serial Semantics),後半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。

Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一時刻只允許一條線程對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

先行發生原則

先行發生是Java內存模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,就是說A產生的影響能被B觀察到,”影響“包括修改了內存中的共享變量值、發送了消息、調用了方法等。

// 線程A中執行
i = 1;

// 線程B中執行
j = i;

// 線程C中執行
i = 2;

​ 如果說線程A是先行發生於線程B的,那麼可以確定在線程B執行之後 j=1,因爲根據先行發生原則,A操作 i = 1 的結果可以被B觀察到,並且線程C還沒有執行。

那麼如果線程C是在A與B之間,j 的值是多少呢?答案是不確定。此時就不滿足先行發生原則了

2. 自動實現先行發生的規則

以下是Java內存模型中天然的先行發生規則,對於不在此列的關係,就沒有順序性保障,虛擬機可以隨意的進行重排:

  • 程序次序規則:代碼執行順序符合流程控制順序。
  • 管程鎖定規則:unlock 操作先行發生於後面對同一個鎖的 lock 操作。
  • volatile 變量規則:對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作。
  • 線程啓動規則:線程對象 start() 方法先行發生於此線程的每一個動作。
  • 線程終止規則:線程中所有操作先行發生於對此線程的終止檢測。
  • 線程中斷規則:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發送:可以通過 Thread.interrupted() 方法檢測到是否有中斷髮生。
  • 對象終結規則:一個對象的初始化完成先行發生於它的 finalize() 方法的開始。
  • 傳遞性:如果操作A先行發生於操作B,B先行發生於C,那麼A先行發生於C。

3. 示例

private int value = 0;
public void setValue(int value) {
    this.value = value;
}
public int getValue(){
    return value;
}

假設有2個線程 A 和 B,A 先調用了 setValue(1),然後 B 調用 get 方法,那麼 B 的返回值是什麼?

我們對照一下上面的那些原則:

  • 2個方法分別在2個線程中調用,不在一個線程中,”程序次序規則“不適用;
  • 沒有同步塊,不會發生 lock 和 unlock 操作,”管程鎖定規則“不適用;
  • value 沒有使用 volidate 關鍵字,”volatile 變量規則“不適用;
  • 其他的線程、對象的啓動終結之類的規則和此代碼沒有關係,都不適用;

所以,B 的返回值無法確定,就是說線程不安全。

先行發生原則是我們判斷併發問題的準則

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