首頁 > 軟體

Java記憶體模型JMM與volatile

2022-07-29 22:05:44

1.Java記憶體模型

JAVA定義了一套在多執行緒讀寫共用資料時時,對資料的可見性、有序性和原子性的規則和保障。遮蔽掉不同作業系統間的微小差異。

Java記憶體模型(Java Memory Model)是一種抽象的概念,並不真實存在,它描述的是一組規則或規範(定義了程式中各個變數的存取方式)。 JVM執行程式的實體是執行緒,而每個執行緒建立時 JVM 都會為其建立一個工作記憶體(棧空間),用於儲存執行緒私有的資料,而Java 記憶體模型中規定所有變數都儲存在主記憶體主記憶體是共用記憶體區域,所有執行緒都可以存取, 但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行。所以首先要將變數從主記憶體拷貝的自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數。工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法存取對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成。

基於JMM規範的執行緒,工作記憶體,主記憶體工作互動圖 :

  • 主記憶體: 執行緒的共用資料區域,主要儲存的是Java範例物件,所有執行緒建立的範例物件都存放在主記憶體中(包括區域性變數、類資訊、常數、靜態變數)。
  • 工作記憶體: 執行緒私有,主要儲存當前方法的所有本地變數資訊(主記憶體中的變數副本拷貝) , 每個執行緒只能存取自己的工作記憶體,即執行緒中的本地變數對其它執行緒是不可見的,即使存取的是同一個共用變數。

對於一個範例物件中的成員方法: 如果方法中包含本地變數是基本資料型別,將直接儲存在工作記憶體的幀棧結構中,如果是參照型別,那麼該變數的參照會儲存在功能記憶體的幀棧中,而物件範例將儲存在主記憶體(共用資料區域,堆)中。

需要注意的是,在主記憶體中的範例物件可以被多執行緒共用,倘若兩個執行緒同時呼叫了同一個物件的同一個方法,那麼兩條執行緒會將要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體

下面是執行緒讀取共用變數count執行count + 1 操作的過程:

資料同步八大原子操作:

  • (1)lock(鎖定): 作用於主記憶體的變數,把一個變數標記為一條執行緒獨佔狀態
  • (2)unlock(解鎖): 作用於主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後 的變數才可以被其他執行緒鎖定
  • (3)read(讀取): 作用於主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體 中,以便隨後的load動作使用
  • (4)load(載入): 作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工 作記憶體的變數副本中
  • (5)use(使用): 作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎
  • (6)assign(賦值): 作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作內 存的變數
  • (7)store(儲存): 作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體 中,以便隨後的write的操作
  • (8)write(寫入): 作用於工作記憶體的變數,它把store操作從工作記憶體中的一個變數的值 傳送到主記憶體的變數中

2.並行三大特性

2.1.原子性

定義: 一個操作在CPU中不可以中途暫停再排程,要麼全部執行完成,要麼全部都不執行

問題: 兩個執行緒對初始值的靜態變數一個做自增,一個做自減同樣做10000次的結果很可能不是 0

解決關鍵字: synchronized、ReentrantLock 建議:

  • 用sychronized對物件加鎖的力度建議大一點(減少加解鎖次數)
  • 鎖住同一個物件

2.2.可見性

定義: 當多個執行緒存取同一個變數時,一個執行緒修改了這個變數的值,其他執行緒立即看得到修改的值。(即時性)

問題: 兩個執行緒在不同的 CPU ,若執行緒1改變了變數 i 的值,還未重新整理到主記憶體,執行緒2又使用了 i,那麼執行緒2看到的這個值肯定還是之前的

//執行緒1
boolean stop = false;
while(stop){
    ....
}
//執行緒2
stop = true;
//並未退出迴圈

解決關鍵字: synchronized、volatile

volatile 關鍵字,它可以用來修飾成員變數和靜態成員變數,避免執行緒從自己的工作記憶體中查詢變數值,必須到主記憶體中獲取它的值,執行緒操作volatile變數都是直接操作主記憶體,還可以禁止指令重排。 synchronized語句塊既可以保證程式碼的原子性,也可以保證程式碼塊內部的可見性,但是呢synchronized屬於重量級操作,效能相對更低

注意: 對於上述迴圈程式碼塊,加入System.out.println(); 會退出迴圈,因為 println 被 synchronized 修飾,所有,不要隨便在程式碼中使用這種列印語句,會極度影響程式效能。

2.3.有序性

定義: 虛擬機器器在進行程式碼編譯時,對改變順序後不會對最終結果造成影響的程式碼,虛擬機器器不一定會按我們寫的程式碼順序執行,有可能進行重排序。實際上雖然重排後不會對變數值有影響,但會造成執行緒安全問題。

解決關鍵字: synchronized、ReentrantLock  volatile關鍵字,可以禁止指令重排

