volatile關鍵字精講

1.錯誤案例

       經過一個案例引出volatile關鍵字,例如如下代碼示例 : 此時沒有加volatile關鍵字兩個線程間的通信就會有問題java

public class ThreadsShare {
  private static boolean runFlag = false; // 此處沒有加 volatile
  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      System.out.println("線程一等待執行");
      while (!runFlag) {
      }
      System.out.println("線程一開始執行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
      System.out.println("線程二開始執行");
      runFlag = true;
      System.out.println("線程二執行完畢");
    }).start();
  }
}

輸出結果 :
圖片緩存

結論 : 線程一併無感受到線程二已經將 runFlag 改成true 的信號, 因此"線程一開始執行"這句話一直也沒有輸出,並且程序也沒有終結安全

就像下面的場景:
在這裏插入圖片描述多線程

       在當前場景中就可能出如今處理器 A 和處理器 B 沒有將它們各自的寫緩衝區中的數據刷回內存中, 將內存中讀取的A = 0、B = 0 進行給X和Y賦值,此時將緩衝區的數據刷入內存,致使了最後結果和實際想要的結果不一致。由於只有將緩衝區的數據刷入到了內存中才叫真正的執行 併發

形成這個問題的緣由:app

       計算機在執行程序時,每條指令都是在處理器中執行的。而執行指令過程當中,勢必涉及到數據的讀取和寫入。程序運行過程當中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,因爲處理器執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟處理器執行指令的速度比起來要慢的多,所以若是任什麼時候候對數據的操做都要經過和內存的交互來進行,會大大下降指令執行的速度。爲了解決這個問題,就設計了CPU高速緩存,每一個線程執行語句時,會先從主存當中讀取值,而後複製一份到本地的內存當中,而後進行數據操做,將最新的值刷新到主存當中。這就會形成一種現象緩存不一致ide

針對以上現象提出了緩存一致性協議: MESI優化

       核心思想是:MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。當處理器寫數據時,若是發現操做的變量是共享變量,即在其餘處理器中也存在該變量的副本,會發出信號通知其餘處理器將該共享變量的緩存行置爲無效狀態(總線嗅探機制),所以當其餘處理器須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。.net

嗅探式的緩存一致性協議:線程

       全部內存的傳輸都發生在一條共享的內存總線上,而全部的處理器都能看到這條總線,緩存自己是獨立的,可是內存是共享的。全部的內存訪問都要進行仲裁,即同一個指令週期中只有一個處理器能夠讀寫數據。處理器不只在內存傳輸的時候與內存總線打交道,還會不斷的在嗅探總線上發生數據交換跟蹤其餘緩存在作什麼,因此當一個處理器讀寫內存的時候,其餘的處理器都會獲得通知(主動通知),他們以此使本身的緩存保存同步。只要某個處理器寫內存,其餘處理器就會知道這塊內存在他們的緩存段中已是無效的了。

MESI詳解:

在MESI協議中每一個緩存行有四個狀態 :

  1. Modified修改的,表示這行數據有效,數據被修改了和內存中的數據不一致,數據只存在當前緩存中
  2. Exclusive獨有的,這行數據有效,數據和內存中的數據一致,數據只存在在本緩存
  3. Shared共享的,這行數據有效,數據和內存中的數據一致,數據存在不少緩存中,
  4. Invalid這行數據無效

       這裏的Invalid,shared,modified都符合嗅探式的緩存一致性協議,可是Exclusive表示獨佔的,當前數據有效而且和內存中的數據一致,可是隻在當前緩存中Exclusive狀態解決了一個處理器在讀寫內存的以前咱們要通知其餘處理器這個問題,只有當緩存行處於Exclusive和modified的時候處理器才能寫,就是說只有在這兩種狀態之下,處理器是獨佔這個緩存行的。

       當處理器想寫某個緩存行的時候,若是沒有控制權就必須先發送一條我要控制權的請求給總線,這個時候會通知其餘處理器把他們擁有同一緩存段的拷貝失效,只要在得到控制權的時候處理器才能修改數據,而且此時這個處理器直到這個緩存行只有一份拷貝而且只在它的緩存裏,不會有任何衝突,反之若是其餘處理器一直想讀取這個緩存行,獨佔或已修改的緩存行必需要先回到共享狀態若是是已經修改的緩存行,還要先將內容回寫到內存中

       因此 java 提供了一個輕量級的同步機制volatile

