Java內存模型

重排序

  重排序是指編譯器或處理器爲了提升程序性能而對指令序列進行從新排序的一種手段。重排序能夠致使操做延時或程序看似亂序執行,給程序運行的結果帶來必定的不肯定性。html

  三類重排序:java

    1)編譯器的重排序:編譯器在不改變單線程語義的前提下,生成的指令順序能夠與源代碼不一樣。對Java來講,此處的編譯器是指JIT即時編譯器,即生成的機器指令與字節碼指令順序不一致。程序員

    2)指令並行的重排序:若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。直接執行當前有能力當即執行的後續指令,避開獲取下一條指令所需數據時形成的等待,可提升執行效率。編程

    3)內存系統的重排序:因爲處理器使用緩存和讀/寫緩衝區,緩存可能會改變寫入變量提交到主內存中的次序,使得加載和存儲操做看上去是亂序執行。數組

  其中1)屬於編譯器級別的重排序,2)和3)屬於處理器級別的重排序。緩存

  as-if-seriaf語義

    數據依賴性:若是兩個操做訪問同一個變量,而且有一個操做爲寫操做(寫後寫、寫後讀、讀後寫),此時兩個操做具備數據依賴性。安全

    數據依賴性是編譯器和處理器判斷可否進行重排序的重要依據。在單線程環境中,編譯器和處理器不會對存在數據依賴關係的兩個操做作重排序;但在多線程環境中,沒有這種保證。多線程

    單線程重排序示例:架構

1         int i = 1;            //A
2         int j = 2;            //B
3         int sum = i + j;        //C

    以A、B、C語句爲例說明單線程重排序(實際應該是Java字節碼或生成機器指令),A和C、B和C有數據依賴關係,因此A、B都不能和C重排序,但A和B能夠進行重排序。有兩種執行順序:併發

      1)A->B->C  sum=3

      2)B->A->C  sum=3

    兩種執行順序的結果是相同的,即在單線程環境中,重排序不會影響程序的語義。

    as-if-seriaf語義:無論編譯器和處理器怎麼重排序,單線程中程序執行的結果不能被改變。因爲執行結果不變,程序員感受單線程程序好像是順序執行的。

    Java編譯器、處理器都會保證單線程下的as-if-serial語義。因此程序員不用關心在單線程中因爲重排序致使的程序語義的不肯定性,即在單個線程中的變量(局部變量確定是單線程)不存在線程安全問題。

    注意:但當一個變量被多個線程共享時,須要經過JMM提供的同步手段來實現程序語義的正確性。

