手撕併發編程:十分鐘搞定 Java 內存模型
隨着硬件技術的飛速發展,多核處理器已經成爲計算設備的標配,這使得開發人員需要掌握併發編程的知識和技巧,以充分發揮多核處理器的潛力。然而併發編程並非易事,它涉及到許多複雜的概念和原理。爲了更好地理解併發編程的內在機制,需要深入研究內存模型及其在併發編程中的應用。本文將主要以 Java 內存模型來探討併發編程中 BUG 的源頭和處理這些問題的底層實現原理,助你更好地把握併發編程的內在機制,歡迎繼續閱讀。
01 併發編程問題 - 可見性和有序性
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 可能存在多種執行結果。可以看到在併發編程中,常常會遇到一些與預期不符的結果,導致程序邏輯的失敗。這樣的異常問題,會讓開發人員感到困惑。但是如果細細探究這些問題的根源,發現是有跡可循的。
這個問題的原因主要是兩點:一是處理器和內存對共享變量的處理的速度差異。二是編譯優化和處理器優化造成代碼指令重排序。前者導致可見性問題,後者導致有序性問題。
1.1 處理器緩存導致的可見性問題
如上圖所示,由於處理器和內存的速度差距太大。爲了提高處理速度,處理器不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存(L1,L2 或其他)後再進行操作。基於局部性原理,處理器在讀取內存數據時,是一塊塊地讀取,每一小塊數據也叫緩存行(cache line)。當處理器操作完數據,也不直接寫回內存,而且先寫入緩存中,並將當前緩存標記爲髒(dirty)。等到當前緩存被替換時,纔將數據寫回內存。這個過程叫寫回策略(write-back)。
同時爲了提高效率,處理器使用寫緩存區(store buffer)臨時保存向內存寫入的數據。寫緩衝區可以保證指令流水線的持續運行,同時合併寫緩衝區中對同一內存地址的多次寫,減少內存總線的佔用。但是由於緩衝區的數據並非及時寫回內存,且寫緩衝區僅對自己的處理器可見,其他處理器無法感知當前共享變量已經變更。處理器的讀寫順序與內存實際操作的讀寫順序可能存在不一致。
現在再回來看上面代碼,那麼可以得到四種結果:
結果一: 假設處理器 A 對變量 a 賦值,但沒及時回寫內存。處理器 B 對變量 b 賦值,且及時回寫內存。處理器 A 從內存中讀到變量 b 最新值。那麼這時結果是:x 等於 2,y 等於 0。
結果二: 假設處理器 A 對變量 a 賦值,且及時回寫內存。處理器 B 從內存中讀到變量 a 最新值。處理器 B 對變量 b 賦值,但沒及時回寫內存。那麼這時結果是:x 等於 0,y 等於 1。
結果三: 假設處理器 A 和 B,都沒及時回寫變量 a 和 b 值到內存。那麼這時結果是:x 等於 0,y 等於 0。
結果四: 假設處理器 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 前綴的指令在多核處理器會引發兩件事:
-
將當前處理器緩存行的數據寫回到系統內存。
-
將個寫回內存的操作會使在其他 CPU 裏緩存了該內存地址的數據無效。
上述的操作是通過總線嗅探和總線仲裁來實現。而基於總線嗅探和總線仲裁,現代處理器逐漸形成了各種緩存一致性協議,例如 MESI 協議。
總之操作系統便是基於上述實現,從底層來保證共享變量在併發情況下的線程安全。而對開發人員,只需要在恰當時候加上 volatile 關鍵字就可以。
除了 volatile,也可以使用 synchronized 關鍵字來保證可見性。關於 volatile 和 synchronized 的具體實現,會在下篇文章詳細闡述。
1.2 編譯優化導致的有序性問題
前面講到通過緩存一致性協議,來保障共享變量的可見性。那麼是否還有其他情況,導致對共享變量操作不符合預期結果。可以看下面的代碼:
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 自己來說,代碼的重排序,不會影響當前線程執行。但是在多線程併發執行下,會出現如下情況:假設處理器 A 先將變量 b=0 賦值給 x,再將變量 a 賦值 1。處理器 B 先將變量 a=0 賦值給 y,再將變量 b 賦值 2。那麼這時結果是:x 等於 0,y 等於 0。
可見代碼的重排序也會影響到程序最終結果。
代碼和指令的重排序的主要原因有三個,分別爲編譯器的重排序,處理器的亂序執行,以及內存系統的重排序。後面兩點是處理器優化。
重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段。重排序需要遵守兩點:
數據依賴性: 如果兩個操作之間存在數據依賴,那麼編譯器和處理器不能調整它們的順序。
// 寫後讀
a = 1;
b = a;
// 寫後寫
a = 1;
a = 2;
// 讀後寫
a = b;
b = 1;
上面 3 種情況,編譯器和處理器不能調整它們的順序,否則將會造成程序語義的改變。
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 關鍵字實現,禁止被它修飾的變量發生指令重排序操作:
-
不允許 volatile 字段寫操作之前的內存訪問被重排序至其之後。
-
不允許 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 指令和內存屏障,實現共享變量的可見性和避免代碼和指令的重排序,最終保障了程序在多線程情況下的正常執行。
02 併發編程問題 - 原子性
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: 把變量 count 從內存加載到 CPU 的寄存器;
指令 2: 在寄存器中執行 + 1 操作;
指令 3: 將結果寫入內存(緩存機制導致可能寫入的是處理器緩存而不是內存)。
那麼假設有兩個線程 A 和 B,同時執行 count+=1,可能存在如下情況:
-
線程 A 從內存加載 count 並執行 count+=1,同時線程 B 從內存加載 count 並執行 count+=1,並同時寫回內存。那麼這時結果是:count = 1。
-
線程 A 從內存加載 count 並執行 count+=1,並將 count 結果寫回內存。線程 B 再從內存加載 count 並執行 count+=1。那麼這時結果是:count = 2。
可以看到如果要 count 結果正確,要保證 count 讀取,操作,寫入三個過程不被中斷。這個過程,可以稱之爲原子操作。原子(atomic)本意是 “不能被進一步分割的最小粒子”,而原子操作(atomicoperation) 意爲 “不可被中斷的一個或一系列操作”。
處理器主要使用基於對緩存加鎖或總線加鎖的方式來實現原子操作:
總線加鎖: 通過 LOCK# 信號鎖住總線 BUS,使當前處理器獨享內存空間。但是此時其他處理器都不能訪問內存其他地址,效率低。
緩存加鎖: 緩存一致性協議(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 包介紹,會在下篇文章詳細闡述。
03 內存模型與 happens-before 關係
前面說到處理器提供了一些特殊指令比如 LOCK,CMPXCHG,內存屏障等來保障多線程情況下的程序邏輯正常執行。但這依然存在幾個問題:
-
處理器底層指令實現細節複雜難懂,開發人員需要付出巨大的學習成本。
-
不同的硬件和操作系統,對指令的支持和實現不一樣,需要考慮跨平臺的兼容性。
-
程序業務邏輯複雜多變,處理器和線程之間的數據操作依賴關係也相應更復雜。
因此高級語言會提供一種抽象的內存模型,用於描述多線程環境下的內存訪問行爲。 無需關心底層硬件和操作系統的具體實現細節,就可以編寫出高效、可移植的併發程序。對於 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 個步驟:
-
線程 A 把本地內存 A 中更新過的共享變量刷新到主內存中。
-
線程 B 到主內存中去讀取線程 A 之前已更新過的共享變量。
儘管定義這樣的數據通信方式,但實際程序的數據依賴操作是複雜多變的。爲了進一步抽象這種線程間的數據同步方式,Java 內存模型定義了下述線程間的 happens-before 關係:
程序順序規則: 單線程內,每個操作 happens-before 於該線程中的任意後續操作。
監視器鎖規則: 解鎖操作 happens-before 之後對同一把鎖的加鎖操作。
volatile 變量規則: volatile 字段的寫操作 happens-before 之後對同一字段的讀操作。
傳遞性規則: 如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。
start() 規則: 如果線程 A 執行操作 ThreadB.start(),那麼 A 線程的 ThreadB.start() 操作 happens-before 於線程 B 中的任意操作。
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可見
// 即滿足傳遞性規則
}
04 內存模型綜述
在本文中,我們對 Java 內存模型進行了全面的概述。Java 內存模型是 Java 虛擬機規範的一部分,爲 Java 開發人員提供了一種抽象的內存模型,用於描述多線程環境下的內存訪問行爲。
Java 內存模型關注併發編程中的原子性、可見性和有序性問題,並提供了一系列同步原語(如 volatile、synchronized 等)來實現這些原則。此外,還定義 happens-before 關係,用於描述操作之間的偏序關係,從而確保內存訪問的正確性和一致性。
Java 內存模型的主要優勢在於它爲併發編程提供了基礎,簡化了複雜性。屏蔽不同處理器差異性,在不同的處理器平臺之上呈現了一致的內存模型,並允許一定程度的性能優化。這些優勢使得 Java 開發人員可以更容易地編寫出正確、高效、可移植的併發程序。
瞭解 Java 內存模型的原理和實踐對於編寫高質量的 Java 併發程序至關重要。希望本文能爲你提供有關 Java 內存模型的有用信息,幫助你更好地理解併發編程的內在機制,以及在實際項目中選擇合適的同步原語和策略。如果文章對你有幫助,歡迎轉發分享。
原創作者|楊波
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/7GHo-UgQ9myeUG0N_uUoBA