隨著硬體技術的飛速發展,多核處理器已經成為計算設備的標配,這使得開發人員需要掌握併發編程的知識和技巧,以充分發揮多核處理器的潛力。然而併發編程並非易事,它涉及到許多複雜的概念和原理。為了更好地理解併發編程的內在機制,需要深入研究記憶體模型及其在併發編程中的應用。本文將主要以 Java 記憶體模型來探討並 ...
隨著硬體技術的飛速發展,多核處理器已經成為計算設備的標配,這使得開發人員需要掌握併發編程的知識和技巧,以充分發揮多核處理器的潛力。然而併發編程並非易事,它涉及到許多複雜的概念和原理。為了更好地理解併發編程的內在機制,需要深入研究記憶體模型及其在併發編程中的應用。本文將主要以 Java 記憶體模型來探討併發編程中 BUG 的源頭和處理這些問題的底層實現原理,助你更好地把握併發編程的內在機制。
併發編程問題-可見性和有序性
private int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 2;
y = a;
});
// ...start啟動線程,join等待線程
assert x == 2;
assert y == 1;
}
首先我們先看一段代碼,這裡定義了兩個共用變數 x 和 y,在兩個線程中分別對 x 和 y 賦值,當同時開啟兩個線程並等待線程執行完成,最終結果是否是共用變數 x 等於 2 並且 y 等於 1 呢?答案是未可知,即共用變數 x 和 y 可能存在多種執行結果。可以看到在併發編程中,常常會遇到一些與預期不符的結果,導致程式邏輯的失敗。這樣的異常問題,會讓開發人員感到困惑。但是如果細細探究這些問題的根源,發現是有跡可循的。
這個問題的原因主要是兩點:一是處理器和記憶體對共用變數的處理的速度差異。二是編譯優化和處理器優化造成代碼指令重排序。前者導致可見性問題,後者導致有序性問題。
處理器緩存導致的可見性問題
如上圖所示,由於處理器和記憶體的速度差距太大。為了提高處理速度,處理器不直接和記憶體進行通信,而是先將系統記憶體的數據讀到內部緩存(L1,L2 或其他)後再進行操作。基於局部性原理,處理器在讀取記憶體數據時,是一塊塊地讀取,每一小塊數據也叫緩存行(cache line)。當處理器操作完數據,也不直接寫回記憶體,而且先寫入緩存中,並將當前緩存標記為臟(dirty)。等到當前緩存被替換時,才將數據寫回記憶體。這個過程叫寫回策略(write-back)。
同時為了提高效率,處理器使用寫緩存區(store buffer)臨時保存向記憶體寫入的數據。寫緩衝區可以保證指令流水線的持續運行,同時合併寫緩衝區中對同一記憶體地址的多次寫,減少記憶體匯流排的占用。但是由於緩衝區的數據並非及時寫回記憶體,且寫緩衝區僅對自己的處理器可見,其他處理器無法感知當前共用變數已經變更。處理器的讀寫順序與記憶體實際操作的讀寫順序可能存在不一致。

