動態

詳情 返回 返回

一個經典案例深入剖析Java併發中的“可見性”陷阱 - 動態 詳情

“你以為程序按順序執行,但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;               // 再“通知”
    }
}

程序的“預期”邏輯很簡單:

  1. 啓動一個線程 ReaderThread,它不斷檢查 ready 是否為 true
  2. 主線程將 number 設為 42,再將 ready 設為 true,表示“數據已就緒”;
  3. 讀線程看到 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 = 42ready = true 之間 沒有 happens-before 關係
  • 主線程寫 ready 與讀線程讀 ready 之間 也沒有 happens-before 關係

結果就是:一切皆有可能(042、死循環)——典型的競態條件(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 變量的讀操作。”

這意味着:

  1. 主線程執行 ready = true(volatile 寫);
  2. ReaderThread 執行 if (!ready)(volatile 讀)並看到 true
  3. 根據 happens-before 規則:
    number = 42 →(程序順序)→ ready = true(volatile寫)
    →(volatile規則)→ ready 讀取為 true
    ⇒ 所以 number = 42 happens-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());

AtomicXxxget()/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!

💡 提示:在服務器模式(-server JVM)或某些CPU架構(如ARM)上,問題更容易復現。


📚 深層思考:由此學到了什麼?

誤區 真相
“變量賦值是原子的,所以沒問題” 原子性 ≠ 可見性。boolean/int 賦值是原子的,但其他線程看不到
Thread.yield() 能讓線程‘同步’” yield() 是線程調度提示,無內存語義,不能替代同步。
“代碼順序 = 執行順序” 編譯器、CPU、JIT 都會重排序——除非你用 volatile/synchronized 禁止。
“單核CPU不會有這問題” 單核也可能緩存不一致!且現代基本都是多核。

🎯 關鍵總結:

  1. 共享可變狀態 必須考慮線程安全;
  2. volatile 不只是“防重排序”,更是建立 happens-before 的輕量級工具;
  3. 一個 volatile flag,可帶動一批普通變量的可見性——這是高效併發設計的基石;
  4. 測試多線程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

Add a new 評論

Some HTML is okay.