Java內存模型(JMM)

JMM 之 happens-before

在 JMM 中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必須存在 happens-before 關係。java

happens-before 原則很是重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,咱們解決在併發環境下兩操做之間是否可能存在衝突的全部問題。下面咱們就一個簡單的例子稍微瞭解下happens-before ;程序員

i = 1; // 線程 A 執行
j = i;  //線程 B 執行
複製代碼

j 是否等於 1 呢?編程

假定線程 A 的操做(i = 1) happens-before 線程 B 的操做(j = i),那麼能夠肯定,線程 B 執行後 j = 1 必定成立。安全

若是他們不存在 happens-before 原則,那麼 j = 1 不必定成立。這就是happens-before原則的威力。多線程

定義

happens-before 原則【定義】以下:併發

若是一個操做 happens-before 另外一個操做,那麼第一個操做的執行結果,將對第二個操做可見,並且第一個操做的執行順序,排在第二個操做以前。app

兩個操做之間存在 happens-before 關係,並不意味着必定要按照 happens-before 原則制定的順序來執行。若是重排序以後的執行結果與按照 happens-before 關係來執行的結果一致,那麼這種重排序並不非法性能

規則

  1. 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操做,happens-before 於書寫在後面的操做。
  2. 鎖定規則:一個 unLock 操做,happens-before 於後面對同一個鎖的 lock 操做。
  3. volatile 變量規則:對一個volatile變量的寫操做,happens-before 於後面對這個變量的讀操做。注意是後面的.
  4. 傳遞規則:若是操做 A happens-before 操做 B,而操做 B happens-before 操做C,則能夠得出,操做 A happens-before 操做C
  5. 線程啓動規則:Thread 對象的 start 方法,happens-before 此線程的每一個一個動做。
  6. 線程中斷規則:對線程 interrupt 方法的調用,happens-before 被中斷線程的代碼檢測到中斷事件的發生。
  7. 線程終結規則:線程中全部的操做,都 happens-before 線程的終止檢測,咱們能夠經過Thread.join() 方法結束、Thread.isAlive() 的返回值手段,檢測到線程已經終止執行。
  8. 對象終結規則:一個對象的初始化完成,happens-before 它的 finalize() 方法的開始

上面八條是原生 Java 知足 happens-before 關係的規則,可是咱們能夠對他們進行推導出其餘知足 happens-before 的規則:優化

  1. 將一個元素放入一個線程安全的隊列的操做,happens-before 從隊列中取出這個元素的操做。
  2. 將一個元素放入一個線程安全容器的操做,happens-before 從容器中取出這個元素的操做。
  3. 在 CountDownLatch 上的 countDown 操做,happens-before CountDownLatch 上的 await 操做。
  4. 釋放 Semaphore 上的 release 的操做,happens-before Semaphore 上的 acquire 操做。
  5. Future 表示的任務的全部操做,happens-before Future 上的 get 操做。
  6. 向 Executor 提交一個 Runnable 或 Callable 的操做,happens-before 任務開始執行操做。

這裏再說一遍 happens-before 的概念:ui

  1. 若是兩個操做不存在上述(前面8條 + 後面6條)任一一個 happens-before 規則,那麼這兩個操做就沒有順序的保障,JVM 能夠對這兩個操做進行重排序。

  2. 若是操做 A happens-before 操做 B,那麼操做A在內存上所作的操做對操做B都是可見的。

下面就用一個簡單的例子,來描述下 happens-before 的原則:

private int i = 0;

public void write(int j ) {
	i = j;
}

public int read() {
	return i;
}
複製代碼

咱們約定線程 A 執行 #write(int j),線程 B 執行 #read(),且線程 A 優先於線程 B 執行,那麼線程 B 得到結果是什麼?

就這段簡單的代碼,咱們來基於 happens-before 的規則作一次分析:

因爲兩個方法是由不一樣的線程調用,因此確定不知足程序次序規則。

兩個方法都沒有使用鎖,因此不知足鎖定規則。

變量 i 不是用volatile修飾的,因此 volatile 變量規則不知足。

傳遞規則確定不知足。

規則 五、六、七、8 + 推導的 6 條能夠忽略,由於他們和這段代碼毫無關係。

因此,咱們沒法經過 happens-before 原則,推導出線程 A happens-before 線程 B 。

雖然,能夠確認在時間上,線程 A 優先於線程 B 執行,可是就是沒法確認線程B得到的結果是什麼,因此這段代碼不是線程安全的。
複製代碼

那麼怎麼修復這段代碼呢?知足規則 二、3 任一便可。

