Stories

Detail Return Return

Java 併發編程揭秘:聽我説 happens-before 規則 - Stories Detail

多線程編程就像走鋼絲,一不小心就掉下去。而 Java 的 happens-before 規則,就是那根讓你穩穩走過去的平衡杆。今天我把這個看起來很深奧的概念拆開來講,讓你真正明白它為啥這麼重要,以及怎麼用它來解決實際問題。

你的代碼可能根本不是按你想的順序執行的!

看這段代碼:

int a = 1;
int b = 2;
int c = a + b;

你以為它就是按這個順序執行的?天真了!JVM 或 CPU 為了跑得更快,可能悄悄把它變成:

int b = 2;
int a = 1;
int c = 3;  // 直接計算好結果

單線程下這沒啥問題——反正結果是對的,你也看不出來。但在多線程環境下,這種"暗地裏的重排"就是災難的開始。

舉個生活例子:你跟朋友説"我買票(A)後發你信息(B),你收到後去取票(C)"。結果因為"重排",你還沒買票就發了信息,朋友跑去取票,尷尬了吧?happens-before 規則就是防止這種情況的。

Java 內存模型:每個線程都有自己的"小本子"

多線程編程的核心難點在於:每個線程都有自己的工作內存,改變量時先改自己的副本,然後再同步到主內存。這就導致了一個線程的修改,另一個線程不一定能立即看到。

happens-before:理解這對概念

先搞清楚兩個概念:

  1. happens-before 關係是 JMM 定義的一種偏序關係,若操作 A happens-before 操作 B,則 JMM 保證 A 的執行結果對 B 可見,且 A 的操作順序在內存語義上先於 B。這種關係不要求 A 和 B 在物理時間上嚴格先後執行,而是通過規則確保 B 能看到 A 的最終結果。

打個比方:即使你下午 3 點改了文檔,我上午 10 點讀取(比如用了緩存),系統也會確保我看到的是你改過的最新版本。

  1. happens-before 規則是 Java 語言規範定義的 7 條具體判定條件,告訴我們哪些場景下操作間必然存在 happens-before 關係。這些規則是我們寫線程安全代碼的基石。

下面我把這 7 條規則一個個拆開説:

1. 程序順序規則:單線程內的順序保證

int a = 1;   // 操作A
int b = a + 1;  // 操作B
System.out.println(b);  // 操作C

在單線程內,A happens-before B,B happens-before C。即使 JVM 或 CPU 可能重排指令,也保證效果與順序執行一致——這就是"as-if-serial"語義。

2. 鎖規則:解鎖前的修改對加鎖後可見

synchronized void modify() {
    // 臨界區操作
    counter++;
}

假設小張執行完 modify()釋放了鎖,隨後小王獲取同一把鎖,那麼小張在臨界區的所有修改對小王都是可見的。

就像交接班一樣——小張下班前必須把最新情況記錄在交接本上,小王上班第一件事就是看交接本。從底層看,JVM 在鎖釋放時插入內存屏障,強制將工作內存的修改刷新到主內存;在獲取鎖時也插入屏障,保證從主內存讀取最新值。

3. volatile 變量規則:寫入即對所有線程可見

class SharedData {
    private volatile boolean flag = false;
    private int value = 0;

    public void produce() {
        value = 42;  // 寫普通變量
        flag = true;  // 寫volatile變量
    }

    public void consume() {
        if (flag) {  // 讀volatile變量
            System.out.println(value);  // 讀普通變量
        }
    }
}

這裏形成一個關鍵的傳遞鏈:

  1. 程序順序規則:寫value=42 happens-before 寫flag=true
  2. volatile 規則:寫flag=true happens-before 讀flag
  3. 傳遞性規則:寫value=42 happens-before 讀flag之後的操作

傳遞性規則的本質是"規則的疊加"。當多個規則(如程序順序、volatile、鎖規則)形成鏈式關係時,無需直接關聯的操作也能通過傳遞性建立 happens-before 關係。在這個例子中:

  • 寫普通變量(value=42)→ 寫 volatile 變量(flag=true,程序順序規則)
  • 寫 volatile 變量(flag=true)→ 讀 volatile 變量(if(flag),volatile 規則)
  • 通過傳遞性,寫 value 的結果對讀 flag 後的操作可見。

因此,根據 happens-before 規則的傳遞性推導,消費者讀到flag=true時,必然能看到value=42的最新值。

4. 線程啓動規則:start()前的操作對新線程可見

class MyTask implements Runnable {
    private int value;

    public MyTask(int value) {
        this.value = value;
    }

    @Override
    public void run() {
        System.out.println("Value: " + value);
    }
}

public void startThread() {
    int localValue = 10;
    Thread thread = new Thread(new MyTask(localValue));
    localValue = 20;  // 這個修改對子線程不可見
    thread.start();
}

這條規則確保thread.start()之前對共享變量的操作對子線程可見。上面例子中的localValue是局部變量,通過構造函數值傳遞給子線程,所以後續修改不影響子線程看到的值。