現在再回來看上面代碼,那麼可以得到四種結果:
1)假設處理器 A 對變數 a 賦值,但沒及時回寫記憶體。處理器 B 對變數 b 賦值,且及時回寫記憶體。處理器 A 從記憶體中讀到變數 b 最新值。那麼這時結果是:x 等於 2,y 等於 0。
2)假設處理器 A 對變數 a 賦值,且及時回寫記憶體。處理器 B 從記憶體中讀到變數 a 最新值。處理器 B 對變數 b 賦值,但沒及時回寫記憶體。那麼這時結果是:x 等於 0,y 等於 1。
3)假設處理器 A 和 B,都沒及時回寫變數 a 和 b 值到記憶體。那麼這時結果是:x 等於 0,y 等於 0。
4)假設處理器 A 和 B,都及時回寫變數 a 和 b 值到記憶體,且從記憶體中讀到變數 a 和 b 的最新值。那麼這時結果是:x 等於 2,y 等於 1。
從上面可發現除了第四種情況,其他三種情況都存在對共用變數的操作不可見。所謂可見性,便是當一個線程對某個共用變數的操作,另外一個線程立即可見這個共用變數的變更。
而從上面推論可以發現,要達到可見性,需要處理器及時回寫共用變數最新值到記憶體,也需要其他處理器及時從記憶體中讀取到共用變數最新值。
因此也可以說只要滿足上述兩個條件。那麼就可以保證對共用變數的操作,在併發情況下是線程安全的。在 Java 語言中,是通過 volatile 關鍵字實現。volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言里也有,它最原始的意義就是禁用 CPU 緩存。
對如下共用變數:
// instance是volatile變數
volatile Singlenton instance = new Singlenton();
轉換成彙編代碼,如下:
0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
0x01a3de24: lock addl $ 0 x 0,(% esp);
可以看到 volatile 修飾的共用變數會多出第二行彙編變數,並且多了一個 LOCK 指令。LOCK 首碼的指令在多核處理器會引發兩件事:
1)將當前處理器緩存行的數據寫回到系統記憶體。
2)這個寫回記憶體的操作會使在其他 CPU 里緩存了該記憶體地址的數據無效。
上述的操作是通過匯流排嗅探和匯流排仲裁來實現。而基於匯流排嗅探和匯流排仲裁,現代處理器逐漸形成了各種緩存一致性協議,例如 MESI 協議。

總之操作系統便是基於上述實現,從底層來保證共用變數在併發情況下的線程安全。而對開發人員,只需要在恰當時候加上 volatile 關鍵字就可以。
除了 volatile,也可以使用 synchronized 關鍵字來保證可見性。關於 volatile 和 synchronized 的具體實現,會在下篇文章詳細闡述。
編譯優化導致的有序性問題
前面講到通過緩存一致性協議,來保障共用變數的可見性。那麼是否還有其他情況,導致對共用變數操作不符合預期結果。可以看下麵的代碼:
private int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
x = b;
a = 1;
});
Thread t2 = new Thread(() -> {
y = a;
b = 2;
});
// ...start啟動線程,join等待線程
assert x == 2;
assert y == 1;
}
假設將線程 t1 的代碼塊從 a = 1;x = b;改成 x = b;a = 1; 。將線程 t2 的代碼塊從 b = 2;y = a;改成 y = a;b = 2;。
對於線程 t1 和 t2 自己來說,代碼的重排序,不會影響當前線程執行。但是在多線程併發執行下,會出現如下情況:
1)假設處理器 A 先將變數 b=0 賦值給 x,再將變數 a 賦值 1。處理器 B 先將變數 a=0 賦值給 y,再將變數 b 賦值 2。那麼這時結果是:x 等於 0,y 等於 0。
可見代碼的重排序也會影響到程式最終結果。
代碼和指令的重排序的主要原因有三個,分別為編譯器的重排序,處理器的亂序執行,以及記憶體系統的重排序。後面兩點是處理器優化。

