一篇文章告訴你什麼是Java記憶體模型

2023-05-23 12:01:30

在上篇 並行程式設計Bug起源:可見性、有序性和原子性問題,介紹了作業系統為了提示執行速度,做了各種優化,同時也帶來資料的並行問題,

定義

在單執行緒系統中,程式碼按照順序從上往下順序執行,執行不會出現問題。比如一下程式碼:

int a = 1;
int b = 2;
int c = a + b;

程式從上往下執行,最終c的結果一定會是3

但是在多執行緒環境中,程式碼就不一定會順序執行了。程式碼的執行結果也有不確定性。在開發中,自己本地沒問題,一行行檢視程式碼也沒有問題,但是在高並行的生產環境就會出現違背常理的問題。

多執行緒系統提升效能有如下幾個優化:

  • 單核的cpu改成多核的cpu,每個cpu都有自己的快取。
  • 多個執行緒可以在cpu執行緒切換。
  • 程式碼可能根據編譯優化,更新程式碼的位置。

這些優化會導致可見性原子性以及有序性問題,為了解決上述問題,Java記憶體模型應運而生。

Java記憶體模型是定義了Java程式在多執行緒環境中,存取共用記憶體和記憶體同步的規範,規定了執行緒之間的互動方式,以及執行緒與主記憶體、工作記憶體的的資料交換。

Java記憶體模型解決並行

導致可見性的原因的是快取,導致有序性的問題是編譯優化,那解決可見性、有序性問題就是禁用快取和編譯優化。這樣雖然解決了並行問題,但是效能卻下降了。

合理的方案就是按需求禁用快取和編譯優化,在需要的地方新增對應的編碼即可。Java記憶體模型規範了JVM如何按需禁用快取和編譯優化,具體包括volatilesynchronizedfinal這幾個關鍵字,以及Happens-Before規則。

可見性問題

在多核cpu作業系統中每次cpu都有自己的快取,cpu先從記憶體獲取資料,再進行運算。比如下圖中執行緒A和執行緒B,分別執行自己的cpu,然後從記憶體獲取變數到自己的cpu快取中,並進行計算。

執行緒B改變了變數之後,執行緒A是無法獲取到最新的值。以下程式碼中,啟動兩個執行緒,執行緒啟動完執行緒A,迴圈獲取變數,如果是true,一直執行迴圈,直到被改成false才跳出迴圈,然後再延遲1s啟動執行緒B,執行緒修改變數值為true:

private static boolean flag = true;

// 執行緒A一直讀取變數flag,直到變數為false,才跳出迴圈
class ThreadA extends Thread {
    @Override
    public void run() {
        while (flag) {
            // flag 為 true,一直讀取flag欄位,flag 為 false 時跳出來。
            //System.out.println("一直在讀------" + flag);
        }
        System.out.println("thread - 1 跳出來了");
    }
}
// 1s 後執行緒B將變數改成 false
class ThreadB extends Thread {

    @Override
    public void run() {
        System.out.println("thread-2 run");
        flag = false;
        System.out.println("flag 改成 false");
    }
}

@Test
public void test2() throws InterruptedException {
    new Thread1().start();
    // 暫停一秒,保證執行緒1 啟動並執行
    Thread.sleep(1000);
    new Thread2().start();
}

執行結果:

thread-2 run
flag 改成 false

執行緒A一直處於執行中,說明執行緒B修改後的變數,執行緒A並未知道。

flag變數新增volatile宣告,修改成:

private static volatile boolean  flag = true;

再執行程式,執行結果:

thread-2 run
flag 改成 false
thread - 1 跳出來了

執行緒B執行完後,執行緒A也跳出了迴圈。說明修改了變數後,其他執行緒也能獲取最新的值。

一個未宣告volatile的變數,都是從各自的cpu快取獲取資料,執行緒更新資料之後,其他執行緒無法獲取最新的值。而使用volatile宣告的變數,表明禁用快取,更新資料直接更新到記憶體中,每次獲取資料都是直接記憶體獲取最新的資料。執行緒之間的資料都是相互可見的。

可見性來自happens-before規則,happens-before用來描述兩個操作的記憶體可見性,如操作Ahappens-before操作B,那麼A的結果對於B是可見的,前面的一個操作結果對後續操作是可見的happens-before定義了以下幾個規則:

  • 解鎖操作happens-before同一把鎖的加鎖操作。
  • volatile 欄位的寫操作happens-before同一欄位的讀操作。
  • 執行緒的啟動操作happens-before該執行緒的第一個操作。
  • Ahappens-beforeB,且Bhappens-beforeC,那麼Ahappens-beforeC。happens-before具有傳遞性。

有序性問題

先看一個反常識的例子:

int a=0, b=0;
public void method1() {
    b = 1;
    int r2 = a; 
}

public void method2() {
    a = 2; 
    int r1 = b; 
}

定義了兩個共用變數ab,以及兩個方法。第一個方法將共用變數b賦值為1 ,然後將區域性變數r2賦值為a。第二個方法將共用變數a賦值為2,然後將區域性變數r1賦值為b

在單執行緒環境下,我們可以先呼叫第一個方法method1,再呼叫method2方法,最終得到r1r2的值分別為1,0。也可以先呼叫method2,最後得到r1r2的值分別為0,2

如果程式碼沒有依賴關係,JVM編譯優化可以對他們隨意的重排序,比如method1方法沒有依賴關係,進行重排序:

int a=0, b=0;
public void method1() {
    int r2 = a; 
    b = 1;
}

public void method2() { 
    int r1 = b; 
    a = 2;
}

此時在多執行緒環境下,兩個執行緒交替執行method1method2方法:

重排序後r1r2分別是0,0

那如何解決重排序的問題呢?答案就是將變數宣告為volatile,比如a或者b變數宣告volatile。比如b宣告為volatile,此時b的賦值操作要happens-before r1的賦值操作。

int a=0;
volatile int b=0;
public void method1() {
    int r2 = a; 
    b = 1;
}

public void method2() { 
    int r1 = b; 
    a = 2;
}

同一個執行緒順序也滿足happens-before關係以及傳遞性,可以得到r2的賦值happens-before a的賦值。也就表明對a賦值時,r2已經完成賦值了。也就不可能出現r1r200的結果。

記憶體模型的底層實現

Java記憶體模型是通過記憶體屏障來實現禁用快取和和禁用重排序

記憶體屏障會禁用快取,在記憶體寫操作時,強制重新整理寫快取,將資料同步到記憶體中,資料的讀取直從記憶體中讀取。

記憶體屏障會限制重排序操作,當一個變數宣告volatile,它就插入了一個記憶體屏障,volatile欄位之前的程式碼只能在之前進行重排序,它之後的程式碼只能在之後進行重排序。

總結

Java記憶體模型(Java Memory Model,JMM)定義了Java程式中多執行緒之間共用變數的存取規則,以及執行緒之間的互動行為。它規定了執行緒如何與主記憶體和工作記憶體互動,以確保多執行緒程式的可見性、有序性和一致性。

  • 可見性:使用volatile宣告變數,資料讀取直接從記憶體中讀取,更新也是強制重新整理快取,並同步到主記憶體中。

  • 有序性:使用volatile宣告變數,確保編譯優化不會重排序該欄位。

  • Happens-Before: 前面一個操作的結果對後續操作是可見的

參考