happens-before原則

    多線程重排序代碼示例:

 1         public class Reordering {
 2             static int x = 0, y = 0;
 3             static int a = 0, b = 0;
 4             public static void main(String[] args) throws InterruptedException {
 5                 Thread one = new Thread(new Runnable() {
 6                     public void run() {
 7                         a = 1;    //A
 8                         x = b;    //B
 9                     }
10                 });
11                 Thread other = new Thread(new Runnable() {
12                     public void run() {
13                         b = 1;    //C
14                         y = a;    //D
15                     }
16                 });
17                 one.start();other.start();
18                 one.join();other.join();
19                 System.out.println("(" + x + "," + y + ")");
20             }  
21         }

     經過兩個線程語句的交替執行很容易判斷出可能的結果有(1,0)、(0,1)或(1,1)。實際上可能的結果還有(0,0),以上3種類型的重排序都有可能致使該結果。四條語句可能的執行次序爲B->C->D-A,此時結果爲(0,0),1)和2)的重排序均可以致使該結果。還有一種狀況,執行次序A->B、C->D,A和C執行後,變量值存入本地緩存,並無刷到主內存,B和D執行時讀不到A和C存入的值,即A操做結果對D不可見、C操做結果對B不可見,此時結果也爲(0,0),3)的重排序能夠致使該狀況。

    這種問題怎麼解決呢?JMM爲程序員提供了友好的方法(happens-before原則)來解決多線程環境中重排序引發的問題。

    happens-before原則

      1)程序順序原則:線程中的每一個動做A都happens-before於該線程中的每個動做B,其中,在程序中,全部的動做B都能出如今A以後。

      2)監視器鎖原則:對一個監視器鎖的解鎖 happens-before於每個後續對同一監視器鎖的加鎖。

      3)volatile變量原則:對volatile域的寫入操做happens-before於每個後續對同一個域的讀寫操做。

      4)線程啓動原則:在一個線程裏,對Thread.start的調用會happens-before於每一個啓動線程的動做。

      5)線程終結原則:線程中的任何動做都happens-before於其餘線程檢測到這個線程已經終結、或者從Thread.join調用中成功返回,或Thread.isAlive返回false。

      6)中斷原則:一個線程調用另外一個線程的interrupt happens-before於被中斷的線程發現中斷。

      7)終結原則:一個對象的構造函數的結束happens-before於這個對象finalizer的開始。

      8)傳遞性:若是A happens-before於B,且B happens-before於C,則A happens-before於C。

    happens-before原則是面向開發人員的,每一個happens-before原則對應於一個或多個編譯器重排序和處理器重排序規則。這樣開發人員只須要理解happens-before原則,而不用根據重排序規則實現內存可見性。

    JMM的設計基於兩個方面的考慮:

      1)從使用角度考慮,開發人員基於happens-before規則提供的內存可見性保證進行編程,易於理解。JMM向開發人員的保證:若是 A happens-before B,那麼A的操做將對B可見,且A的執行順序排在B以前(但實際執行時,A可能在B以後)。

      2)從性能方面考慮,只要不改變程序結果的重排序(指單線程程序或正確同步的多線程程序),JMM容許編譯器和處理器的任何優化。

    as-if-seriaf和happens-before的對比

      1)相同點:二者的目的都是在不改變程序語義的前提下,儘量的提升程序執行的並行度。

      2)不一樣點:as-if-seriaf保證在單線程程序中執行的結果不變;happens-before保證在正確同步的多線程程序中執行結果不變。

     JMM是怎麼實現以上規則的內存可見性的呢?JMM經過編譯器重排序規則會禁止特定類型的編譯器重排序;經過在指令序列的適當位置插入內存屏障指令(Memory Barriers)禁止特定類型的處理器重排序。

內存屏障(Memory Barriers)

  內存屏障是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題。

  四種內存屏障指令:

    1)LoadLoad:load1 LoadLoad load2 確保load1讀取的數據被讀取完畢先於load2及後續全部讀取指令的讀取。

    2)StoreStore:store1 StoreStore store2 確保store1數據對其餘處理器可見(刷新到內存)先於store2及全部後續存儲指令的存儲。

    3)LoadStore:load1 LoadStore store2 確保load1的數據讀取完畢先於store2及全部後續存儲指令刷新到內存。

    4)StoreLoad:store1 StoreLoad load2 確保store1數據對其餘處理器可見(刷新到內存)先於load2及後續全部讀取指令的讀取,StoreLoad會使該屏障以前的全部內存訪問指令(存儲和讀取指令)完成以後,才執行該屏障以後的內存訪問指令。StoreLoad屏障同時具備其餘三種屏障的效果。相對的,執行該屏障的開銷是最大的,由於當前處理器一般要把寫緩衝區的數據所有刷新到內存中。

volatile

  特性:

    1)可見性:對一個volatile變量的讀,總能看到任意線程對這個volatile變量最後的寫入;

    2)原子性:對任意單個volatile變量的讀/寫具備原子性(即便是64位的long/double型變量),但對複合操做(如count++)不具備原子性。

  內存語義:

    從內存語義的角度來說,volatile的寫-讀與鎖的釋放-獲取有相同的內存語義。

    當寫一個volatile變量時,JMM會把線程對應的本地內存中的共享變量值刷新到主內存(本地內存中的全部共享變量的值都刷新到主內存)。

    當讀一個volatile變量時,JMM會把線程對應的本地內存置爲無效(即本地內存中的全部共享變量的值都將無效),從主內存中從新加載共享變量。

    代碼示例:

 1         class Test {
 2             int count = 0;
 3             volatile boolean flag = false;    //用volatile關鍵詞修飾
 4             public void write() {
 5                 count = 1;        //1
 6                 flag = true;        //2
 7             }
 8             public void read() {
 9                 if(flag) {        //3
10                     count++;    //4
11                 }
12             }
13         }

    根據happens-before原則中的程序順序規則,有1 happens-before 二、3 happens-before 4。根據volatile規則,有2 happens-before 3。根據傳遞性,1 happens-before 4。注意此時是1和2是不能重排序的,3和4一樣不能重排序。假設線程A先執行write方法,線程B後執行read方法,volatile的內存語義能保證線程B必定能看到線程A對count變量的更改。

  內存語義的實現原理:

    JMM針對編譯器制定的volatile重排序規則表以下:

  後一個操做
  前一個操做   普通讀/寫 volatile讀 volatile寫