若修改的是共享變量(比如類的靜態字段或成員變量),那麼 start()前的修改對子線程可見,而 start()後的修改則不保證可見。這條規則主要針對共享內存,而非方法參數的值傳遞。

5. 線程終止規則:線程的操作對 join()之後可見

class Worker extends Thread {
    private int result;

    @Override
    public void run() {
        // 複雜計算
        result = 42;
    }

    public int getResult() {
        return result;
    }
}

public void useWorker() throws InterruptedException {
    Worker worker = new Worker();
    worker.start();
    worker.join();  // 等待線程終止
    int finalResult = worker.getResult();  // 一定能看到正確結果
    System.out.println("Result: " + finalResult);
}

worker.join()返回時,Worker 線程的所有操作(包括對共享變量的修改)對當前線程可見。這就像你等同事完成任務再離開,此時你能看到他完成的所有工作。

6. 中斷規則:interrupt()對被中斷線程立即可見

Thread t = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 執行任務
    }
    System.out.println("線程被中斷了");
});
t.start();
// 一段時間後
t.interrupt();  // 這個調用happens-before於t線程檢測到中斷

主線程調用t.interrupt()後,子線程一定能檢測到中斷狀態的變化。這保證了使用中斷進行線程協作的可靠性。

7. 傳遞性規則:happens-before 的連鎖反應

如果 A happens-before B,B happens-before C,那麼 A happens-before C。這條規則是實際應用中的強大工具。

class TransitiveExample {
    int a = 0;
    volatile boolean flag1 = false;
    volatile boolean flag2 = false;

    void writeA() {
        a = 1;                 // A
        flag1 = true;          // B
    }

    void writeFlag2() {
        if (flag1) {           // 讀取B
            flag2 = true;      // C
        }
    }

    void readA() {
        if (flag2) {           // 讀取C
            // 此處的a一定是1
            System.out.println(a);
        }
    }
}

傳遞性規則建立了這樣的鏈條:

  • 寫 a=1 → 寫 flag1=true(程序順序規則)
  • 寫 flag1=true → 讀 flag1(volatile 規則)
  • 讀 flag1 → 寫 flag2=true(程序順序規則)
  • 寫 flag2=true → 讀 flag2(volatile 規則)
  • 通過傳遞性,寫 a=1 對讀 flag2 後的操作可見

所以如果讀到flag2=true,那麼a的值必然是 1。通過這種規則的組合,我們能構建出複雜的線程間同步邏輯。

實戰案例解析

案例 1:雙重檢查鎖定的隱藏坑

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {  // 第一次檢查
            synchronized (Singleton.class) {
                if (instance == null) {  // 第二次檢查
                    instance = new Singleton();  // 隱藏的坑
                }
            }
        }
        return instance;
    }
}

問題在哪?

instance = new Singleton()看似簡單,實際包含三個步驟:

  1. 分配內存空間
  2. 調用構造函數初始化對象
  3. 將引用賦值給 instance 變量

JVM 允許在單線程內部對這三步重排序(因為不影響單線程結果),可能變成 1→3→2 的順序。這意味着可能先把引用賦值給 instance,然後才執行構造函數。若線程 A 執行到這種狀態,線程 B 剛好通過第一個檢查,會看到 instance 不為 null 但實際上對象還沒初始化完成,使用時可能拋異常。

解決方案:加 volatile

public class Singleton {
    private static volatile Singleton instance;
    // 其他代碼不變
}

volatile 通過插入內存屏障,禁止了 1→3→2 的重排序,確保對象完全初始化後才會對 instance 賦值,這樣其他線程要麼看到 null,要麼看到已完全初始化的對象。

案例 2:用 volatile 實現簡單的消息傳遞

public class ProducerConsumer {
    private volatile boolean hasData = false;
    private int data;

    public void produce(int newData) {
        data = newData;  // 1
        hasData = true;  // 2 (volatile寫)
    }

    public void consume() {
        if (hasData) {  // 3 (volatile讀)
            process(data);  // 4
            hasData = false;  // 5 (volatile寫)
        }
    }

    private void process(int data) {
        System.out.println("Processing: " + data);
    }
}

happens-before 分析

這裏的傳遞鏈很清晰:

  1. 步驟 1(data=新值)→ 步驟 2(hasData=true)[程序順序規則]
  2. 步驟 2 → 步驟 3(讀hasData)[volatile 規則]
  3. 根據傳遞性,步驟 1 → 步驟 3 之後的操作 [傳遞性規則]

所以,根據 happens-before 規則的傳遞性推導,當消費者看到hasData=true時,必然能看到生產者設置的 data 的最新值。

如果去掉 volatile 會怎樣?

  1. 消費者可能永遠看不到hasData=true(可見性問題)
  2. 或者消費者看到了hasData=true,但看不到最新的data值(重排序問題)

第二種情況特別坑——程序表面上在運行,但處理的卻是錯誤數據,這種 bug 調起來真要命!

常見誤區與注意事項

誤區 1:volatile 能解決所有併發問題