重排序是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段。重排序需要遵守兩點:
1)數據依賴性:如果兩個操作之間存在數據依賴,那麼編譯器和處理器不能調整它們的順序。
// 寫後讀
a = 1;
b = a;
// 寫後寫
a = 1;
a = 2;
// 讀後寫
a = b;
b = 1;
上面 3 種情況,編譯器和處理器不能調整它們的順序,否則將會造成程式語義的改變。
2)as-if-serial 語義:即給程式一個順序執行的假象。即經過重排序的執行結果要與順序執行的結果保持一致。
a = 1;
b = 2;
c = a * b;
如上對變數 a 的賦值和對變數 b 的賦值,不存在數據依賴關係。因此對變數 a 和 b 重排序不會影響變數 c 的結果。
但數據依賴性和 as-if-serial 語義只保證單個處理器中執行的指令序列和單個線程中執行的操作,並不考慮多處理器和多線程之間的數據依賴情況。因此在多線程程式中,對存在數據依賴的操作重排序,可能會改變程式的執行結果。因此要避免程式的錯誤的執行,便是需要禁止這種編譯和處理器優化導致的重排序。
這種方式叫做記憶體屏障(memory barriers)。記憶體屏障是一組處理器指令,用戶實現對記憶體操作的順序限制。以我們日常接觸的 X86_64 架構來說,讀讀(loadload)、讀寫(loadstore)以及寫寫(storestore)記憶體屏障是空操作(no-op),只有寫讀(storeload)記憶體屏障會被替換成具體指令。
在 Java 語言中,記憶體屏障通過 volatile 關鍵字實現,禁止被它修飾的變數發生指令重排序操作:
1)不允許 volatile 欄位寫操作之前的記憶體訪問被重排序至其之後。
2)不允許 volatile 欄位讀操作之後的記憶體訪問被重排序至其之前。
// 變數a,b通過volatile修飾
private volatile int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
a = 1;
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數a的最新值到記憶體
x = b;
// 1)強制從記憶體中讀取變數b的最新值
});
Thread t2 = new Thread(() -> {
b = 2;
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數b的最新值到記憶體
y = a;
// 1)強制從記憶體中讀取變數a的最新值
});
// ...start啟動線程,join等待線程
assert x == 2;
assert y == 1;
}
可以看到通過 volatile 修飾的變數通過 LOCK 指令和記憶體屏障,實現共用變數的可見性和避免代碼和指令的重排序,最終保障了程式在多線程情況下的正常執行。
併發編程問題-原子性
private int count = 0;
public void test() {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count += 1;
}
});
ts.add(t);
}
// ...start啟動線程,join等待線程
assert count == 100 * 10000;
}
對於 Java 這樣的高級語言,一條語句最終會被轉換成多條 CPU 指令完成,例如上面代碼中的 count+=1,至少需要三條 CPU 指令:
1)指令 1:把變數 count 從記憶體載入到 CPU 的寄存器;
2)指令 2:在寄存器中執行+1 操作;
3)指令 3:將結果寫入記憶體(緩存機制導致可能寫入的是處理器緩存而不是記憶體)。
那麼假設有兩個線程 A 和 B,同時執行 count+=1,可能存在如下情況:
1)線程 A 從記憶體載入 count 並執行 count+=1,同時線程 B 從記憶體載入 count 並執行 count+=1,並同時寫回記憶體。那麼這時結果是:count = 1。
2)線程 A 從記憶體載入 count 並執行 count+=1,並將 count 結果寫回記憶體。線程 B 再從記憶體載入 count 並執行 count+=1。那麼這時結果是:count = 2。
可以看到如果要 count 結果正確,要保證 count 讀取,操作,寫入三個過程不被中斷。這個過程,可以稱之為原子操作。原子 (atomic)本意是“不能被進一步分割的最小粒子”,而原子操作 (atomicoperation) 意為“不可被中斷的一個或一系列操作”。
處理器主要使用基於對緩存加鎖或匯流排加鎖的方式來實現原子操作:
1)匯流排加鎖:通過 LOCK#信號鎖住匯流排 BUS,使當前處理器獨享記憶體空間。但是此時其他處理器都不能訪問記憶體其他地址,效率低。

2)緩存加鎖:緩存一致性協議(MESI)。強制當前處理器緩存行失效,並從記憶體讀取其他處理器回寫的數據。當有些無法被緩存或跨域多個緩存行的數據,依然需要使用匯流排鎖。