happen-before原則是JMM中很是重要的原則,它是判斷數據是否存在競爭、線程是否安全的主要依據,保證了多線程環境下的可見性。

happen-before 總結

JMM 之分析 volatile

咱們知道volatile 的特性:

  1. volatile 可見性:對一個 volatile 的讀,總能夠看到對這個變量最終的寫。
  2. volatile 原子性:volatile 對單個讀 / 寫具備原子性(32 位 Long、Double),可是複合操做除外,例如 i++ 。
  3. JVM 底層採用「內存屏障」來實現 volatile 語義。

下面 經過 happens-before 原則和 volatile 的內存語義,兩個方向分析 volatile 。

volatile 與 happens-before

咱們知道happens-before 是用來判斷是否存在數據競爭、線程是否安全的主要依據,它保證了多線程環境下的可見性。下面咱們就那個經典的例子,來分析 volatile 變量的讀寫,如何創建的 happens-before 關係。

public class VolatileTest {

    int i = 0;
    volatile boolean flag = false;

    // Thread A
    public void write(){
        i = 2;              // 1
        flag = true;        // 2
    }

    // Thread B
    public void read(){
        if(flag) {                                   // 3
            System.out.println("---i = " + i);      // 4
        }
    }
}
複製代碼

依據 happens-before 原則,就上面程序獲得以下關係:

  1. 程序順序原則:操做 1 happens-before 操做 2 ,操做 3 happens-before 操做 4 。

  2. volatile 原則:操做 2 happens-before 操做 3 。

    • 2 happens-before 操做 3的前提是2操做比 3操做要早
    • 若是1操做後面插入
      for (int j = 0; j < 100; j++) {
          System.out.println(13213123);
      }
      複製代碼

    那麼3會先執行完

  3. 傳遞性原則:操做 1 happens-before 操做 4 。

  4. 操做 一、操做 4 存在 happens-before 關係,那麼操做 1 必定是對 操做 4 是可見的。

可能有人就會問,操做 一、操做 2 可能會發生重排序啊,會嗎?volatile 除了保證可見性外,還有就是禁止重排序。因此 A 線程在寫 volatile 變量以前全部可見的共享變量,在線程 B 讀同一個 volatile 變量後,將當即變得對線程 B 可見。

volataile 的內存語義及其實現

  1. 當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量值,當即刷新到主內存中。
  2. 當讀一個 volatile 變量時,JMM 會把該線程對應的本地內存設置爲無效,直接從主內存中讀取共享變量

因此 volatile 的寫內存語義是直接刷新到主內存中,讀的內存語義是直接從主內存中讀取。

那麼 volatile 的內存語義是如何實現的呢?對於通常的變量則會被重排序,而對於 volatile 的變量則不能。這樣會影響其內存語義,因此爲了實現 volatile 的內存語義,JMM 會限制重排序。其重排序規則以下:

  1. 若是第一個操做爲 volatile 讀,則無論第二個操做是啥,都不能重排序。這個操做確保volatile 讀以後的操做,不會被編譯器重排序到 volatile 讀以前;
  2. 若是第二個操做爲 volatile 寫,則無論第一個操做是啥,都不能重排序。這個操做確保volatile 寫以前的操做,不會被編譯器重排序到 volatile 寫以後;
  3. 當第一個操做 volatile 寫,第二個操做爲 volatile 讀時,不能重排序。

volatile 的底層實現,是經過插入內存屏障。可是對於編譯器來講,發現一個最優佈置來最小化插入內存屏障的總數幾乎是不可能的,因此,JMM 採用了保守策略。

策略以下:

  1. 在每個 volatile 寫操做前面,插入一個 StoreStore 屏障
  2. 在每個 volatile 寫操做後面,插入一個 StoreLoad 屏障
  3. 在每個 volatile 讀操做後面,插入一個 LoadLoad 屏障
  4. 在每個 volatile 讀操做後面,插入一個 LoadStore 屏障

緣由以下:

StoreStore 屏障:保證在 volatile 寫以前,其前面的全部普通寫操做,都已經刷新到主內存中。
StoreLoad 屏障:避免 volatile 寫,與後面可能有的 volatile 讀 / 寫操做重排序。
LoadLoad 屏障:禁止處理器把上面的 volatile讀,與下面的普通讀重排序。
LoadStore 屏障:禁止處理器把上面的 volatile讀,與下面的普通寫重排序。
複製代碼

案例 1:VolatileTest

下面咱們就上面 VolatileTest 例子從新分析下:

public class VolatileTest {
    
    int i = 0;
    volatile boolean flag = false;
    
    public void write() {
        i = 2;
        flag = true;
    }

    public void read() {
        if (flag){
            System.out.println("---i = " + i);
        }
    }
    
}
複製代碼

內存屏障圖例

案例 2:VolatileBarrierExample

volatile 的內存屏障插入策略很是保守,其實在實際中,只要不改變 volatile 寫-讀的內存語義,編譯器能夠根據具體狀況優化,省略沒必要要的屏障。

public class VolatileBarrierExample {
    int a = 0;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite(){
        int i = v1;     //volatile讀
        int j = v2;     //volatile讀
        a = i + j;      //普通讀
        v1 = i + 1;     //volatile寫
        v2 = j * 2;     //volatile寫
    }
}
複製代碼

沒有優化的示例圖以下:

咱們來分析,上圖有哪些內存屏障指令是多餘的:

1:這個確定要保留了

2:禁止下面全部的普通寫與上面的 volatile 讀重排序,可是因爲存在第二個 volatile讀,那個普通的讀根本沒法越過第二個 volatile 讀。因此能夠省略。

3:下面已經不存在普通讀了,能夠省略。

4:保留

5:保留

6:下面跟着一個 volatile 寫,因此能夠省略

7:保留

8:保留

因此 二、三、6 能夠省略,其示意圖以下:

總結

JMM 之重排序

在執行程序時,爲了提升性能,處理器和編譯器經常會對指令進行重排序,可是不能隨意重排序,不是你想怎麼排序就怎麼排序,它須要知足如下兩個條件:

  1. 在單線程環境下,不能改變程序運行的結果。
  2. 存在數據依賴關係的狀況下,不容許重排序。

其實這兩點能夠歸結於一點:沒法經過 happens-before 原則推導出來的,JMM 容許任意的排序。

as-if-serial 語義

as-if-serial 語義的意思是:全部的操做都可覺得了優化而被重排序,可是你必需要保證重排序後執行的結果不能被改變,編譯器、runtime、處理器都必須遵照 as-if-serial 語義。注意,as-if-serial 只保證單線程環境,多線程環境下無效。

下面咱們用一個簡單的示例來講明:

int a = 1 ;      // A
int b = 2 ;      // B
int c = a + b;   // C
複製代碼

A、B、C 三個操做存在以下關係:

A、B 不存在數據依賴關係,
A和C、B和C存在數據依賴關係,
複製代碼

所以在進行重排序的時候,A、B 能夠隨意排序,可是必須位於 C 的前面,執行順序能夠是 A –> B –> C 或者 B –> A –> C 。可是不管是何種執行順序最終的結果 C 老是等於 3 。

as-if-serail 語義把單線程程序保護起來了,它能夠保證在重排序的前提下程序的最終結果始終都是一致的。

其實,對於上段代碼,他們存在這樣的 happen-before 關係:

A happens-before B
B happens-before C
A happens-before C
複製代碼

一、2 是程序順序次序規則,3 是傳遞性。可是,不是說經過重排序,B 可能會排在 A 以前執行麼,爲什麼還會存在存在 A happens-before B 呢?這裏再次申明 A happens-before B 不是 A 必定會在 B 以前執行,而是 A 的執行結果對 B 可見,可是相對於這個程序 A 的執行結果不須要對 B 可見,且他們重排序後不會影響結果,因此 JMM 不會認爲這種重排序非法。

咱們須要明白這點:在不改變程序執行結果的前提下,儘量提升程序的運行效率。

下面咱們在看一段有意思的代碼:

public class RecordExample1 {

    public static void main(String[] args){
        int a = 1;
        int b = 2;

        try {
            a = 3;           // A
            b = 1 / 0;       // B
        } catch (Exception e) {

        } finally {
            System.out.println("a = " + a);
        }
    }
    
}
複製代碼

按照重排序的規則,操做 A 與操做 B 有可能會進行重排序,若是重排序了,B 會拋出異常( / by zero),此時A語句必定會執行不到,那麼 a 還會等於 3 麼?

若是按照 as-if-serial 原則它就改變了程序的結果。

其實,JVM 對異常作了一種特殊的處理,爲了保證 as-if-serial 語義,Java 異常處理機制對重排序作了一種特殊的處理:JIT 在重排序時,會在catch 語句中插入錯誤代償代碼(a = 3),這樣作雖然會致使 catch 裏面的邏輯變得複雜,可是 JIT 優化原則是:儘量地優化程序正常運行下的邏輯,哪怕以 catch 塊邏輯變得複雜爲代價。

