Java 併發基礎之記憶體模型

Rose發表於2019-01-19

併發三問題
這節將介紹重排序、記憶體可見性以及原子性相關的知識,這些也是併發程式為什麼難寫的原因。

1. 重排序
請讀者先在自己的電腦上執行一下以下程式:

public class Test {

private static int x = 0, y = 0;
private static int a = 0, b =0;

public static void main(String[] args) throws InterruptedException {
    int i = 0;
    for(;;) {
        i++;
        x = 0; y = 0;
        a = 0; b = 0;
        CountDownLatch latch = new CountDownLatch(1);

        Thread one = new Thread(() -> {
            try {
                latch.await();
            } catch (InterruptedException e) {
            }
            a = 1;
            x = b;
        });

        Thread other = new Thread(() -> {
            try {
                latch.await();
            } catch (InterruptedException e) {
            }
            b = 1;
            y = a;
        });
        one.start();other.start();
        latch.countDown();
        one.join();other.join();

        String result = "第" + i + "次 (" + x + "," + y + ")";
        if(x == 0 && y == 0) {
            System.err.println(result);
            break;
        } else {
            System.out.println(result);
        }
    }
}
}

幾秒後,我們就可以得到 x == 0 && y == 0 這個結果,仔細看看程式碼就會知道,如果不發生重排序的話,這個結果是不可能出現的。

重排序由以下幾種機制引起:

  1. 編譯器優化:對於沒有資料依賴關係的操作,編譯器在編譯的過程中會進行一定程度的重排。

大家仔細看看執行緒 1 中的程式碼,編譯器是可以將 a = 1 和 x = b 換一下順序的,因為它們之間沒有資料依賴關係,同理,執行緒 2
也一樣,那就不難得到 x == y == 0 這種結果了。

  1. 指令重排序:CPU 優化行為,也是會對不存在資料依賴關係的指令進行一定程度的重排。

這個和編譯器優化差不多,就算編譯器不發生重排,CPU 也可以對指令進行重排,這個就不用多說了。

  1. 記憶體系統重排序:記憶體系統沒有重排序,但是由於有快取的存在,使得程式整體上會表現出亂序的行為。

假設不發生編譯器重排和指令重排,執行緒 1 修改了 a 的值,但是修改以後,a 的值可能還沒有寫回到主存中,那麼執行緒 2 得到 a == 0
就是很自然的事了。同理,執行緒 2 對於 b 的賦值操作也可能沒有及時重新整理到主存中。

2. 記憶體可見性
前面在說重排序的時候,也說到了記憶體可見性的問題,這裡再囉嗦一下。

執行緒間的對於共享變數的可見性問題不是直接由多核引起的,而是由多快取引起的。如果每個核心共享同一個快取,那麼也就不存在記憶體可見性問題了。

現代多核 CPU 中每個核心擁有自己的一級快取或一級快取加上二級快取等,問題就發生在每個核心的獨佔快取上。每個核心都會將自己需要的資料讀到獨佔快取中,資料修改後也是寫入到快取中,然後等待刷入到主存中。所以會導致有些核心讀取的值是一個過期的值。

Java 作為高階語言,遮蔽了這些底層細節,用 JMM 定義了一套讀寫記憶體資料的規範,雖然我們不再需要關心一級快取和二級快取的問題,但是,JMM 抽象了主記憶體和本地記憶體的概念。

所有的共享變數存在於主記憶體中,每個執行緒有自己的本地記憶體,執行緒讀寫共享資料也是通過本地記憶體交換的,所以可見性問題依然是存在的。這裡說的本地記憶體並不是真的是一塊給每個執行緒分配的記憶體,而是 JMM 的一個抽象,是對於暫存器、一級快取、二級快取等的抽象。

3. 原子性
在本文中,原子性不是重點,它將作為併發程式設計中需要考慮的一部分進行介紹。

說到原子性的時候,大家應該都能想到 long 和 double,它們的值需要佔用 64 位的記憶體空間,Java 程式語言規範中提到,對於 64 位的值的寫入,可以分為兩個 32 位的操作進行寫入。本來一個整體的賦值操作,被拆分為低 32 位賦值和高 32 位賦值兩個操作,中間如果發生了其他執行緒對於這個值的讀操作,必然就會讀到一個奇怪的值。