普通讀/寫     N
volatile讀 N N N
volatile寫   N N

    N表示不可重排序,能夠看出:

      1)當後一個操做是volatile寫時,無論前一個操做是什麼,都不能重排序,確保在volatile寫以前的操做不會被編譯器重排序到volatile寫以後。

      2)當前一個操做是volatile讀時,無論第二個操做是什麼,都不能重排序,確保volatile讀以後的操做不會被編譯器重排序到volatile讀以前。

      3)當前一個操做是volatile寫,後一個操做是volatile讀時,不能重排序。

    編譯器在指令序列中插入內存屏障來禁止特定類型的重排序。

    基於保守策略內存屏障的插入策略:

      1)在每一個volatile寫操做的前面插入StoreStore屏障:禁止前面的普通寫與volatile寫重排序。能保證volatile寫以前的全部普通寫操做已經對全部處理器可見了。

      2)在每一個volatile寫操做的後面插入StoreLoad屏障:禁止volatile寫與後面可能的volatile讀/寫重排序。

      3)在每一個volatile讀操做的後面插入LoadLoad屏障:禁止後面的全部普通讀操做與volatile讀重排序。

      4)在每一個volatile讀操做的後面插入LoadStore屏障:禁止後面的全部普通寫操做與volatile讀重排序。

  什麼狀況下不能使用volatile關鍵字?

    對於volatile關鍵字,當且僅當知足如下全部條件時可以使用:

      1)對變量的寫入操做不依賴變量的當前值,或者你能確保只有單個線程更新變量的值,例如i++;

      2)該變量沒有包含在具備其餘變量的不變式中。

    第一個條件很好理解,對第二個條件怎麼理解呢?

      舉個例子,代碼是一個非線程安全的數值範圍類,它包含了一個不變式:下界老是小於或等於上界。

 1                 @NotThreadSafe 
 2                 public class NumberRange {
 3                     private int lower, upper;
 4 
 5                     public int getLower() { return lower; }
 6                     public int getUpper() { return upper; }
 7 
 8                     public void setLower(int value) { 
 9                         if (value > upper) 
10                             throw new IllegalArgumentException(...);
11                         lower = value;
12                     }
13 
14                     public void setUpper(int value) { 
15                         if (value < lower) 
16                             throw new IllegalArgumentException(...);
17                         upper = value;
18                     }
19                 }

 

 

 

      這種方式限制了範圍的狀態變量,所以將lower和upper字段定義爲volatile類型不可以充分實現類的線程安全;從而仍然須要使用同步。不然,若是湊巧兩個線程在同一時間使用不一致的值執行setLower和setUpper的話,則會使範圍處於不一致的狀態。例如,若是初始狀態是(0,5),同一時間內,線程A調用setLower(4)而且線程B調用setUpper(3),顯然這兩個操做交叉存入的值是不符合條件的,那麼兩個線程都會經過用於保護不變式的檢查,使得最後的範圍值是(4,3)是個無效值。至於針對範圍的其餘操做,咱們須要使setLower()和setUpper()操做原子化,而將字段定義爲volatile類型是沒法實現這一目的的。

  volatile 和 synchronized 的對比

    Java語言包含兩種內在的同步機制:synchronized同步塊(或方法)和 volatile 變量。

      1)synchronized有互斥和可見性兩種特性,是一種「重量級」同步機制;volatile有可見性,是一種「輕量級」同步機制;

      2)synchronized能夠用在變量、方法、類級別;volatile只能修飾變量;

      3)synchronized可能形成線程阻塞;volatile不會阻塞線程。

    在目前大多數的處理器架構上,volatile讀操做開銷很是低,幾乎和非volatile讀操做同樣。而volatile寫操做的開銷要比非volatile寫操做多不少,由於要保證可見性須要實現內存界定(Memory Fence),即使如此,volatile的總開銷仍然要比鎖獲取低。volatile操做不會像鎖同樣形成阻塞,所以,在可以安全使用volatile的狀況下,volatile能夠提供一些優於鎖的可伸縮特性。若是讀操做的次數要遠遠超過寫操做,與鎖相比,volatile變量一般可以減小同步的性能開銷。ConcurrentLinkedQueue的做者DougLea設計hops變量,就是經過增長對volatile變量的讀來減小對volatile變量的寫,以實現入隊和出隊效率的提高。

