1. 程式人生 > 程式設計 >1.2.1 執行緒安全之可見性問題

1.2.1 執行緒安全之可見性問題

多執行緒中的問題

  • 所見非所得

  • 無法肉眼去檢測程式的準確性

  • 不同的執行平臺有不同的表現

  • 錯誤很難重現

  • 程式碼舉例:

    public class VisibilityDemo {
        private boolean flag = true;
    
        public static void main(String[] args) throws InterruptedException {
            VisibilityDemo demo1 = new VisibilityDemo();
            Thread thread1 = new Thread(new Runnable() {
                @Override
    public void run() { int i=0; while (demo1.flag){ i++; } System.out.println(i); } }); thread1.start(); TimeUnit.SECONDS.sleep(2); // 設定flag為false,預期使上面的執行緒結束while迴圈,但是沒有成功
    demo1.flag=false; System.out.println("被設定為false了"); } } 複製程式碼

    從記憶體結構到記憶體模型的原理,來分析排查導致的原因。

從記憶體結構到記憶體模型

工作記憶體快取

  • JVM記憶體分為工作記憶體(執行緒獨享的記憶體,儲存在cpu快取記憶體),和主記憶體(java堆,儲存java物件例項的)。
  • 多核情況下,不同CPU中執行的執行緒是直接與對應的CPU快取互動資料的,極有可能與主記憶體的資料在極短時間內不一致。導致程式執行結果與預期不一致。

指令重排

  • Java程式語言的語義允許編譯器和微處理器執行優化,這些優化可以與不正確的同步程式碼互動,從而產生看似矛盾的行為。
  • 即時編譯器(JIT compiler,just-in-time compiler),提供了程式碼優化機制,來提高 JVM 的效能。也正是由於開啟了 JIT,使得 class 檔案在執行成為組合語言時進行指令重排。
  • 可以通過設定 JVM 引數來關閉 JIT 優化機制:-Djava.compiler=NONE。但是 JVM 的效能會降低很多。所以java 提供了 volatile 關鍵字來解決這個問題:CPU快取可能導致非常短的時間內資料不一致
  • volatile :宣告這個欄位不能被儲存在CPU快取中,每次都要從主存中讀取。解決了資料在多執行緒間的可見性問題。

記憶體模型 - 含義

  • 記憶體模型實際上就是一種規範。決定了在程式的每個點可以讀取什麼值。
  • 比如,每個執行緒都由自己私有的工作記憶體(執行緒棧記憶體),要想多執行緒之間進行資料互動就需要在共享記憶體或堆記憶體中進行訪問。而這就需要一種規範,來規定每個操作。具體的記憶體模型的實現就需要不同平臺的 JVM 的不同實現。
  • 記憶體模型描述了程式的可能行為,程式的所有執行產生的結果都可以由記憶體模型預測。具體的實現者任意實現,包括操作的重新排序和刪除不必要的同步。

記憶體模型 - Shared Variables 共享變數描述

  • 可以線上程之間共享的記憶體稱為共享記憶體或堆記憶體。
  • 所有例項欄位、靜態欄位和陣列元素都儲存在堆記憶體中。

記憶體模型 - 執行緒操作的定義

  • 操作定義
    • write:要寫的變數以及要寫的值;
    • read:要讀的變數以及可見的寫入值(由此,我們可以確定可見的值)。
    • lock:要鎖定的管程(監視器monitor);
    • unlock:要解鎖的管程;
    • 外部操作(socket等...);
    • 啟動和終止。
  • 程式順序:如果一個程式沒有資料競爭,那麼程式的所有執行看起來都是順序一致的。
  • 本規範只涉及執行緒間的操作;