這個時候我們要使用 volatile 關鍵字進行控制了,JMM 規定了對於 volatile long 和 volatile double,JVM 需要保證寫入操作的原子性。

另外,對於引用的讀寫操作始終是原子的,不管是 32 位的機器還是 64 位的機器。

Java 程式語言規範同樣提到,鼓勵 JVM 的開發者能保證 64 位值操作的原子性,也鼓勵使用者儘量使用 volatile 或使用正確的同步方式。關鍵詞是”鼓勵“。

在 64 位的 JVM 中,不加 volatile 也是可以的,同樣能保證對於 long 和 double
寫操作的原子性。關於這一點,我沒有找到官方的材料描述它,如果讀者有相關的資訊,希望可以給我反饋一下。

Java 對於併發的規範約束
併發問題使得我們的程式碼有可能會產生各種各樣的執行結果,顯然這是我們不能接受的,所以 Java 程式語言規範需要規定一些基本規則,JVM 實現者會在這些規則的約束下來實現 JVM,然後開發者也要按照規則來寫程式碼,這樣寫出來的併發程式碼我們才能準確預測執行結果。下面進行一些簡單的介紹。

Synchronization Order
Java 語言規範對於同步定義了一系列的規則:17.4.4. Synchronization Order,包括瞭如下同步關係:

對於監視器 m 的解鎖與所有後續操作對於 m 的加鎖同步

對 volatile 變數 v 的寫入,與所有其他執行緒後續對 v 的讀同步

啟動執行緒的操作與執行緒中的第一個操作同步。

對於每個屬性寫入預設值(0, false,null)與每個執行緒對其進行的操作同步。

儘管在建立物件完成之前對物件屬性寫入預設值有點奇怪,但從概念上來說,每個物件都是在程式啟動時用預設值初始化來建立的。

執行緒 T1 的最後操作與執行緒 T2 發現執行緒 T1 已經結束同步。

執行緒 T2 可以通過 T1.isAlive() 或 T1.join() 方法來判斷 T1 是否已經終結。

如果執行緒 T1 中斷了 T2,那麼執行緒 T1 的中斷操作與其他所有執行緒發現 T2 被中斷了同步(通過丟擲 InterruptedException 異常,或者呼叫 Thread.interrupted 或 Thread.isInterrupted )

Happens-before Order
兩個操作可以用 happens-before 來確定它們的執行順序,如果一個操作 happens-before 於另一個操作,那麼我們說第一個操作對於第二個操作是可見的。

如果我們分別有操作 x 和操作 y,我們寫成 hb(x, y) 來表示 x happens-before y。以下幾個規則也是來自於 Java 8 語言規範 Happens-before Order:

如果操作 x 和操作 y 是同一個執行緒的兩個操作,並且在程式碼上操作 x 先於操作 y 出現,那麼有 hb(x, y)

物件構造方法的最後一行指令 happens-before 於 finalize() 方法的第一行指令。

如果操作 x 與隨後的操作 y 構成同步,那麼 hb(x, y)。這條說的是前面一小節的內容。
hb(x, y) 和 hb(y, z),那麼可以推斷出 hb(x, z)
這裡再提一點,x happens-before y,並不是說 x 操作一定要在 y 操作之前被執行,而是說 x 的執行結果對於 y 是可見的,只要滿足可見性,發生了重排序也是可以的。

synchronized 關鍵字
monitor,這裡翻譯成監視器鎖,為了大家理解方便。

synchronized 這個關鍵字大家都用得很多了,這裡不會教你怎麼使用它,我們來看看它對於記憶體可見性的影響。

一個執行緒在獲取到監視器鎖以後才能進入 synchronized 控制的程式碼塊,一旦進入程式碼塊,首先,該執行緒對於共享變數的快取就會失效,因此 synchronized 程式碼塊中對於共享變數的讀取需要從主記憶體中重新獲取,也就能獲取到最新的值。

退出程式碼塊的時候的,會將該執行緒寫緩衝區中的資料刷到主記憶體中,所以在 synchronized 程式碼塊之前或 synchronized 程式碼塊中對於共享變數的操作隨著該執行緒退出 synchronized 塊,會立即對其他執行緒可見(這句話的前提是其他讀取共享變數的執行緒會從主記憶體讀取最新值)。

