你知道Java是如何解決可見性和有序性問題的嗎?

無敵天驕發表於2021-04-22

Java 記憶體模型這個概念,在職場的很多面試中都會考核到,是一個熱門的考點,也是一個人併發水平的具體體現。原因是當併發程式出問題時,需要一行一行地檢查程式碼,這個時候,只有掌握 Java 記憶體模型,才能慧眼如炬地發現問題。

什麼是 Java 記憶體模型?

你已經知道,導致可見性的原因是快取,導致有序性的原因是編譯最佳化,那解決可見性、有序性最直接的辦法就是禁用快取和編譯最佳化,但是這樣問題雖然解決了,我們程式的效能可就堪憂了。

合理的方案應該是 按需禁用快取以及編譯最佳化。 那麼,如何做到“按需禁用”呢?對於併發程式,何時禁用快取以及編譯最佳化只有程式設計師知道,那所謂“按需禁用”其實就是指按照程式設計師的要求來禁用。所以,為了解決可見性和有序性問題,只需要提供給程式設計師按需禁用快取和編譯最佳化的方法即可。

Java 記憶體模型是個很複雜的規範,可以從不同的視角來解讀,站在我們這些程式設計師的視角,本質上可以理解為,Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯最佳化的方法。具體來說,這些方法包括  volatilesynchronized 和  final 三個關鍵字,以及六項  Happens-Before 規則,這也正是本期的重點內容。

使用 volatile 的困惑

volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言裡也有,它最原始的意義就是禁用 CPU 快取。

例如,我們宣告一個 volatile 變數  volatile int x = 0,它表達的是:告訴編譯器,對這個變數的讀寫,不能使用 CPU 快取,必須從記憶體中讀取或者寫入。這個語義看上去相當明確,但是在實際使用的時候卻會帶來困惑。

例如下面的示例程式碼,假設執行緒 A 執行  writer() 方法,按照 volatile 語義,會把變數 “v=true” 寫入記憶體;假設執行緒 B 執行  reader() 方法,同樣按照 volatile 語義,執行緒 B 會從記憶體中讀取變數 v,如果執行緒 B 看到 “v == true” 時,那麼執行緒 B 看到的變數 x 是多少呢?

直覺上看,應該是 42,那實際應該是多少呢?這個要看 Java 的版本,如果在低於 1.5 版本上執行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上執行,x 就是等於 42。

class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
        x = 42;
        v = true;
    }
    public void reader() {
        if (v == true) {
            // 這裡 x 會是多少呢?
        }
    }
    }

分析一下,為什麼 1.5 以前的版本會出現 x = 0 的情況呢?我相信你一定想到了,變數 x 可能被 CPU 快取而導致可見性問題。這個問題在 1.5 版本已經被圓滿解決了。Java 記憶體模型在 1.5 版本對 volatile 語義進行了增強。怎麼增強的呢?答案是一項 Happens-Before 規則。

Happens-Before 規則

如何理解  Happens-Before 呢?如果望文生義(很多網文也都愛按字面意思翻譯成“先行發生”),那就南轅北轍了, Happens-Before 並不是說前面一個操作發生在後續操作的前面,它真正要表達的是: 前面一個操作的結果對後續操作是可見的。 就像有心靈感應的兩個人,雖然遠隔千里,一個人心之所想,另一個人都看得到。Happens-Before 規則就是要保證執行緒之間的這種“心靈感應”。所以比較正式的說法是: Happens-Before 約束了編譯器的最佳化行為,雖允許編譯器最佳化,但是要求編譯器最佳化後一定遵守  Happens-Before 規則。

Happens-Before 規則應該是 Java 記憶體模型裡面最晦澀的內容了,和程式設計師相關的規則一共有如下六項,都是關於可見性的。

恰好前面示例程式碼涉及到這六項規則中的前三項,為便於你理解,我也會分析上面的示例程式碼,來看看規則 1、2 和 3 到底該如何理解。至於其他三項,我也會結合其他例子作以說明。

①程式的順序性規則

這條規則是指在一個執行緒中,按照程式順序,前面的操作 Happens-Before 於後續的任意操作。這還是比較容易理解的,比如剛才那段示例程式碼,按照程式的順序,第 6 行程式碼 “ x = 42;” Happens-Before 於第 7 行程式碼 “ v = true;”,這就是規則 1 的內容,也比較符合單執行緒裡面的思維: 程式前面對某個變數的修改一定是對後續操作可見的。

class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
        x = 42;
        v = true;
    }
    public void reader() {
        if (v == true) {
            // 這裡 x 會是多少呢?
        }
    }
}

②volatile 變數規則

這條規則是指對一個 volatile 變數的寫操作, Happens-Before 於後續對這個 volatile 變數的讀操作。

這個就有點費解了,對一個 volatile 變數的寫操作相對於後續對這個 volatile 變數的讀操作可見,這怎麼看都是禁用快取的意思啊,貌似和 1.5 版本以前的語義沒有變化啊?如果單看這個規則,的確是這樣,但是如果我們關聯一下規則 3,就有點不一樣的感覺了。

③傳遞性

這條規則是指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。

我們將規則 3 的傳遞性應用到我們的例子中,會發生什麼呢?可以看下面這幅圖:


你知道Java是如何解決可見性和有序性問題的嗎?
示例程式碼中的傳遞性規則

從圖中,我們可以看到:

  1. “x=42” Happens-Before 寫變數 “v=true” ,這是規則 1 的內容;
  2. 寫變數“v=true” Happens-Before 讀變數 “v=true”,這是規則 2 的內容 。

再根據這個傳遞性規則,我們得到結果:“x=42” Happens-Before 讀變數“v=true”。這意味著什麼呢?