2.做用

       volatile是Java提供的一種輕量級的同步機制。volatile是輕量級,由於它不會引發線程上下文的切換和調度。可是volatile 變量的同步性較差,它不能保證一個代碼塊的同步,並且其使用也更容易出錯。volatile關鍵字 被用來保證可見性,即保證共享變量的內存可見性以解決緩存一致性問題。一旦一個共享變量被 volatile關鍵字修飾,那麼就具有了兩層語義:內存可見性和禁止進行指令重排序。在多線程環境下,volatile關鍵字主要用於及時感知共享變量的修改,並使得其餘線程能夠當即獲得變量的最新值

使用volatile關鍵字後程序的效果 :

使用方式 :

private volatile static boolean runFlag = false;

代碼 :

public class ThreadsShare {
  private volatile static boolean runFlag = false;
  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      System.out.println("線程一等待執行");
      while (!runFlag) {
      }
      System.out.println("線程一開始執行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
      System.out.println("線程二開始執行");
      runFlag = true;
      System.out.println("線程二執行完畢");
    }).start();
  }
}

輸出結果 :
圖片

結論 : 線程一感受到了線程二已經將 runFlag 改成true 的信號, 因此"線程一開始執行"這句話獲得了輸出,並且程序終結了。

volatile 兩個效果

  1. 當一個線程寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量值強制刷新到主內存中去
  2. 這個寫會操做會致使其餘線程中的這個共享變量的緩存失效,要使用這個變量的話必須從新去主內存中取值。

思考 : 若是兩個處理器同時讀取或者修改同一個共享變量咋辦?

多個處理器要訪問內存,首先要得到內存總線鎖,任什麼時候刻只有一個處理器能得到內存總線的控制權,因此不會出現以上狀況。

重點 : volatile關鍵字 被用來保證可見性,即保證共享變量的內存可見性以解決緩存一致性問題

3.特色

3.1 可見性

當一個共享變量被volatile修飾時,它會保證修改的值會當即被更新到主存,當有其餘線程須要讀取時,它會去內存中讀取新值。而普通的共享變量不能保證可見性,由於普通共享變量被修改以後,何時被寫入主存是不肯定的,當其餘線程去讀取時,此時內存中可能仍是原來的舊值,所以沒法保證可見性(以上的案例就已經展現了可見性的做用了)

3.2 禁止指令重排

       在Java內存模型中,容許編譯器和處理器對指令進行重排序,可是重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性

volatile關鍵字禁止指令重排序有兩層意思:

  1. 當程序執行到volatile變量的讀操做或者寫操做時,在其前面的操做的更改確定所有已經進行,且結果已經對後面的操做可見;在其後面的操做確定尚未進行;
  2. 在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

       爲了解決處理器重排序致使的內存錯誤,java編譯器在生成指令序列的適當位置插入內存屏障指令,來禁止特定類型的處理器重排序

內存屏障指令 : 內存屏障是volatile語義的實現下面會講解

屏障類型 指令示例 說明
LoadLoadBarriers Load1;LoadLoad;Load2 Load1數據裝載發生在Load2及其全部後續數據裝載以前
StoreStoreBarriers Store1;StoreStore;Store2 Store1數據刷回主存要發生在Store2及其後續全部數據刷回主存以前
LoadStoreBarriers Load1;LoadStore;Store2 Load1數據裝載要發生在Store2及其後續全部數據刷回主存以前
StoreLoadBarriers Store1;StoreLoad;Load2 Store1數據刷回內存要發生在Load2及其後續全部數據裝載以前

4.volatile 與 happens-before

public class Example {
  int r = 0;
  double π = 3.14;
  volatile boolean flag = false; // volatile 修飾
  /**
   * 數據初始化
   */
  void dataInit() {
    r = 1; // 1
    flag = true; // 2
  }
  /**
   * 數據計算
   */
  void compute() {
    if(flag){ // 3
      System.out.println(π * r * r); //4
    }
  }
}

       若是線程A 執行 dataInit() ,線程B執行 compute() 根據 happens-before 提供的規則(前一篇java內存模型有講) java內存模型有講步驟 2 必定在步驟 3 前面符合volatile規則, 步驟 1 在步驟 2前面,步驟 3 在步驟 4 前面,因此根據傳遞性規則 步驟 1 也在步驟 4 前面。