指令重排: JIT 編譯器在執行時的一些優化,可以提升 CPU 的執行效率,不讓 CPU 空閒下來。對改變順序後不會對最終結果造成影響的程式碼,虛擬機器器不一定會按我們寫的程式碼順序執行,有可能進行重排序。比如說,我兩行程式碼 X 和 Y,虛擬機器器認為它們倆的執行順序不影響程式結果,但 Y 已經在 CacheLine 中存在了,就會優先執行 Y。

分析下面虛擬碼的執行情況(r.r1的值):

int num = 0;
boolean ready = false;
// 執行緒1 執行此方法
public void action1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 執行緒2 執行此方法
public void action2(I_Result r) {
    num = 2;
    ready = true;
}
情況1:執行緒1 先執行,此時 ready = false,所有進入else ,結果為1
情況2:執行緒2 先執行 num = 2,但還沒來得及執行 ready = true,執行緒1 開始執行,還是進入else ,結果為1
情況3:執行緒2 先執行到ready = true,執行緒1 執行,進入else ,結果為4
情況4:指令重排導致,執行緒2執行 ready = true,切換到執行緒1,進入 if 分支,相加為0,再切回執行緒2,執行 num = 2,結果為0

double-checked locking 單例模式: 也存在指令重排問題(不使用volatile,物件範例化是原子操作,但分為幾步,每一步又不是原子操作),因此需要在物件前加上 volatile 關鍵字防止指令重排,這也是個非常經典的禁止指令重排的例子。

public class SingleLazy {
    private SingleLazy() {}
    private volatile static SingleLazy INSTANCE;
    // 獲取實體
    public static SingleLazy getInstance() {
        // 範例未被建立,開啟同步程式碼塊準備建立
        if (INSTANCE == null) {
            synchronized (SingleLazy.class) {
                // 也許其他執行緒在判斷完後已經建立,再次判斷
                if (INSTANCE == null) {
                    INSTANCE = new SingleLazy();
                }
            }
        }
        return INSTANCE;
    }
}

建立物件可以大致分為三步,其中第一步和第二步可能會發生指令重排導致安全性問題:

memory = allocate();//1.分配物件記憶體空間
instance(memory);//2.初始化物件
instance = memory;//3.設定instance指向剛分配的記憶體地址,此時instance e != null

注意: JDK1.5前的 volatile 關鍵字不保證指令重排問題

3.兩個規則

as-if-serial 語意保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結構不被改變

3.1.happens-before規則

定義: 如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前

兩個操作之間存在 happens-before 關係,並不意味 java 平臺的具體實現必須按照 happens-before 關係指定的順序來執行。如果重排序後的執行結構,與按 happens-before 關係來執行的結果一致,那麼這種重排序並不非法(JMM允許這種重排序),happens-before 原則內容如下:

程式順序原則 即在一個執行緒內必須保證語意序列性,也就是說按照程式碼順序執行,(時間上)先執行的操作happen-before(時間上後執行的操作)

鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。

volatile規則 volatile變數的寫,先發生於讀,這保證了volatile變數的可見性,簡 單的理解就是,volatile變數在每次被執行緒存取時,都強迫從主記憶體中讀該變數的 值,而當該變數發生變化時,又會強迫將最新的值重新整理到主記憶體,任何時刻,不同的執行緒總是能夠看到該變數的最新值。

執行緒啟動規則 執行緒的start()方法先於它的每一個動作,即如果執行緒A在執行執行緒B 的start方法之前修改了共用變數的值,那麼當執行緒B執行start方法時,執行緒A對共用 變數的修改對執行緒B可見

傳遞性 A先於B ,B先於C 那麼A必然先於C

執行緒終止規則 執行緒的所有操作先於執行緒的終結,Thread.join()方法的作用是等待 當前執行的執行緒終止。假設線上程B終止之前,修改了共用變數,執行緒A從執行緒B的 join方法成功返回後,執行緒B對共用變數的修改將對執行緒A可見。

執行緒中斷規則 對執行緒 interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到 中斷事件的發生,可以通過Thread.interrupted()方法檢測執行緒是否中斷。

物件終結規則 物件的建構函式執行,結束先於finalize()方法

3.2.as-if-serial

不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結構不能被改變。為了遵守as-if-serial 語意,編譯器和處理器不會存在資料依賴關係的操作做重排序,但是如果操作之前不存在資料依賴關係,這些操作就可能被編譯器和處理器重排序。

4.volatile

volatile是Java虛擬機器器提供的輕量級的同步機制,可以保證可見性,但無法保證原子性。  作用:

  • 保證可見性,也就是當一個執行緒修改了一個被volatile修飾共用變數的值,新值總是可以被其他執行緒立即得知。即時可見通過快取一致性協定保證
  • 禁止指令重排優化。通過記憶體屏障實現。
//範例
//並行場景下,count++操作不具備原子性,分為兩步先讀取值,再寫回,會出現執行緒安全問題
public class VolatileVisibility {
    public static volatile int count = 0;
    public static void increase(){
        count++;
    }
}