final域  

  內存語義:

    1)在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,兩個操做不能重排序;

    2)初次讀一個包含final域的對象的引用,與隨後初次讀這個對象的final域,兩個操做不能重排序。

    3)當Final爲引用類型時,增長以下限制:在構造函數內對一個final引用對象的成員域的寫入,與隨後在構造方法外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。(數組示例)

  對象引用「逸出」問題:

    在構造函數內部,不能讓被構造對象的引用爲其餘線程可見,也就是對象引用不能在構造函數中「逸出」。

  加強以後的final語義:只要對象是正確構造的(被構造的對象引用沒有在構造函數中「逸出」),那麼不須要使用同步(lock和volatile)就能夠保證任意線程都能看到這個final域在構造函數中被初始化後的值。

 

  內存語義的實現原理:

    爲了保證final字段的特殊語義,也會在下面的語句加入內存屏障。

    x.finalField = v; StoreStore; sharedRef = x;

雙重檢查鎖定模型

  雙重檢查鎖定代碼:

 1     public class DoubleCheckLock {
 2         private static DoubleCheckLock instance;
 3         public static DoubleCheckLock getInstance() {
 4         if(instance == null) {                          //一、第一次檢查
 5             synchronized (DoubleCheckLock.class) {      //二、加鎖
 6             if(instance == null) {                  //三、第二次檢查
 7                 instance = new DoubleCheckLock();   //四、新建實例
 8             }
 9             }
10         }
11         return instance;
12         }
13     }

 

  存在的問題:

    第4步新建實例能夠分爲如下三步:1)分配對象的內存空間、2)初始化實例、3)把instance指向內存空間。

    其中2)和3)能夠重排序。注意這並不違反單線程執行結果不改變的原則,假設4)爲使用instance,只要保證2)和4)不作重排序就能保證單線程執行結果不變。這樣就有可能致使另外一個線程在檢查instance不爲null時,使用一個未完成初始化的對象。

  解決方案一:禁止2)和3)重排序,只需將instance設爲volatile變量。

  解決方案二:讓其餘線程沒法看到2)和3)的重排序,利用類初始化實現延遲加載。

        原理:在Class被加載後,且被線程使用以前,JVM會執行類的初始化。JVM執行類的初始化時會去獲取一個鎖,這個鎖同步多個線程同時對一個類初始化 。

        代碼:

1             class Singleton {
2                 private class SingletonHolder {
3                     public static Singleton instance = new Singleton();
4                 }
5                 public Singleton getInstance() {
6                     return SingletonHolder.instance; //類SingletonHolder初始化,由類初始化時的同步機制保證不會建立多個實例。
7                 }
8             }

        類的初始化介紹(詳細請查閱JVM相關書籍):

          類的初始化包括:類的靜態初始化和類的靜態字段的初始化。

          何時觸發類的初始化?1)首次建立一個該類的實例時;2)首次調用該類中的靜態方法時;3)首次爲類或接口中的靜態域賦值時;4)首次使用類或接口的靜態域時(前提靜態域不能由final修飾);

          多線程併發初始化一個類或接口時,怎麼保證同步?初始化鎖:每一個類或接口都有一個對應的初始化鎖LC,JVM執行類的初始化時會去獲取這個初始化鎖。

  兩種解決方案的對比:

    1)基於類初始化的方法代碼更簡潔,但只能對靜態域延遲初始化。

    2)基於volatile的雙重檢查鎖的方法對靜態域和實例域均可以。

  

參考資料

  《Java內存訪問重排序的研究》https://tech.meituan.com/java-memory-reordering.html  

  《java併發編程的藝術》

  《就是要你懂Java中volatile關鍵字實現原理》https://www.cnblogs.com/xrq730/p/7048693.html

相關文章
相關標籤/搜索