5.內存語義

5.1 讀內存語義

       當讀取一個volatile的變量時會將本地的工做內存變成無效,去內存中獲取volatile修飾的變量當前值。

5.2 寫內存語義

       當寫一個volatile的變量時會將本地的工做內存中的值強制的刷回內存中。

5.3 內存語義的實現

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

是否能從新排序 第二個操做
第一個操做 普通的讀或者寫 volatile讀 volatile寫
普通的或者寫 NO
volatile 讀 NO NO NO
volatile 寫 NO NO

舉例說明,第三行最後一個單元格的意思:

       當地一個操做爲普通操做的時候,若是第二個操做爲volatile寫,那麼編譯器不能重排序這兩個操做

5.4 總結

  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 屏障。

volatile寫插入內存屏障後生成的指令序列示意圖:

在這裏插入圖片描述

       StoreStore屏障能夠保證在volatile 寫以前,其前面的全部普通寫操做已經對任意處理器可見了,這是由於StoreStore屏障將保障上面全部的普通寫在volatile 寫以前刷新到主內存。

       StoreLoad屏障能夠保證volatile寫與後面可能有的volatile讀或者寫操做重排序。

volatile讀插入內存屏障後生成的指令序列示意圖:

圖片

       LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。

       LoadStore 屏障用來禁止處理器把上面的volatile讀與下面的普通讀寫重排序。

6.實戰

6.1 使用 volatile 必須具有條件

  • 對變量的寫操做不依賴於當前值
  • 該變量沒有包含在具備其餘變量的不變式中

       實際上,這些條件代表能夠被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。事實上,上面的兩個條件就是保證對該volatile變量的操做是原子操做,這樣才能保證使用 volatile關鍵字的程序在併發時可以正確執行

6.2 volatile 主要使用的場景

       在多線程環境下及時感知共享變量的修改,並使得其餘線程能夠當即獲得變量的最新值

場景一 : 狀態標記量(文中舉例)

public class ThreadsShare {
  private volatile static boolean runFlag = false; // 狀態標記
  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      System.out.println("線程一等待執行");
      while (!runFlag) {
      }
      System.out.println("線程一開始執行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
      System.out.println("線程二開始執行");
      runFlag = true;
      System.out.println("線程二執行完畢");
    }).start();
  }
}

場景二 Double-Check

DCL版單例模式是double check lock 的縮寫,中文名叫雙端檢索機制。所謂雙端檢索,就是在加鎖前和加鎖後都用進行一次判斷

public class Singleton1 {
    private static Singleton1 singleton1 = null;
    private Singleton1 (){
        System.out.println("構造方法被執行.....");
    }
    public static Singleton1 getInstance(){
        if (singleton1 == null){ // 第一次check
            synchronized (Singleton1.class){
                if (singleton1 == null) // 第二次check
                    singleton1 = new Singleton1();
            }
        }
        return singleton1 ;
    }
 }

       用synchronized只鎖住建立實例那部分代碼,而不是整個方法。在加鎖前和加鎖後都進行了判斷,這就叫雙端檢索機制。這樣確實只建立了一個對象。可是,這也並不是絕對安全。new 一個對象也是分三步的:

  • 1.分配對象內存空間
  • 2.初始化對象
  • 3.將對象指向分配的內存地址,此時這個對象不爲null

       步驟二和步驟三不存在數據依賴,所以編譯器優化時容許這兩句顛倒順序。當指令重排後,多線程去訪問也會出問題。因此便有了以下的最終版單例模式。這種狀況不會發生指令重排

public class Singleton2 {
  private static volatile Singleton2 singleton2 = null;
  private Singleton2() {
    System.out.println("構造方法被執行......");
  }
  public static Singleton2 getInstance() {
    if (singleton2 == null) { // 第一次check
      synchronized (Singleton2.class) {
        if (singleton2 == null) // 第二次check
          singleton2 = new Singleton2();
      }
    }
    return singleton2;
  }
}
相關文章
相關標籤/搜索