在上篇 並行程式設計Bug起源:可見性、有序性和原子性問題,介紹了作業系統為了提示執行速度,做了各種優化,同時也帶來資料的並行問題,
在單執行緒系統中,程式碼按照順序從上往下
順序執行,執行不會出現問題。比如一下程式碼:
int a = 1;
int b = 2;
int c = a + b;
程式從上往下執行,最終c
的結果一定會是3
。
但是在多執行緒環境中,程式碼就不一定會順序執行了。程式碼的執行結果也有不確定性。在開發中,自己本地沒問題,一行行檢視程式碼也沒有問題,但是在高並行的生產環境就會出現違背常理的問題。
多執行緒系統提升效能有如下幾個優化:
cpu
改成多核的cpu
,每個cpu
都有自己的快取。cpu
執行緒切換。這些優化會導致可見性
、原子性
以及有序性
問題,為了解決上述問題,Java
記憶體模型應運而生。
Java
記憶體模型是定義了Java
程式在多執行緒環境中,存取共用記憶體和記憶體同步的規範,規定了執行緒之間的互動方式,以及執行緒與主記憶體、工作記憶體的的資料交換。
導致可見性的原因的是快取,導致有序性的問題是編譯優化,那解決可見性、有序性問題就是禁用快取和編譯優化。這樣雖然解決了並行問題,但是效能卻下降了。
合理的方案就是按需求禁用快取和編譯優化,在需要的地方新增對應的編碼即可。Java記憶體模型規範了JVM如何按需禁用快取和編譯優化,具體包括volatile
、synchronized
、final
這幾個關鍵字,以及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
同一把鎖的加鎖操作。happens-before
同一欄位的讀操作。happens-before
該執行緒的第一個操作。happens-before
B,且Bhappens-before
C,那麼Ahappens-before
C。happens-before
具有傳遞性。先看一個反常識的例子:
int a=0, b=0;
public void method1() {
b = 1;
int r2 = a;
}
public void method2() {
a = 2;
int r1 = b;
}
定義了兩個共用變數a
和b
,以及兩個方法。第一個方法將共用變數b
賦值為1
,然後將區域性變數r2
賦值為a
。第二個方法將共用變數a
賦值為2
,然後將區域性變數r1
賦值為b
。
在單執行緒環境下,我們可以先呼叫第一個方法method1
,再呼叫method2
方法,最終得到r1
、r2
的值分別為1,0
。也可以先呼叫method2
,最後得到r1
、r2
的值分別為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;
}
此時在多執行緒環境下,兩個執行緒交替執行method1
和method2
方法:
重排序後r1
、r2
分別是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
已經完成賦值了。也就不可能出現r1
、r2
為0
、0
的結果。
Java
記憶體模型是通過記憶體屏障
來實現禁用快取
和和禁用重排序
。
記憶體屏障會禁用快取,在記憶體寫操作時,強制重新整理寫快取,將資料同步到記憶體中,資料的讀取直從記憶體中讀取。
記憶體屏障會限制重排序操作,當一個變數宣告volatile
,它就插入了一個記憶體屏障,volatile
欄位之前的程式碼只能在之前進行重排序,它之後的程式碼只能在之後進行重排序。
Java
記憶體模型(Java Memory Model,JMM)定義了Java
程式中多執行緒之間共用變數的存取規則,以及執行緒之間的互動行為。它規定了執行緒如何與主記憶體和工作記憶體互動,以確保多執行緒程式的可見性、有序性和一致性。
可見性:使用volatile
宣告變數,資料讀取直接從記憶體中讀取,更新也是強制重新整理快取,並同步到主記憶體中。
有序性:使用volatile
宣告變數,確保編譯優化不會重排序該欄位。
Happens-Before: 前面一個操作的結果對後續操作是可見的,