如果執行緒 B 讀到了“v=true”,那麼執行緒 A 設定的“x=42”對執行緒 B 是可見的。也就是說,執行緒 B 能看到 “x == 42” ,有沒有一種恍然大悟的感覺?這就是 1.5 版本對 volatile 語義的增強,這個增強意義重大,1.5 版本的併發工具包( java.util.concurrent)就是靠 volatile 語義來搞定可見性的,這個在後面的內容中會詳細介紹。

④管程中鎖的規則

這條規則是指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。

要理解這個規則,就首先要了解“管程指的是什麼”。 管程是一種通用的同步原語,在 Java 中指的就是 synchronized,synchronized 是 Java 裡對管程的實現。

管程中的鎖在 Java 裡是隱式實現的,例如下面的程式碼,在進入同步塊之前,會自動加鎖,而在程式碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實現的。

synchronized (this) { 
    // 此處自動加鎖
    // x 是共享變數, 初始值 =10
    if (this.x < 12) {
        this.x = 12; 
    } 
 } // 此處自動解鎖

所以結合規則 4——管程中鎖的規則,可以這樣理解:假設 x 的初始值是 10,執行緒 A 執行完程式碼塊後 x 的值會變成 12(執行完自動釋放鎖),執行緒 B 進入程式碼塊時,能夠看到執行緒 A 對 x 的寫操作,也就是執行緒 B 能夠看到 x==12。這個也是符合我們直覺的,應該不難理解。

⑤執行緒 start() 規則

這條是關於執行緒啟動的。它是指主執行緒 A 啟動子執行緒 B 後,子執行緒 B 能夠看到主執行緒在啟動子執行緒 B 前的操作。

換句話說就是,如果執行緒 A 呼叫執行緒 B 的  start() 方法(即線上程 A 中啟動執行緒 B),那麼該  start() 操作 Happens-Before 於執行緒 B 中的任意操作。具體可參考下面示例程式碼。

Thread B = new Thread(()->{
    // 主執行緒呼叫 B.start() 之前
    // 所有對共享變數的修改,此處皆可見
    // 此例中,var==77
});
// 此處對共享變數 var 修改var = 77;
// 主執行緒啟動子執行緒
B.start();

⑥執行緒 join() 規則

這條是關於執行緒等待的。它是指主執行緒 A 等待子執行緒 B 完成(主執行緒 A 透過呼叫子執行緒 B的 join() 方法實現),當子執行緒 B 完成後(主執行緒 A 中 join() 方法返回),主執行緒能夠看到子執行緒的操作。當然所謂的“看到”,指的是對 共享變數的操作。

換句話說就是,如果線上程 A 中,呼叫執行緒 B 的 join() 併成功返回,那麼執行緒 B 中的任意操作 Happens-Before 於該 join() 操作的返回。具體可參考下面示例程式碼。

Thread B = new Thread(()->{
    // 此處對共享變數 var 修改
    var = 66;
});
// 例如此處對共享變數修改,
// 則這個修改結果對執行緒 B 可見
// 主執行緒啟動子執行緒
B.start();
B.join()
// 子執行緒所有對共享變數的修改
// 在主執行緒呼叫 B.join() 之後皆可見
// 此例中,
var==66

被我們忽視的 final

前面我們講 volatile 為的是禁用快取以及編譯最佳化,我們再從另外一個方面來看,有沒有辦法告訴編譯器最佳化得更好一點呢?這個可以有,就是 final 關鍵字

final 修飾變數時,初衷是告訴編譯器:這個變數生而不變,可以可勁兒最佳化。 Java 編譯器在 1.5 以前的版本的確最佳化得很努力,以至於都最佳化錯了。

問題類似於上一期提到的利用雙重檢查方法建立單例,建構函式的錯誤重排導致執行緒可能看到 final 變數的值會變化。

當然了,在 1.5 以後 Java 記憶體模型對 final 型別變數的重排進行了約束。現在只要我們提供正確建構函式沒有“逸出”,就不會出問題了。

“逸出”有點抽象,我們還是舉個例子吧,在下面例子中,在建構函式里面將 this 賦值給了全域性變數  global.obj,這就是“逸出”,執行緒透過  global.obj 讀取 x 是有可能讀到 0 的。因此我們一定要避免“逸出”。

final int x;// 錯誤的建構函式public FinalFieldExample() { 
    x = 3;
    y = 4;
    // 此處就是講 this 逸出,
    global.obj = this;
}

總結

Java 的記憶體模型是併發程式設計領域的一次重要創新,之後 C++、C#、Golang 等高階語言都開始支援記憶體模型。Java 記憶體模型裡面,最晦澀的部分就是 Happens-Before 規則了,Happens-Before 規則最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System的論文中提出來的,在這篇論文中,Happens-Before 的語義是一種因果關係。在現實世界裡,如果 A 事件是導致 B 事件的起因,那麼 A 事件一定是先於(Happens-Before)B 事件發生的,這個就是 Happens-Before 語義的現實理解。

在 Java 語言裡面,Happens-Before 的語義本質上是一種可見性,A Happens-Before B 意味著 A 事件對 B 事件來說是可見的,無論 A 事件和 B 事件是否發生在同一個執行緒裡。例如 A 事件發生線上程 1 上,B 事件發生線上程 2 上,Happens-Before 規則保證執行緒 2 上也能看到 A 事件的發生。

Java 記憶體模型主要分為兩部分,一部分面向你我這種編寫併發程式的應用開發人員,另一部分是面向 JVM 的實現人員的,我們可以重點關注前者,也就是和編寫併發程式相關的部分,這部分內容的核心就是 Happens-Before 規則。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69964492/viewspace-2769404/,如需轉載,請註明出處,否則將追究法律責任。

相關文章