根據happens-before法則藉助同步

pluto_charon發表於2022-01-19

在文章的開始,我們先來看一段程式碼以及他的執行情況:

public class PossibleRecording{
    static int x = 0, y = 0;
    static int a = 0, b = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                y = b;
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                x = a;
            }
        });

        threadOne.start();
        threadTwo.start();
        threadOne.join();
        threadTwo.join();
        System.out.println("( " + x + " , " + y + " )");
    }
}

執行結果:
( 0 , 1 )   
( 1 , 0 )
( 1 , 1 )
( 0 , 0 )

對於上面這一段及其簡單的程式碼,可以很簡單的想到程式是如何列印( 0 , 1 ) 或 ( 1 , 0 ) 或 ( 1 , 1 ) 的,執行緒One可以線上程Two開始前完成,執行緒Two也可以線上程One開始前完成,又或者他們可以交替完成。但是奇怪的是,程式竟然可以列印( 0 , 0 ),下圖展示了一種列印(0 , 0)的可能(由於每個執行緒中的動作都沒有依賴其他執行緒的資料流,因此這些動作可以亂序執行):

在執行程式時,為了提高效能,編譯器和處理器會對指令做重排序,記憶體級的重排序會讓程式的行為變得不可預期。而同步就抑制了編譯器、執行時和硬體對儲存操作的各種方式的重排序,否則這些重排序將會破壞JMM提供的可見性保證。JMM確保在不同的編譯器和不同的處理器平臺上,通過插入特定型別的Memory Barrier來禁止特定型別的編譯器重排序和處理器重排序,為上層提供一致的可見性保證。

那麼在正確使用同步、鎖的情況下,執行緒One修改了變數a的值何時對執行緒Two可見呢?我們無法就所有場景來規定某個執行緒修改的變數何時對其他執行緒可見,但是我們可以指定某些規則,這個規則就是happens-before。從JDK 5開始,JMM就是用happens-before的概念來闡述多執行緒之間的記憶體可見性。

happens-before原則的定義如下:

  1. 如果一個操作 happens-before 於另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
  2. 兩個操作之間存在 happens-before 的關係,並不意味著一定要按照happens-before原則制定的順序來執行,如果重排序之後的執行結果於按照 happens-before 關係來執行的結果一致,那麼這種重排序並不非法。

happens-before法則包括:

  • 程式次序法則:執行緒中的每個動作A都 happens-before 於該執行緒中的每一個動作B,其中在程式中,所有的動作B都出現在動作A之後
  • 監視器鎖法則:對一個監視器鎖的解鎖 happens-before 於每一個後續對同一監視器鎖的枷鎖
  • volatile變數法則: 對 volatile 域的寫入操作 happens-before 於每一個後續對同一個域的讀操作
  • 執行緒啟動法則: 在一個執行緒裡,對Thread.start() 方法的呼叫會 happens-before 於每一個啟動執行緒中的動作
  • 執行緒終結法則: 縣城中的任何動作都 happens-before 於其他執行緒檢測到這個執行緒已經終結、或者動Thread.join()的呼叫中成功返回,或者Thread.isAlive()返回false
  • 中斷法則:一個執行緒呼叫另一個執行緒的 interrupt happens-before與被中斷的執行緒發現中斷(通過丟擲InterruptedException異常,或者呼叫isInterrupted和 interrupted)
  • 終結法則: 一個物件的建構函式的結束 happens-before 於這個物件 finalizer 的開始
  • 傳遞性: 如果A happens-before 於B,且B happens-before 於C,則A happens-before 於 C

當一個變數被多個執行緒讀取,且至少被一個執行緒寫入時,如果讀寫操作並未依照排序,就會產生資料競爭。一個正確同步的執行緒是沒有資料競爭的程式。加鎖解鎖對volatile變數的讀寫啟動一個執行緒以及檢測執行緒是否結束這樣的操作均是同步動作。

FutureTask原始碼解讀

接下來看看FutureTask中是如何巧妙運用happens-before法則的。

在FutureTask中最重要的變數就是上圖中標記出來的兩個。

  • state:是一個volatile修飾的變數,用於表示當前task的狀態
  • outcome:用於get()返回的正常結果,也可能是異常

注意看outcome後面的註釋,在jdk原始碼中很少有這樣的註釋,一旦有這樣的註釋,那肯定是非常重要的。

理論上講,outcome會被多個執行緒訪問,其中應該是一個執行緒可以讀寫,其他的執行緒都只能讀。那這種情況下,為啥不加上volatile呢?加上volatile的好處就是可以讓outcome和state變數被修改後,其他執行緒可以立刻感知到。但作者為啥不加上volatile呢?

在整個類中,與outcome變數的寫入操作,只有這兩個地方:

protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
        finishCompletion();
    }
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

與outcome有關的讀取操作,即get操作:

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

public V get(long timeout, TimeUnit unit)
    throws InterruptedException, ExecutionException, TimeoutException {
    if (unit == null)
        throw new NullPointerException();
    int s = state;
    if (s <= COMPLETING &&
        (s = awaitDone(true, unit.toNanos(timeout))) <= COMPLETING)
        throw new TimeoutException();
    return report(s);
}

接下來我們把目光集中到這三個方法上:set(),get(),report()

我們把get()和report()合併到一起,將多餘的程式碼去掉,如下:

public V get() {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    Object x = outcome;
    if (s == NORMAL);
        return (V)x;
}

從上面可以看出,當state為NORMAL的時候,返回outcome。

再來看看set()方法:

protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
        finishCompletion();
    }
}

第二行,通過UNSAFE的cas操作將狀態從NEW狀態改為COMPLETING,cas設定成功之後,進入if方法裡面,然後給outcome設定值,第四行,將state的狀態設定為NORMAL狀態,從備註中可以看到這是一個最終狀態。那從NEW狀態到NORMAL狀態,中間有一個稍縱即逝的狀態-COMPLETING。從get方法中可以看到,如果state的狀態小於等於COMPLETING(即為NEW狀態)時,就是當前執行緒沒有搶到CPU的執行時間,進入等到狀態。

我們把get()和set()的虛擬碼放在一起:

首先你讀到標號為4的地方,讀到的值是NORMAL,那麼說明標號為3的地方一定已經執行過了,因為state是volatile修飾過的,根據happens-before關係:volatile變數法則:對 volatile 域的寫入操作 happens-before 於每一個後續對同一個域的讀操作。所以我們可以得出標號3的程式碼先於標號4的程式碼執行。

而又根據程式次序規則,即:

在一個執行緒內,按照控制流順序,書寫在前面的操作先行於書寫在後面的操作。注意,這裡說的是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。

可以得出:2 happens-before 3 happens-before 4 happens-before 5;

又根據傳遞性的規則,即:

傳遞性: 如果A happens-before 於B,且B happens-before 於C,則A happens-before 於 C

可以得出,2 happens-before 5。而2就是對outcome變數的寫入,5是對outcome變數的讀取。所以,雖然outcome的變數沒有加volatile,但是他是通過被volatile修飾的state變數,藉助了變數的happens-before關係,完成了同步的操作(即寫入先於讀取)。

參考文章:

推薦一個公眾號:【why技術】 https://mp.weixin.qq.com/s/1SjOChRD0a241UCsBEAfCA

https://www.cmsblogs.com/?p=2102

https://blog.csdn.net/xixi_haha123/article/details/81155796

相關文章