在研讀《Java 並發編程實戰》時,常會遇到一個令人費解的現象:同一個變數 n 在 assertSanity() 方法中竟然可能 n != n。這看似違反邏輯的現象,實則揭示了 Java 內存模型中「安全發布」的核心問題。本文將透過具體代碼與字節碼分析,深入剖析指令重排序如何導致可見性問題,並說明 final 與 volatile 如何協同解決此問題。
考慮以下簡單的 Holder 類別:
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n) {
throw new AssertionError("This statement is false.");
}
}
}
乍看之下,n != n 永遠為 false,但在多線程環境下,這卻可能拋出 AssertionError。為什麼?
假設有兩個線程:
Holder holder = new Holder(42);if (holder != null) { holder.assertSanity(); }JVM 執行 new Holder(42) 時,實際步驟為:
holder 引用Holder 記憶體空間n 賦值為 42holder 指向堆中記憶體由於指令重排序優化,執行順序可能變為 1 → 3 → 2 → 4,即 holder 引用已指向記憶體(非 null),但建構子尚未完成,n 仍為預設值 0。
assertSanity() 編譯後的字節碼關鍵指令如下:
0: aload_0
1: getfield #2 // Field n:I // 第一次讀取 n 入棧
4: aload_0
5: getfield #2 // Field n:I // 第二次讀取 n 入棧
8: if_icmpeq 21 // 比較棧頂兩值
因為要比較,需將 n 兩次載入操作數棧。若線程 B 在第一次 getfield 時讀到 0(建構子未完成),第二次 getfield 時讀到 42(建構子已完成),則導致比較失敗,拋出異常。
public class Holder {
private final int n; // 1. final 修飾欄位
public Holder(int n) {
this.n = n;
}
// ...
}
// 發布時使用 volatile
public class Publisher {
private volatile Holder holder; // 2. volatile 修飾引用
}
| 關鍵字 | 作用機制 | 解決的問題 |
|---|---|---|
final |
禁止建構子內的寫入操作與引用賦值重排序(JMM final 語義) | 確保物件構造完成前,其他線程不可見「半初始化」狀態 |
volatile |
禁止引用賦值與後續讀取重排序;保證可見性(happens-before) | 確保線程 A 發布的引用對線程 B 立即可見,且讀到的物件狀態一致 |
核心原則:final 守護「構造過程」,volatile 守護「發布過程」,兩者缺一不可。
synchronized 或 Lock 同步發布與讀取ConcurrentHashMap、CopyOnWriteArrayList 等Java 安全發布的本質是「有序性」與「可見性」的雙重保障。final 防止構造期重排序,volatile 防止發布期重排序並保證跨線程可見。理解字節碼層面的雙重讀取機制,能幫助我們從根源上避免「同一變數不等於自己」的並發陷阱。