記憶體模型 - 對於同步的規則定義(抽象的規範要求)

  • 啟動執行緒的操作與執行緒中的第一個操作同步(執行緒能看到操作的資料變化,記憶體模型規定:資料發生變化要及時反饋)

  • 對於監視器 m 的解鎖與所有後續操作對於 m 的加鎖同步(同步關鍵字synchronized,保持可見性。記憶體模型規定:鎖的變化要及時反饋通知其他執行緒,同步區塊內的資料要同步到主記憶體,保持可見性)

  • 對於 volatile 變數 v 的寫入,與所有其他執行緒後續對 v 的讀同步(volatile變數的v保持可見。執行緒1操作了v,後續執行緒能讀到最新的v)

  • 對於每個屬性寫入預設值(0,false,null)與每個執行緒對其進行的操作同步(屬性的預設值,在它變化前,就必須能被讀取到)

  • 執行緒 T1 的最後操作與執行緒 T2 發現執行緒 T1 已經結束同步。(isAlive,join 可以判斷執行緒是否終結。執行緒中止狀態的可見性)

  • 如果執行緒 T1 中斷了 T2,那麼執行緒 T1 的中斷操作與其他所有執行緒發現 T2 被中斷了同步(執行緒被中止,那麼它所有的監聽者都要收到這個事件。同步=====可見性)

    通過丟擲 InterruptedException 異常,或呼叫 Thread.interrupted 或 Thread.isInterrupted

記憶體模型 - happens-before 先行發生原則

happens-before關係主要用於強調兩個有衝突的動作之間的順序,以及定義資料的發生時機。

具體的虛擬機器器實現,有必要確保以下原則的成立:

  • 某個執行緒中的每個動作都 happens-before 該執行緒中的該動作後面的動作。
  • 某個管程(monitor)上的 unlock 動作 happens-before 同一個管程上後續的 lock 動作。
  • 對某個 volatile 欄位的寫操作 happens-before 每個後續對該 volatile 欄位的讀操作。
  • 在某個執行緒執行緒上呼叫 start() 方法 happens-before 該啟動了的執行緒中任意動作。
  • 某個執行緒中的所有動作 happens-before 任意其他執行緒成功從該執行緒物件上的 join() 中返回。
  • 如果某個動作 a happens-before 動作 b,且 b happens-before 動作 c,則有 a happens-before c。

當程式包含兩個沒有被 happens-before 關係排序的衝突訪問時,就稱存在資料爭用

遵守了這個原則,也就意味著有些程式碼不能進行重排序!

記憶體模型 - final 在 JVM 中的處理

  • final 在該物件的建構函式中設定物件的欄位,當執行緒看到該物件時,將始終看到該物件的final欄位的正確構造版本。(類中final欄位,在建構函式中被賦值,建立物件以後在被不同的執行緒讀取欄位時,保證都能讀到建構函式中賦予的值。如果是普通欄位,就不能保證,可能有的執行緒讀的是預設值。普通欄位沒有被要求可見性。)

  • 如果在建構函式中設定欄位後發生讀取,則會看到該final欄位分配的值,否則它將看到預設值。

    虛擬碼例項:public finalDemo(){ x=1;y=x;}; y會等於1;

  • 讀取該共享物件的 final 成員變數之前,先要讀取共享物件。

    虛擬碼例項:r=new ReferenceObj();k=r.f;這兩個操作不能重排序。

  • 通常 static final 是不可以修改的欄位。然後System.in,System.out 和 System.err 是static final 欄位,遺留原因,必須允許通過set方法改變,我們將這些欄位稱為防寫,以區別於普通 final 欄位。

記憶體模型 - Word Tearing 位元組處理

記憶體模型 - double 和 long 的特殊處理

volatile關鍵字總結

  • 可見性問題:讓一個執行緒對共享變數的修改,能夠及時的被其他執行緒看到。

根據 JMM 中規定的 happen before 和同步原則:

要滿足這些條件,所以 volatile 關鍵字就有這些功能:

  1. 禁止快取;volatile變數的訪問控制符會加個ACC_VOLATILE。
  2. 對 volatile 變數相關的指令不做重排序;