重排序對多線程的影響

在單線程環境下,因爲 as-if-serial 語義,重排序沒法影響最終的結果,可是對於多線程環境呢?

以下代碼(volatile的經典用法):

public class RecordExample2 {
    
    int a = 0;
    boolean flag = false;

    /** * A線程執行 */
    public void writer() {
        a = 1;                  // 1
        flag = true;            // 2
    }

    /** * B線程執行 */
    public void read(){
        if (flag) {                 // 3
           int i = a + a;          // 4
        }
    }

}
複製代碼

A 線程先執行 #writer(),線程 B 後執行 #read(),線程 B 在執行時可否讀到 a = 1 呢?

答案是不必定(注:x86 CPU 不支持寫寫重排序,若是是在 x86 上面操做,這個必定會是 a = 1 )。

因爲操做 1 和操做 2 之間沒有數據依賴性,因此能夠進行重排序處理。 操做 3 和操做 4 之間也沒有數據依賴性,他們亦能夠進行重排序,可是操做 3 和操做 4 之間存在控制依賴性。

假如操做1 和操做2 之間重排序:

按照這種執行順序線程 B 確定讀不到線程 A 設置的 a 值,在這裏多線程的語義就已經被重排序破壞了。

實際上,操做 3 和操做 4 之間也能夠重排序,雖然他們之間存在一個控制依賴的關係,只有操做 3 成立操做 4 纔會執行

當代碼中存在控制依賴性時,會影響指令序列的執行的並行度,因此編譯器和處理器會採用猜想執行來克服控制依賴對並行度的影響。

假如操做 3 和操做 4 重排序了,操做 4 先執行,則先會把計算結果臨時保存到重排序緩衝中,當操做 3 爲真時,纔會將計算結果寫入變量 i 中。

經過上面的分析,重排序不會影響單線程環境的執行結果,可是會破壞多線程的執行語義。

重排序總結

JMM 角度分析 DCL

DCL ,即 Double Check Lock ,中文稱爲「雙重檢查鎖定」。

其實 DCL 不少人在單例模式中用過,可是有不少人都會寫錯。他們爲何會寫錯呢?其錯誤根源在哪裏?有什麼解決方案?下面就一塊兒來分析。

問題分析

咱們先看單例模式裏面的懶漢式:

public class Singleton {
    
    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        if (singleton == null) {
            singleton = new Singleton();
        }

        return singleton;
    }
    
}
複製代碼

咱們都知道這種寫法是錯誤的,由於它沒法保證線程的安全性。優化以下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){}

    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }

        return singleton;
    }
    
}
複製代碼

優化很是簡單,就是在 #getInstance() 方法上面作了同步,可是 synchronized 就會致使這個方法比較低效,致使程序性能降低,那麼怎麼解決呢?聰明的人們想到了雙重檢查 DCL:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance(){
        if(singleton == null){                              // 1
            synchronized (Singleton.class){                 // 2
                if(singleton == null){                      // 3
                    singleton = new Singleton();            // 4
                }
            }
        }
        return singleton;
    }
}
複製代碼

就如上面所示,這個代碼看起來很完美,理由以下:

  1. 若是檢查第一個 singleton 不爲 null ,則不須要執行下面的加鎖動做,極大提升了程序的性能。
  2. 若是第一個 singleton 爲 null ,即便有多個線程同一時間判斷,可是因爲 synchronized 的存在,只會有一個線程可以建立對象。
  3. 當第一個獲取鎖的線程建立完成後 singleton 對象後,其餘的在第二次判斷 singleton 必定不會爲 null ,則直接返回已經建立好的 singleton 對象。

經過上面的分析,DCL 看起確實是很是完美,可是能夠明確地告訴你,這個錯誤的。上面的邏輯確實是沒有問題,分析也對,可是就是有問題,那麼問題出在哪裏呢?在回答這個問題以前,咱們先來複習一下建立對象過程,實例化一個對象要分爲三個步驟:

memory = allocate();   //1:分配內存空間
ctorInstance(memory);  //2:初始化對象
instance = memory;     //3:將內存空間的地址賦值給對應的引用
複製代碼

可是因爲重排序的緣由,步驟 二、3 可能會發生重排序,其過程以下:

memory = allocate();   // 1:分配內存空間
instance = memory;     // 3:將內存空間的地址賦值給對應的引用
                                    // 注意,此時對象尚未被初始化!
ctorInstance(memory);  // 2:初始化對象
複製代碼

