什么是 happen-before 原則?
happen-before 是一個邏輯關系,用于描述兩個操作之間的 “先后順序”—— 如果操作 A happen-before 操作 B,那么 A 的執行結果必須對 B 可見,且 A 的執行順序在邏輯上先于 B。也就是保證指令有序性和共享變量的可見性。
具體的 happen-before 規則
JMM 定義了 9 條核心 happen-before 規則,每條規則都直接或間接關聯可見性:
規則名稱 | 描述 | 代碼示例 | 可見性體現與說明 |
---|---|---|---|
程序次序規則 | 在同一個線程中,按照程序的控制流順序,前面的操作 Happens-Before 于后面的任何操作。 | int a = 1; int b = a; // b 一定能看到 a=1 | 線程內,后面的操作一定能看到前面操作對變量的修改。這是單線程語義的基礎。 |
管程鎖定規則 | 對一個鎖的解鎖操作 Happens-Before 于后續對同一個鎖的加鎖操作。 | // 線程A synchronized(lock) { sharedVar = 100; } // 解鎖 // 線程B synchronized(lock) { // 加鎖 print(sharedVar); // 保證看到100 } | 線程B在獲得鎖之后,一定能看到線程A在釋放同一把鎖之前對所有共享變量所做的修改。管程(Monitor) 指鎖,synchronized 及 Lock 實現類(如 ReentrantLock )都遵守此規則。 |
volatile 變量規則 | 對一個 volatile 變量的寫操作 Happens-Before 于后續任何一個對該變量的讀操作。 | // 線程A sharedData = ...; // 普通寫 volatileFlag = true; // volatile寫 // 線程B if (volatileFlag) { // volatile讀 print(sharedData); // 能看到sharedData的修改 } | 線程B讀到 volatileFlag 為 true 時,不僅能看到 volatileFlag 的最新值,也能看到線程A在寫 volatileFlag 之前的所有寫操作。 |
線程啟動規則 | 主線程調用子線程的 start() 方法 Happens-Before 于該子線程中的任何操作。 | int x = 10; // 啟動前修改 Thread t = new Thread(() -> { int finalX = x; // 子線程讀取 System.out.println(finalX); // 輸出10 }); // x = 20; // 此處賦值會導致編譯錯誤! t.start(); | 子線程開始執行時,能看到主線程在調用 start() 之前對(effectively final的)變量x 的修改。注意:由于Lambda與匿名內部類要求局部變量是final或effectively final的,主線程無法在創建線程后再修改x 。 如果x是成員變量,那么修改x = 20,子線程可以讀取到20 |
線程終止規則 | 一個線程中的所有操作都 Happens-Before 于其他線程成功從該線程的 join() 方法返回。 | // 子線程 Thread t = new Thread(() -> { result = compute(); // 子線程中計算 }); t.start(); t.join(); // 等待子線程終止 System.out.println(result); // 能看到result的修改 | 主線程在 join() 成功返回后,能 guaranteed 看到子線程在執行過程中對共享變量(如result )的所有修改。 |
線程中斷規則 | 調用線程 interrupt() 方法 Happens-Before 于被中斷線程檢測到中斷狀態。 | // 線程A threadB.interrupt(); // 中斷操作 // 線程B if (Thread.interrupted()) { // 一定能感知到中斷操作 } | 如果一個線程被中斷,它之后檢測中斷狀態時,一定能看到那個中斷請求。 |
對象終結規則 | 一個對象的構造函數執行結束 Happens-Before 于它的 finalize() 方法的開始。 | public class MyClass { private int value; MyClass() { value = 50; // 構造器內初始化 } // 構造結束 protected void finalize() { // 此處一定能看到 value == 50 } } | 保證垃圾回收器在回收對象之前,該對象已經被完全正確地初始化了。 |
傳遞性 | 如果操作 A Happens-Before B,且操作 B Happens-Before C,那么可以得出操作 A Happens-Before C。 | // 線程A sharedVar = 1; // (A) 普通寫 volatileFlag = true; // (B) volatile寫 // 線程C if (volatileFlag) { // (C) volatile讀 (B hb C) // 根據傳遞性: A hb B, B hb C, 所以 A hb C // 故此處能看到 A 的寫入結果 (sharedVar=1) } | 該規則是連接其他規則的橋梁,使得跨線程的可見性保證能夠通過中間操作進行傳遞。 |
final 字段規則 | 對于一個包含 final 字段的對象,其構造函數的結束 Happens-Before 于任何其他線程獲取到該對象引用并訪問其 final 字段。 | public class FinalExample { private final int x = 42; // final字段 } // 其他線程 FinalExample obj = ...; // 獲取對象引用 System.out.println(obj.x); // 保證看到42 | 其他線程在拿到一個包含final字段的對象引用后,無須額外的同步,就能 guaranteed 看到 final 字段被構造器初始化的值。 |
補充說明第四條規則中局部變量與成員變量在匿名內部類中的訪問區別
生命周期不匹配:
局部變量 x 存儲在棧內存中,其生命周期與 方法的執行周期相同
匿名內部類對象(task)存儲在堆內存中,其生命周期可能比 方法更長
如果允許內部類訪問非 final 的局部變量,當 方法執行完畢,x 的棧幀被銷毀后,內部類對象可能還在運行,這將導致訪問無效內存
成員變量 x 存儲在堆內存中,與匿名內部類對象具有相同的生命周期
內部類通過隱式持有外部類的引用(RunnableExample.this)來訪問成員變量
值捕獲機制:
Java 通過值捕獲來解決這個問題:在創建內部類實例時,將局部變量的值復制一份到內部類中
為了保證復制值與原始變量的一致性,Java 要求局部變量必須是 final 或 effectively final
這樣內部類使用的就是捕獲時的值快照,不會受到外部修改的影響
內部類不是捕獲成員變量的值,而是通過引用訪問它
因此,對成員變量的修改會反映到內部類中
總結對比
特性 | 局部變量 | 成員變量 |
---|---|---|
存儲位置 | 棧內存 | 堆內存 |
生命周期 | 與方法調用相同 | 與對象實例相同 |
內部類訪問方式 | 值捕獲(復制) | 引用訪問 |
final 要求 | 必須為 final 或 effectively final | 無要求 |
修改可見性 | 內部類看不到外部修改 | 內部類可以看到外部修改 |
線程安全性 | 由語言機制保證 | 需要開發者自己保證 |
synchronized 關鍵字
最基礎的內置鎖,通過同步代碼塊或同步方法實現:
進入 synchronized 塊(加鎖)時,線程會清空本地緩存,從主內存加載共享變量的最新值。
退出 synchronized 塊(解鎖)時,線程會將本地緩存中修改的共享變量刷新到主內存。
示例:
private int count = 0;// 同步方法
public synchronized void increment() {count++; // 解鎖時會將修改刷新到主內存
}// 同步代碼塊
public void getCount() {synchronized (this) {return count; // 加鎖時會從主內存加載最新值}
}
java.util.concurrent.locks.Lock 接口的實現類
顯式鎖,最常用的實現是 ReentrantLock,還包括 ReentrantReadWriteLock 等:
調用 lock() 方法(加鎖)時,線程會失效本地緩存,強制從主內存加載變量。
調用 unlock() 方法(解鎖)時,線程會將本地緩存中的修改刷新到主內存。
示例(ReentrantLock):
private final Lock lock = new ReentrantLock();
private int count = 0;public void increment() {lock.lock();try {count++; // 解鎖時刷新到主內存} finally {lock.unlock();}
}public int getCount() {lock.lock();try {return count; // 加鎖時從主內存加載} finally {lock.unlock();}
}
讀寫鎖 ReentrantReadWriteLock
分離讀鎖和寫鎖,更細粒度的控制:
寫鎖(writeLock()):獲取時會強制加載最新值,釋放時會刷新修改到主內存(同普通鎖)。
讀鎖(readLock()):多個線程可同時獲取,能看到之前寫鎖釋放的所有修改(保證讀操作可見性)。
著名的雙重檢查單例模式
public class Singleton {// 關鍵1:使用volatile修飾單例實例private static volatile Singleton instance;// 關鍵2:私有構造函數,防止外部直接實例化private Singleton() {// 初始化邏輯}// 關鍵3:雙重檢查鎖定獲取實例public static Singleton getInstance() {// 第一次檢查:避免不必要的同步(提高性能)if (instance == null) {// 關鍵4:同步塊,保證多線程安全synchronized (Singleton.class) {// 第二次檢查:防止多線程同時進入同步塊后重復創建實例if (instance == null) {// 關鍵5:創建實例(volatile在此處防止指令重排序)instance = new Singleton();}}}return instance;}
}
關鍵代碼解析
volatile 修飾符的作用
volatile 在這里有兩個核心作用:
保證 instance 變量的可見性(多線程環境下,一個線程對 instance 的修改會立即被其他線程感知),因為第一次檢查并使用synchronized 關鍵字將instance 包含在內,所以必須使用volatile關鍵字保證可見性。
禁止指令重排序(這是 DCL 模式中 volatile 的核心價值)。
雙重檢查的意義
第一次檢查(同步塊外):避免每次調用 getInstance() 都進入同步塊,提高性能(多數情況下 instance 已初始化,無需同步)。
第二次檢查(同步塊內):防止多個線程同時通過第一次檢查后,在同步塊內重復創建實例。
volatile 如何禁止指令重排序?
對象創建過程(instance = new Singleton())在 JVM 中會被拆分為三步操作:
1. memory = allocate(); // 分配內存空間
2. ctorInstance(memory); // 初始化對象(執行構造函數)
3. instance = memory; // 將引用指向內存地址
問題場景:
如果沒有 volatile 修飾,編譯器或 CPU 可能對步驟 2 和 3 進行重排序,導致執行順序變為:1 → 3 → 2。
此時會出現嚴重問題:
線程 A 執行到步驟 3 后,instance 已非 null(引用已指向內存),但步驟 2 尚未完成(對象未初始化)。
線程 B 此時進行第一次檢查(instance == null),會發現 instance 不為 null,直接返回一個未初始化完成的對象,導致程序異常。
volatile 的解決方案:
volatile 通過在對象創建指令前后插入內存屏障(Memory Barrier) 禁止這種重排序:
在步驟 3 之后插入 StoreStore 屏障:禁止初始化對象(步驟 2)與設置引用(步驟 3)的重排序。
在步驟 3 之后插入 StoreLoad 屏障:確保引用賦值(步驟 3)完成后,才允許其他線程讀取 instance。
這兩個內存屏障強制保證了執行順序為 1 → 2 → 3,即對象完全初始化后,才會將引用賦值給 instance,從而避免線程 B 讀取到未初始化的對象。