public class Counter {
    private volatile int count = 0;

    public void increment() {
        count++;  // 表面上一行代碼,實際分三步:讀取、遞增、寫入
    }
}

加了 volatile 只解決了可見性和有序性,沒解決原子性。count++包含讀取、遞增、寫入三個步驟,多線程同時執行時仍會出現計數錯誤。

原子性和 happens-before 是兩個不同維度的問題:

AtomicIntegergetAndIncrement()為例:

  • 原子性:通過 CPU 的 CAS 指令保證"讀取-修改-寫入"作為整體執行,不會中途被打斷
  • happens-before:方法內部通過內存屏障保證,當前線程的寫操作結果對後續其他線程的讀操作可見

兩者缺一不可:原子性確保操作不被打斷,happens-before 確保結果及時可見,共同保障多線程計數的正確性。

誤區 2:隨便用同步機制

過度使用 synchronized 或 volatile 會帶來性能開銷:

  • synchronized 可能導致線程阻塞和上下文切換(一次上下文切換大約耗時 1-10 微秒)
  • volatile 的內存屏障會禁止某些指令重排優化
  • volatile 的緩存同步會導致高速緩存失效(高頻讀寫時尤為明顯)

使用建議:

  • 局部變量不需要同步(不在線程間共享)
  • 不可變對象不需要額外同步(如 String)
  • 選擇合適的併發工具(如 ConcurrentHashMap)

內存屏障:happens-before 的底層實現

happens-before 規則是通過內存屏障(Memory Barrier)指令實現的,它們就像指令執行路上的"紅綠燈":

規則 底層內存屏障
鎖規則 解鎖:StoreStore 屏障;加鎖:LoadLoad 屏障
volatile 寫 StoreStore 屏障(前)+ StoreLoad 屏障(後)
volatile 讀 LoadLoad 屏障(後)+ LoadStore 屏障(後)
  1. StoreStore 屏障:寫操作的紅綠燈,確保前面所有寫操作完成後,後面的寫操作才能執行
  2. LoadLoad 屏障:讀操作的紅綠燈,確保前面所有讀操作完成後,後面的讀操作才能執行
  3. LoadStore 屏障:確保前面所有讀操作完成後,後面的寫操作才能執行
  4. StoreLoad 屏障:最強的屏障,確保前面所有寫操作完成後,後面的讀操作才能執行

鎖規則的底層實現:當線程釋放鎖時(monitorexit),JVM 插入 StoreStore 屏障,強制將工作內存中所有修改刷新到主內存。當另一個線程獲取同一把鎖時(monitorenter),JVM 插入 LoadLoad 屏障,強制從主內存重新加載變量。這兩個屏障的組合,確保了"解鎖操作的結果對後續加鎖操作可見"。

JMM 三大特性與 happens-before 的關係

Java 內存模型(JMM)有三大特性:

  1. 原子性:操作不可分割
  • 由 synchronized、Atomic 類等保證
  • happens-before 不直接解決原子性問題
  1. 可見性:一個線程的修改對其他線程可見
  • 由 happens-before 規則保證
  • 通過 volatile、鎖釋放/獲取等機制實現
  1. 有序性:程序執行順序可預測
  • 單線程內由 as-if-serial 語義保證
  • 多線程間由 happens-before 規則保證

如何平衡性能與線程安全

  1. 減少共享:能不共享的變量就不共享
  2. 優先不可變:不變的數據天生線程安全
  3. 隔離修改:用 ThreadLocal 隔離線程修改
  4. 縮小同步範圍:只鎖關鍵部分,不要鎖整個方法
  5. 選對工具:
  • 讀多寫少用 ReadWriteLock
  • 高頻計數用 LongAdder 代替 AtomicLong
  • 集合用 ConcurrentHashMap 代替 HashMap+鎖

總結

規則 描述 典型應用 底層實現
程序順序規則 單線程中按代碼順序保證可見性 單線程代碼執行 JVM 的 as-if-serial 語義
鎖規則 解鎖 happens-before 後續加鎖 synchronized 代碼塊、ReentrantLock monitorenter/exit 指令與內存屏障
volatile 規則 volatile 寫 happens-before 後續讀 狀態標誌、安全發佈、雙重檢查鎖定 讀寫屏障指令
線程啓動規則 start()調用 happens-before 線程中操作 向新線程傳遞初始狀態、線程池提交任務 JVM 內部同步機制
線程終止規則 線程操作 happens-before 檢測到線程終止 獲取子線程處理結果、Future.get()異步結果獲取 join()方法的內存同步
中斷規則 interrupt()調用 happens-before 檢測中斷 線程協作取消任務、超時處理 JVM 對中斷狀態的同步
傳遞性規則 A→B 且 B→C 則 A→C 構建複雜同步鏈、在多線程間傳遞狀態 基於其他規則的邏輯推導
user avatar youyudeshangpu_cny857 Avatar dbkangaroo Avatar liu_486 Avatar zzger Avatar
Favorites 4 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.