4.1.volatile 禁止重排優化的實現

volatile 變數通過記憶體屏障實現其可見性和禁止重排優化。

記憶體屏障: 又稱記憶體柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變數的記憶體可見性。編譯器和處理器都能執行指令重排優化。Intel 硬體提供了一系列的記憶體屏障,主要有:Ifence(讀屏障)、sfence(寫屏障)、mfence(全能屏障,包括讀寫)、Lock字首等。不同的硬體實現記憶體屏障的方式不同,Java 記憶體模型遮蔽了這種底層硬體平臺的差異,由 JVM 來為不同的平臺生成相應的機器碼。 JVM 中提供了四類記憶體屏障指令:

屏障型別指令說明
LoadLoadLoad1; LoadLoad; Load2保證load1的讀取操作在load2及後續讀取操作之前執行
StoreStoreLoad1; LoadLoad; Load2在store2及其後的寫操作執行前,保證store1的寫操作
StoreStoreStore1; StoreStore; Store2在stroe2及其後的寫操作執行前,保證load1的讀操作
StoreLoadStore1; StoreLoad; Load2保證store1的寫操作已重新整理到主記憶體之後,load2及其作

volatile記憶體語意的實現: JMM 針對編譯器制定的 volatile 重排序規則表

操作普通讀寫volatile讀volatile寫
普通讀寫可以重排可以重排不可以重排
volatile讀不可以重排不可以重排不可以重排
volatile寫可以重排不可以重排不可以重排

比如第二行最後一個單元格的意思是:在程式中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。 

編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序:

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

class VolatileBarrierExample {
        int a;
        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寫
    }
}

4.2.MESI快取一致性協定

連結: 認識Java底層作業系統與並行基礎.  多核CPU的情況下,如何保證快取內部資料的一致性?JAVA引入了MESI快取一致性協定。 

Java程式碼的執行流程:

volatile 修飾的變數(鎖也是)翻譯的組合指令前會加 Lock 字首,OS排程時會 觸發硬體快取鎖定機制(匯流排鎖 或 快取一致性協定) ,CPU 通過匯流排橋存取記憶體條,多個 CPU 存取同一記憶體,首先需要拿到匯流排權。早期,計算機不發達,效能低,匯流排鎖採用直接佔有,其他 CPU 無法繼續通過匯流排橋存取。無法發揮 CPU 的多核能力。現代 CPU 採用採用快取一致性協定進行保證(跨快取行CacheLine(快取儲存資料的資料單元) 時會升級為匯流排鎖)。

MESI 是指4種狀態的首字母。每個 Cache line 有4個狀態,可用2個bit表示:

狀態描述監聽任務
M 修改(Modified)該CacheLine有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本Cache中快取行必須時刻監聽所有試圖讀該快取行相對就主記憶體的操作,這種操作必須在快取將該快取行寫回主記憶體並將狀態變成S(共用)狀態之前被延遲執行
E 獨享、互斥(Exclusive)該CacheLine有效,資料和記憶體中的資料一致,資料只存在於本Cache中快取行也必須監聽其它快取讀主記憶體中該快取行的操作,一旦有這種操作,該快取行需要變成S(共用)狀態
S 共用 (Shared)該CacheLine有效,資料和記憶體中的資料一致,資料存在於很多Cache中快取行也必須監聽其它快取使該快取行無效或者獨享該快取行的請求,並將該快取行變成無效(Invalid)
I 無效 (Invalid)該CacheLine無效

MESI 協定狀態切換過程分析:

舉例:

注意:一個 CacheLine 裝不下變數,會升級為匯流排鎖。

MESI優化和他們引入的問題:  快取的一致性訊息傳遞是要時間的,這就使其切換時會產生延遲。當一個快取被切換狀態時其他快取收到訊息完成各自的切換並且發出迴應訊息這麼一長串的時間中CPU都會等待所有快取響應完成。可能出現的阻塞都會導致各種各樣的效能問題穩定性問題

 為了避免這種CPU運算能力的浪費,Store Bufferes 被引入使用。處理器把它想要寫入到主記憶體的值寫到快取,然後繼續去處理其他事情。當所有失效確認(Invalidate Acknowledge)都接收到時,資料才會最 終被提交。

但它也會帶來一定的風險:

  • 處理器會嘗試從儲存快取(Store buffer)中讀取值,但它還沒有進行提交。這個的解決方案稱為Store Forwarding,它使得載入的時候,如果儲存快取中存在,則進行返回
  • 儲存什麼時候會完成,這個並沒有任何保證,可能會發生重排序(非指令重排)。CPU會讀到跟程式中寫入的順序不一樣的結果。

到此這篇關於Java記憶體模型JMM與volatile的文章就介紹到這了,更多相關Java記憶體模型內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com