“你以為程序按順序執行,但CPU和JVM説:不,我們有自己的想法。”
一起來解剖一段看似簡單、實則暗藏玄機的Java代碼。它只有20行,卻濃縮了多線程編程中最經典、最易被忽視的陷阱——可見性(Visibility)問題與指令重排序(Reordering)。
它來自《Java併發編程實戰》(JCIP)的經典示例,也是無數面試題的源頭。
🔍 代碼原貌:平靜下的風暴
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield(); // 禮貌地讓出CPU
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start(); // 啓動讀線程
number = 42; // 先賦值
ready = true; // 再“通知”
}
}
程序的“預期”邏輯很簡單:
- 啓動一個線程
ReaderThread,它不斷檢查ready是否為true; - 主線程將
number設為 42,再將ready設為true,表示“數據已就緒”; - 讀線程看到
ready == true後,打印number,理應輸出 42。
❓但現實呢?
多次運行,你可能會看到:
42✅(幸運時刻)0⚠️(高頻出現!)- 或者……程序永遠卡住不退出(需手動
Ctrl+C)💥
🤔 這段代碼沒有
synchronized,沒有鎖,沒有異常——它“語法正確”,卻“語義錯誤”。問題出在哪?
🌪️ 問題根源:Java內存模型(JMM)的三重“背叛”
1️⃣ 緩存不一致:可見性缺失
現代CPU為提升性能,每個線程都有自己的工作內存(高速緩存)。對共享變量的讀寫,可能只發生在本地緩存,不立即同步到主內存。
- 主線程修改了
ready = true,但這個值可能還“躺”在它的緩存裏; ReaderThread的緩存裏ready仍是false→ 無限循環;- 即便它看到了
ready == true,它的緩存裏number可能還是初始值0→ 打印0。
⚠️
Thread.yield()只是建議線程讓出CPU時間片,並不觸發緩存刷新!它無法解決可見性問題。
2️⃣ 編譯器與CPU的“自作聰明”:指令重排序
為優化性能,JVM 和 CPU 在不改變單線程語義的前提下,允許重排指令順序:
// 你寫的:
number = 42;
ready = true;
// 實際執行的,可能是:
ready = true; // 先執行!
number = 42; // 後執行!
對主線程自己來説,結果一樣;但對 ReaderThread 而言,它可能在 ready 變成 true 的瞬間跳出循環,此時 number 還沒被寫入——於是讀到 0。
📌 重排序是合法的,只要你沒用同步機制“約束”它。
3️⃣ 缺乏“happens-before”保證
Java 內存模型用 happens-before 規則定義操作間的可見性順序。若操作 A happens-before 操作 B,則 A 的結果對 B 一定可見。
而上述代碼中:
number = 42與ready = true之間 沒有 happens-before 關係;- 主線程寫
ready與讀線程讀ready之間 也沒有 happens-before 關係。
結果就是:一切皆有可能(0、42、死循環)——典型的競態條件(Race Condition)。
✅ 正確解法:建立“因果律”
要讓 ReaderThread 在看到 ready == true 時 必然 看到 number == 42,我們必須建立明確的 happens-before 邊界。
✅ 方案一:volatile —— 最簡潔優雅(推薦!)
private static volatile boolean ready; // ← 只需加在這裏!
private static int number; // number 可以不加 volatile
為什麼有效?
Java 內存模型規定:
“對一個 volatile 變量的寫操作 happens-before 後續對這個 volatile 變量的讀操作。”
這意味着:
- 主線程執行
ready = true(volatile 寫); ReaderThread執行if (!ready)(volatile 讀)並看到true;- 根據 happens-before 規則:
number = 42→(程序順序)→ready = true(volatile寫)
→(volatile規則)→ready讀取為true
⇒ 所以number = 42happens-before 讀取number!
✅ number 即便不是 volatile,也能被正確看到為 42!
🌟 這就是
volatile的“內存可見性傳遞性”:一個 volatile 寫,能“捎帶”它之前所有普通寫操作的可見性 。
✅ 方案二:synchronized —— 重量級但通用
private static final Object lock = new Object();
// ReaderThread 中:
while (!ready) {
synchronized (lock) { } // 空同步塊,只為建立同步邊
Thread.yield();
}
// main 中:
synchronized (lock) {
number = 42;
ready = true;
}
synchronized 天然提供:
- 互斥訪問(此處非必需);
- 進入/退出同步塊時的內存屏障,刷新緩存,禁止重排序;
- 明確的 happens-before:釋放鎖 happens-before 獲取同一把鎖。
✅ 方案三:AtomicBoolean / AtomicInteger
private static final AtomicBoolean ready = new AtomicBoolean(false);
private static final AtomicInteger number = new AtomicInteger(0);
// main:
number.set(42);
ready.set(true);
// ReaderThread:
while (!ready.get()) {
Thread.yield();
}
System.out.println(number.get());
AtomicXxx 的 get()/set() 默認具有 volatile 語義(除 lazySet),同樣滿足 happens-before 。
🧪 實驗驗證:眼見為實
你可以在本地反覆運行原版代碼:
for i in {1..10}; do java NoVisibility; done
# 很可能混雜着 0 和 42,甚至卡住
再運行修復版(加 volatile):
for i in {1..10}; do java FixedNoVisibility; done
# 穩定輸出 42!
💡 提示:在服務器模式(
-serverJVM)或某些CPU架構(如ARM)上,問題更容易復現。
📚 深層思考:由此學到了什麼?
| 誤區 | 真相 |
|---|---|
| “變量賦值是原子的,所以沒問題” | 原子性 ≠ 可見性。boolean/int 賦值是原子的,但其他線程看不到! |
“Thread.yield() 能讓線程‘同步’” |
yield() 是線程調度提示,無內存語義,不能替代同步。 |
| “代碼順序 = 執行順序” | 編譯器、CPU、JIT 都會重排序——除非你用 volatile/synchronized 禁止。 |
| “單核CPU不會有這問題” | 單核也可能緩存不一致!且現代基本都是多核。 |
🎯 關鍵總結:
- 共享可變狀態 必須考慮線程安全;
volatile不只是“防重排序”,更是建立 happens-before 的輕量級工具;- 一個
volatileflag,可帶動一批普通變量的可見性——這是高效併發設計的基石; - 測試多線程bug不能靠“跑幾次沒事”,而要靠理論保證。
📖 延伸閲讀
- 📘 《Java Concurrency in Practice》第3章 “Sharing Objects”
- 📜 JSR-133: Java Memory Model and Thread Specification
- 🌐 Java Language Specification §17.4.5. Happens-before Order