因此,我們可以總結一下:執行緒 a 對於進入 synchronized 塊之前或在 synchronized 中對於共享變數的操作,對於後續的持有同一個監視器鎖的執行緒 b 可見。雖然是挺簡單的一句話,請讀者好好體會。

注意一點,在進入 synchronized 的時候,並不會保證之前的寫操作刷入到主記憶體中,synchronized 主要是保證退出的時候能將本地記憶體的資料刷入到主記憶體。

單例模式中的雙重檢查

廢話少說,看以下單例模式的寫法:

public class Singleton {

private static Singleton instance = null;

private int v;
private Singleton() {
    this.v = 3;
}

public static Singleton getInstance() {
    if (instance == null) { // 1. 第一次檢查
        synchronized (Singleton.class) { // 2
            if (instance == null) { // 3. 第二次檢查
                instance = new Singleton(); // 4
            }
        }
    }
    return instance;
}
}

很多人都知道上述的寫法是不對的,但是可能會說不清楚到底為什麼不對。

我們假設有兩個執行緒 a 和 b 呼叫 getInstance() 方法,假設 a 先走,一路走到 4 這一步,執行 instance = new Singleton() 這句程式碼。

instance = new Singleton() 這句程式碼首先會申請一段空間,然後將各個屬性初始化為零值(0/null),執行構造方法中的屬性賦值[1],將這個物件的引用賦值給 instance[2]。在這個過程中,[1] 和 [2] 可能會發生重排序。

此時,執行緒 b 剛剛進來執行到 1(看上面的程式碼塊),就有可能會看到 instance 不為 null,然後執行緒 b 也就不會等待監視器鎖,而是直接返回 instance。問題是這個 instance 可能還沒執行完構造方法(執行緒 a 此時還在 4 這一步),所以執行緒 b 拿到的 instance 是不完整的,它裡面的屬性值可能是初始化的零值(0/false/null),而不是執行緒 a 在構造方法中指定的值。

回顧下前面的知識,分析下這裡為什麼會有這個問題。

1、編譯器可以將構造方法內聯過來,之後再發生重排序就很容易理解了。

2、即使不發生程式碼重排序,執行緒 a 對於屬性的賦值寫入到了執行緒 a 的本地記憶體中,此時對於執行緒 b 不可見。

最後提一點,如果執行緒 a 從 synchronized 塊出來了,那麼 instance 一定是正確構造的完整例項,這是我們前面說過的 synchronized 的記憶體可見性保證。

對於大部分讀者來說,這一小節其實可以結束了,很多讀者都知道,解決方案是使用 volatile 關鍵字,這個我們在介紹 volatile 的時候再說。當然,如果你還有耐心,也可以繼續看看本小節。

我們看下下面這段程式碼,看看它能不能解決我們之前碰到的問題。

public static Singleton getInstance() {

if (instance == null) { //
    Singleton temp;
    synchronized (Singleton.class) { //
        temp = instance;
        if (temp == null) { //
            synchronized (Singleton.class) { // 內嵌一個 synchronized 塊
                temp = new Singleton();
            }
            instance = temp; //
        }
    }
}
return instance;
}


上面這個程式碼很有趣,想利用 synchronized 的記憶體可見性語義,不過這個解決方案還是失敗了,我們分析下。

前面我們也說了,synchronized 在退出的時候,能保證 synchronized 塊中對於共享變數的寫入一定會刷入到主記憶體中。也就是說,上述程式碼中,內嵌的 synchronized 結束的時候,temp 一定是完整構造出來的,然後再賦給 instance 的值一定是好的。

可是,synchronized 保證了釋放監視器鎖之前的程式碼一定會在釋放鎖之前被執行(如 temp 的初始化一定會在釋放鎖之前執行完 ),但是沒有任何規則規定了,釋放鎖之後的程式碼不可以在釋放鎖之前先執行。

也就是說,程式碼中釋放鎖之後的行為 instance = temp 完全可以被提前到前面的 synchronized 程式碼塊中執行,那麼前面說的重排序問題就又出現了。

最後扯一點,如果所有的屬性都是使用 final 修飾的,其實之前介紹的雙重檢查是可行的,不需要加 volatile,這個等到 final 那節再介紹。

volatile 關鍵字
大部分開發者應該都知道怎麼使用這個關鍵字,只是可能不太瞭解箇中緣由。