對於以上兩個機制,處理器底層提供了很多指令來實現,其中最重要的是 CMPXCHG 指令。但 CMPXCHG 只在單核處理器下有效,多核處理器依然要加上 LOCK 首碼(LOCK CMPXCHG)。利用 CMPXCHG 指令可以通過迴圈 CAS 方式來實現原子操作。
// 判斷當前機器是否是多核處理器
int mp = os::is MP();
_asm {
mov edx, dest
mov ecx, exchange value
mov eax, compare_value
// 這裡需要先進行判斷是否為多核處理器
LOCK IF MP(mp)
// 如果是多核處理器就會在這行指令前加Lock標記
cmpxchg dword ptr [edx],ecx
}
CAS 即 Compare and Swap。CAS 操作需要輸入兩個數值,一個舊值 (期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。
Java 語言提供了大量的原子操作類,來實現對應的 CAS 操作。比如 AtomicBoolean,AtomicInteger,AtomicLong 等。
private AtomicInteger count = new AtomicInteger(0);
public void test() {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count.incrementAndGet();
}
});
ts.add(t);
}
// ...start啟動線程,join等待線程
assert count.get() == 100 * 10000;
}
CAS 雖然很高效解決了原子操作,但是 CAS 也存在一些問題,比如 ABA 問題,迴圈時間長開銷大,只能保障一個共用變數的原子操作。關於如上問題解決和 Atomic 包介紹,會在下篇文章詳細闡述。
記憶體模型與 happens-before 關係
前面說到處理器提供了一些特殊指令比如 LOCK,CMPXCHG,記憶體屏障等來保障多線程情況下的程式邏輯正常執行。但這依然存在幾個問題:
1)處理器底層指令實現細節複雜難懂,開發人員需要付出巨大的學習成本。
2)不同的硬體和操作系統,對指令的支持和實現不一樣,需要考慮跨平臺的相容性。
3)程式業務邏輯複雜多變,處理器和線程之間的數據操作依賴關係也相應更複雜。
因此高級語言會提供一種抽象的記憶體模型,用於描述多線程環境下的記憶體訪問行為。無需關心底層硬體和操作系統的具體實現細節,就可以編寫出高效、可移植的併發程式。對於 Java 語言,這種記憶體模型便是 Java 記憶體模型(Java Memory Model,簡稱 JMM)。
Java 記憶體模型主要特性是提供了 volatile、synchronized、final 等同步原語,用於實現原子性、可見性和有序性。另一個重要的概念便是 happens-before 關係,用來描述併發編程中操作之間的偏序關係。除了 Java 語言,包括 golang,c++,rust 等高級語言也實現了自己的 happens-before 關係。
Java 記憶體模型的 happens-before 關係是用來描述兩個線程操作的記憶體可見性。需註意這裡的可見性,並不代表 A 線程的執行順序一定早於 B 線程, 而是 A 線程對某個共用變數的操作對 B 線程可見。即 A 線程對變數 a 進行寫操作,B 線程讀取到是變數 a 的變更值。
Java 記憶體模型定義了主記憶體(main memory),本地記憶體(local memory),共用變數等抽象關係,來決定共用變數在多線程之間通信同步方式,即前面所說兩個線程操作的記憶體可見性。其中本地記憶體,涵蓋了緩存,寫緩衝區,寄存器以及其他硬體和編譯器優化等概念。

