【Java併發】1. Java執行緒記憶體模型JMM及volatile相關知識

晴天天天藍發表於2021-04-16

JMM

併發程式設計的三大特性:可見性(volatile)、有序性(volatile)、原子性(synchronized)

  • JMM跟CPU快取模型相似,是基於CPU快取模型來建立的,是標準化的,遮蔽了不同計算機的區別
  • JMM隸屬於JVM,定義了執行緒與主記憶體間的抽象關係,執行緒間的共享變數存放於主記憶體
  • 每個執行緒均有私有工作記憶體(JMM抽象概念,實際不存在),工作記憶體包含了該執行緒讀寫共享變數的副本。如果需要使得變數其他執行緒可訪問,需要加volatile修飾
  • 執行緒實際操作的資料均為其工作記憶體的變數副本,所以會出現多執行緒裡相同變數無法同步

JMM八大原子操作及volatile的可見性原理

JMM八大原子操作是:lock(鎖定)、unlock(解鎖)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(儲存)、write(寫入)。
有如下案例,當flag有volatile修飾時,執行main函式,將輸出"flag值已改";無volatile修飾時則A執行緒一直死迴圈

public class Demo {
  // 沒有volatile修飾時,B執行緒對flag的改動,A執行緒是不可見的
  // 有volatile修飾時,B執行緒對flag的改動對A執行緒同樣可見
  private static boolean flag = false;
  public static void setFlag(){
    flag = true;
  }

  public static void main(String[] args){
    // A 執行緒,死迴圈檢查flag的值,如果發生改變,則退出死迴圈
    new Thread(() -> {
      while(!flag);
      System.out.println("flag值已改");
    }).start();
    // B 執行緒,改變flag的值
    new Thread(() -> {
      setFlag();
    }).start();
  }
}
  • 在不加volatile修飾時,執行緒A的JMM操作流程

    • 主記憶體裡有共享變數flag=false
    • read操作:將變數從主記憶體裡讀取到執行緒的工作記憶體中
    • load操作:將read讀取的值從工作記憶體放到工作記憶體裡的副本變數中
    • use操作:將副本變數傳遞給cpu使用(例子裡A執行緒需要對falg進行取反操作,即使用use從副本變數裡取得flag到cpu,再進行取反計算)
    • 由於A執行緒在死迴圈裡,所以每次迴圈檢查flag的值時,均會直接從副本變數裡取得flag值
  • 在不加volatile修飾時,執行緒B的JMM操作流程

    • 主記憶體裡有共享變數flag=false
    • read操作:將變數從主記憶體裡讀取到執行緒的工作記憶體中
    • load操作:將read讀取的值從工作記憶體放到工作記憶體裡的副本變數中
    • use操作:將副本變數傳遞給cpu使用
    • assign操作:B執行緒將flag設定為了true,此時觸發assign操作,將cpu計算所得值賦值給工作記憶體裡的變數副本中
    • store操作:將工作記憶體裡的共享變數傳入主記憶體
    • write操作:將store傳送的值寫道主記憶體變數裡,至此主記憶體內flag=true
    • 所以B執行緒對共享變數的修改,無法同步到A執行緒

圖片來自網路

  • volatile實現共享變數可見的原理
    • 瞭解JVM的都知道volatile具備兩個特殊規則:

      • read、load、use動作必須連續出現
        這三個操作將資料從主記憶體讀取到cpu
      • assign、store、write動作必須連續出現
        這三個操作將資料從cpu寫回到主記憶體,也即是賦值語句會馬上更新到主記憶體中去
    • 對變數加了volatile指令後,編譯成的彙編指令裡會給賦值語句加入lock字首指令。作用如下:

      • 被volatile修飾的變數,在某執行緒裡其資料在工作記憶體中但凡出現變動,將被立即寫回主記憶體(相當於JMM操作assign、store、write必須連續出現)
      • 開啟快取一致性協議,資料回寫主記憶體的操作會引起其他執行緒裡對應快取資料立即失效(工作記憶體裡的對應變數失效)。此時其他執行緒需要重新從主記憶體讀取變數,這樣就確保了其他執行緒讀取到了最新的資料
      • 提供記憶體屏障功能,使lock指令前後的指令不能重新排序(volatile有序性的原理)

volatile的有序性原理

  • 指令重排序

    在不影響單執行緒執行結果(多執行緒不保證)的情況下,計算機為了優化效能,會對機器指令進行重新排序優化。重排序遵循as-if-serial和happens-before原則

    • as-if-serial:重排序不影響單執行緒執行結果
    • happens-before:定義了一些規則來遵循
  • 物件半初始化問題

    對於雙重檢查鎖單例模式,如下程式碼,在執行lazy = new Singleton();語句時,是有可能被指令重排序的。假設A執行緒由於重排序,在未初始化完成的情況下,先給lazy賦值了,恰巧賦值後B執行緒也執行getInstance方法,獲取到了不為null的lazy變數,而這時候A執行緒卻並沒有初始化完畢單例物件,則B執行緒將使用半初始化的單例物件,造成錯誤。這就是經典的物件半初始化問題
    對於此問題,只需要在單例變數lazy宣告時用volatile修飾即可解決,因為volatile禁止指令重排序

    public class Singleton { 
      private volatile static Singleton lazy = null; 
      private Singleton(){} 
      public static Singleton getInstance(){ 
        if(lazy == null){ 
          synchronized (Singleton.class){ 
            if(lazy == null){ 
              //1.分配記憶體給這個物件 
              //2.初始化物件 
              //3.設定 lazy 指向剛分配的記憶體地址
              lazy = new Singleton(); 
            } 
          } 
        }
        return lazy; 
      } 
    }
    
  • volatile關鍵字通過“記憶體屏障”來防止指令被重排序,記憶體屏障底層依舊是通過彙編的lock來實現的

    • JMM記憶體屏障規範

      • LoadLoad:[Load1;LoadLoad;Load2] 保證load1的讀操作在load2及後續讀操作前執行
      • StoreStore:保證Store1寫操作已重新整理至主記憶體,才進行後續的Store操作
      • LoadStore:保證Load1讀取結束後,後續的Store才進行
      • StoreLoad:保證Store1寫操作已重新整理到主記憶體後,才進行後續的Load操作
    • JVM要求volatile需要執行的記憶體屏障規範

      • 在每個volatile寫操作的前面插入一個 StoreStore 屏障。
      • 在每個volatile寫操作的後面插入一個 StoreLoad 屏障。
      • 在每個volatile讀操作的後面插入一個 LoadLoad 屏障。
      • 在每個volatile讀操作的後面插入一個 LoadStore 屏障。

交流&聯絡

  • QQ群
    歡迎加入Java交流群(qq群號: 776241689 )

  • 歡迎關注公眾號"後端技術學習分享"獲取更多技術文章!
    PS:小到Java後端技術、計算機基礎知識,大到微服務、Service Mesh、大資料等,都是本人研究的方向。我將定期在公眾號中分享技術乾貨,希望以我一己之力,拋磚引玉,幫助朋友們提升技術能力,共同進步!
    在這裡插入圖片描述

  • 部落格

原創不易,轉載請在開頭著名文章來源和作者。如果我的文章對您有幫助,請點贊/收藏/關注鼓勵支援一下吧❤❤❤❤❤❤

原創不易,轉載請在開頭著名文章來源和作者。如果我的文章對您有幫助,請點贊/收藏/關注鼓勵支援一下吧❤❤❤❤❤❤

相關文章