若是 二、3 發生了重排序,就會致使第二個判斷會出錯,singleton != null,可是它其實僅僅只是一個地址而已,此時對象尚未被初始化,因此 return 的 singleton 對象是一個沒有被初始化的對象,以下:

按照上面圖例所示,線程 B 訪問的是一個沒有被初始化的 singleton 對象。

知道問題根源所在,那麼怎麼解決呢?有兩個解決辦法:

不容許初始化階段步驟 二、3 發生重排序。
容許初始化階段步驟 二、3 發生重排序,可是不容許其餘線程「看到」這個重排序。
複製代碼

解決方案

解決方案依據上面兩個解決辦法便可。

基於 volatile 解決方案

對於上面的DCL其實只須要作一點點修改便可:將變量singleton生命爲volatile便可:

public class Singleton {

    // 經過volatile關鍵字來確保安全
    private volatile static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
複製代碼

當 singleton 聲明爲 volatile後,步驟 二、3 就不會被重排序了,也就能夠解決上面那問題了。

基於類初始化的解決方案

該解決方案的根本就在於:利用 ClassLoder 的機制,保證初始化 instance 時只有一個線程。JVM 在類初始化階段會獲取一個鎖,這個鎖能夠同步多個線程對同一個類的初始化。

public class Singleton {

    private static class SingletonHolder{
        public static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance(){
        return SingletonHolder.singleton;
    }
}
複製代碼

Java 語言規定,對於每個類或者接口 C ,都有一個惟一的初始化鎖 LC 與之相對應。從C 到 LC 的映射,由 JVM 的具體實現去自由實現。JVM 在類初始化階段期間會獲取這個初始化鎖,而且每個線程至少獲取一次鎖來確保這個類已經被初始化過了。

DCL 總結

延遲初始化下降了初始化類或建立實例的開銷,但增長了訪問被延遲初始化的字段的開銷。在大多數時候,正常的初始化要優於延遲初始化。

若是確實須要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基於 volatile 的延遲初始化的方案。 若是確實須要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化的方案。

JMM 之總結

通過上面的討論,如今對 JMM 作一個比較簡單的總結。

  1. JMM 規定了線程的工做內存和主內存的交互關係,以及線程之間的可見性和程序的執行順序。

  2. 一方面,要爲程序員提供足夠強的內存可見性保證。

  3. 另外一方面,對編譯器和處理器的限制要儘量地放鬆。JMM 對程序員屏蔽了 CPU 以及 OS 內存的使用問題,可以使程序在不一樣的 CPU 和 OS 內存上都可以達到預期的效果。

  4. Java 採用內存共享的模式來實現線程之間的通訊。編譯器和處理器能夠對程序進行重排序優化處理,可是須要遵照一些規則,不能隨意重排序。

  5. 在併發編程模式中,勢必會遇到上面三個概念:

    原子性:一個操做或者多個操做要麼所有執行要麼所有不執行。
     可見性:當多個線程同時訪問一個共享變量時,若是其中某個線程更改了該共享變量,其餘線程應該能夠馬上看到這個改變。
     有序性:程序的執行要按照代碼的前後順序執行。
    複製代碼
  6. JMM 對原子性並無提供確切的解決方案,可是 JMM 解決了可見性和有序性,至於原子性則須要經過鎖或者 synchronized 來解決了。

  7. 若是一個操做 A 的操做結果須要對操做 B 可見,那麼咱們就認爲操做 A 和操做 B 之間存在happens-before 關係,即 A happens-before B 。

  8. happens-before 原則,是 JMM 中很是重要的一個原則,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,咱們能夠解決在併發環境下兩個操做之間是否存在衝突的全部問題。JMM 規定,兩個操做存在 happens-before 關係並不必定要 A 操做先於B 操做執行,只要 A 操做的結果對 B 操做可見便可。

  9. 在程序運行過程當中,爲了執行的效率,編譯器和處理器是能夠對程序進行必定的重排序,可是他們必需要知足兩個條件:

    執行的結果保持不變
     存在數據依賴的不能重排序。重排序是引發多線程不安全的一個重要因素。
    複製代碼
  10. 同時,順序一致性是一個比較理想化的參考模型,它爲咱們提供了強大而又有力的內存可見性保證,他主要有兩個特徵:

    一個線程中的全部操做必須按照程序的順序來執行。
    全部線程都只能看到一個單一的操做執行順序,在順序一致性模型中,每一個操做都必須原則執行且馬上對全部線程可見。複製代碼
相關文章
相關標籤/搜索