如圖所示,如果線程 A 與線程 B 之間要通信的話,必須要經歷下麵 2 個步驟:
1)線程 A 把本地記憶體 A 中更新過的共用變數刷新到主記憶體中
2)線程 B 到主記憶體中去讀取線程 A 之前已更新過的共用變數
儘管定義這樣的數據通信方式,但實際程式的數據依賴操作是複雜多變的。為了進一步抽象這種線程間的數據同步方式,Java 記憶體模型定義了下述線程間的 happens-before 關係:
1)程式順序規則:單線程內,每個操作 happens-before 於該線程中的任意後續操作。
2)監視器鎖規則:解鎖操作 happens-before 之後對同一把鎖的加鎖操作。
3)volatile 變數規則:volatile 欄位的寫操作 happens-before 之後對同一欄位的讀操作。
4)傳遞性規則:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。
5)start()規則:如果線程 A 執行操作 ThreadB.start(),那麼 A 線程的 ThreadB.start()操作 happens-before 於線程 B 中的任意操作。
6)join()規則:如果線程 A 執行操作 ThreadB.join()併成功返回,那麼線程 B 中的任意操作 happens-before 線程 A 從 ThreadB.join()操作成功返回。
與開發人員密切相關的是 1、2、3、4 四個規則。其中規則 1 滿足了 as-if-serial 語義,即 Java 記憶體模型允許代碼和指令重排序,只要不影響程式執行結果。規則 2 和 3 是通過 synchronized、volatile 關鍵字實現。結合規則 1、2、3 來看看規則 4 的具體使用,可以看到如下的代碼,程式最終執行且得到正確結果:
// x, y, z被volatile關鍵字修飾
private volatile int x, y, z;
public void test() {
Thread a = new Thread(() -> {
// 基於程式順序規則
// 沒有數據依賴關係,可以重排序下麵代碼
int i = 0;
x = 2;
// 基於volatile變數規則
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數x的最新值到記憶體
});
Thread b = new Thread(() -> {
int i = 0;
// 存在數據依賴關係,無法重排序下麵代碼
// 強制從主記憶體中讀取變數x的最新值
y = x;
// 基於volatile變數規則
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數y的最新值到記憶體
// 3)y = x;可能會被編譯優化去除
y = 3;
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數y的最新值到記憶體
});
Thread c = new Thread(() -> {
// 基於程式順序規則
// 沒有數據依賴關係,可以重排序下麵代碼
int i = 0;
// 基於volatile變數規則
// 強制從主記憶體中讀取變數x和y的最新值
z = x * y;
// 編譯器插入storeload記憶體屏障指令
// 1)禁止代碼和指令重排序
// 2)強制刷新變數z的最新值到記憶體
});
// ...start啟動線程,join等待線程
assert z == 6;
// 可以看到a線程對變數x變更,b線程對變數y變更,最終對線程c可見
// 即滿足傳遞性規則
}
private int x, y, z;
public void test() {
Thread a = new Thread(() -> {
// synchronized,同步原語,程式邏輯將順序串列執行
synchronized (this){
// 基於程式順序規則
// 沒有數據依賴關係,可以重排序下麵代碼
int i = 0;
x = 2;
// 基於監視器鎖規則
// 強制刷新變數x的最新值到記憶體
}
});
Thread b = new Thread(() -> {
// synchronized,同步原語,程式邏輯將順序串列執行
synchronized (this) {
int i = 0;
// 存在數據依賴關係,無法重排序下麵代碼
// 強制從主記憶體中讀取變數x的最新值
y = x;
// 基於監視器鎖規則
// 1)強制刷新變數y的最新值到記憶體
// 2)y = x;可能會被編譯優化去除
y = 3;
// 強制刷新變數y的最新值到記憶體
}
});
Thread c = new Thread(() -> {
// synchronized,同步原語,程式邏輯將順序串列執行
synchronized (this) {
// 基於程式順序規則
// 沒有數據依賴關係,可以重排序下麵代碼
int i = 0;
// 基於監視器鎖規則
// 強制從主記憶體中讀取變數x和y的最新值
z = x * y;
// 強制刷新變數z的最新值到記憶體
}
});
// ...start啟動線程,join等待線程
assert z == 6;
// 可以看到a線程對變數x變更,b線程對變數y變更,最終對線程c可見
// 即滿足傳遞性規則
}
記憶體模型綜述
在本文中,我們對 Java 記憶體模型進行了全面的概述。Java 記憶體模型是 Java 虛擬機規範的一部分,為 Java 開發人員提供了一種抽象的記憶體模型,用於描述多線程環境下的記憶體訪問行為。
jJava 記憶體模型關註併發編程中的原子性、可見性和有序性問題,並提供了一系列同步原語(如 volatile、synchronized 等)來實現這些原則。此外,還定義 happens-before 關係,用於描述操作之間的偏序關係,從而確保記憶體訪問的正確性和一致性。
Java 記憶體模型的主要優勢在於它為併發編程提供了基礎,簡化了複雜性。屏蔽不同處理器差異性,在不同的處理器平臺之上呈現了一致的記憶體模型,並允許一定程度的性能優化。這些優勢使得 Java 開發人員可以更容易地編寫出正確、高效、可移植的併發程式。
瞭解 Java 記憶體模型的原理和實踐對於編寫高質量的 Java 併發程式至關重要。希望本文能為您提供有關 Java 記憶體模型的有用信息,幫助您更好地理解併發編程的內在機制,以及在實際項目中選擇合適的同步原語和策略。
作者:ehtan
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Deeply-Understanding-the-Memory-Model-of-Concurrent-Programming-Art.html