如果你下次面試的時候有人問你 volatile 的作用,記住兩點:記憶體可見性和禁止指令重排序。

volatile 的記憶體可見性
我們還是用 JMM 的主記憶體和本地記憶體抽象來描述,這樣比較準確。還有,並不是只有 Java 語言才有 volatile 關鍵字,所以後面的描述一定要建立在 Java 跨平臺以後抽象出了記憶體模型的這個大環境下。

還記得 synchronized 的語義嗎?進入 synchronized 時,使得本地快取失效,synchronized 塊中對共享變數的讀取必須從主記憶體讀取;退出 synchronized 時,會將進入 synchronized 塊之前和 synchronized 塊中的寫操作刷入到主存中。

volatile 有類似的語義,讀一個 volatile 變數之前,需要先使相應的本地快取失效,這樣就必須到主記憶體讀取最新值,寫一個 volatile 屬性會立即刷入到主記憶體。所以,volatile 讀和 monitorenter 有相同的語義,volatile 寫和 monitorexit 有相同的語義。

volatile 的禁止重排序
前面提到,加個 volatile 能解決問題。其實就是利用了 volatile 的禁止重排序功能。

volatile 的禁止重排序並不侷限於兩個 volatile 的屬性操作不能重排序,而且是 volatile 屬性操作和它周圍的普通屬性的操作也不能重排序。

之前 instance = new Singleton() 中,如果 instance 是 volatile 的,那麼對於 instance 的賦值操作(賦一個引用給 instance 變數)就不會和建構函式中的屬性賦值發生重排序,能保證構造方法結束後,才將此物件引用賦值給 instance。

根據 volatile 的記憶體可見性和禁止重排序,那麼我們不難得出一個推論:執行緒 a 如果寫入一個 volatile 變數,此時執行緒 b 再讀取這個變數,那麼此時對於執行緒 a 可見的所有屬性對於執行緒 b 都是可見的。

volatile 小結

  1. volatile 修飾符適用於以下場景:某個屬性被多個執行緒共享,其中有一個執行緒修改了此屬性,其他執行緒可以立即得到修改後的值。在併發包的原始碼中,它使用得非常多。
  2. volatile 屬性的讀寫操作都是無鎖的,它不能替代 synchronized,因為它沒有提供原子性和互斥性。因為無鎖,不需要花費時間在獲取鎖和釋放鎖上,所以說它是低成本的。
  3. volatile 只能作用於屬性,我們用 volatile 修飾屬性,這樣 compilers 就不會對這個屬性做指令重排序。
  4. volatile 提供了可見性,任何一個執行緒對其的修改將立馬對其他執行緒可見。volatile 屬性不會被執行緒快取,始終從主存中讀取。
  5. volatile 提供了 happens-before 保證,對 volatile 變數 v 的寫入 happens-before 所有其他執行緒後續對 v 的讀操作。
  6. volatile 可以使得 long 和 double 的賦值是原子的,前面在說原子性的時候提到過。

final 關鍵字
用 final 修飾的類不可以被繼承,用 final 修飾的方法不可以被覆寫,用 final 修飾的屬性一旦初始化以後不可以被修改。當然,我們不關心這些段子,這節,我們來看看 final 帶來的記憶體可見性影響。

之前在說雙重檢查的單例模式的時候,提過了一句,如果所有的屬性都使用了 final 修飾,那麼 volatile 也是可以不要的,這就是 final 帶來的可見性影響。

在物件的構造方法中設定 final 屬性,同時在物件初始化完成前,不要將此物件的引用寫入到其他執行緒可以訪問到的地方(不要讓引用在建構函式中逸出)。如果這個條件滿足,當其他執行緒看到這個物件的時候,那個執行緒始終可以看到正確初始化後的物件的 final 屬性。

上面說得很明白了,final 屬性的寫操作不會和此引用的賦值操作發生重排序,如:

x.finalField = v; …; sharedRef = x;

小結
本文中的很多知識是和它相關的,不過那篇直譯的文章的可讀性差了些,希望本文能給讀者帶來更多的收穫。

描述該類知識需要非常嚴謹的語言描述,雖然我仔細檢查了好幾篇,但還是擔心有些地方會說錯,一來這些內容的正誤非常受我自身的知識積累影響,二來也和我在行文中使用的話語有很大的關係。希望讀者能幫助指正我表述錯誤的地方。

相關文章