農林漁牧網

您現在的位置是:首頁 > 農業

Java記憶體模型:看Java如何解決可見性和有序性問題

2022-03-16由 Java架構師丨蘇先生 發表于 農業

管程是什麼意思

什麼是 Java 記憶體模型?

你已經知道,導致可見性的原因是快取,導致有序性的原因是編譯最佳化,那解決可見性、有序性最直接的辦法就是

禁用快取和編譯最佳化

,但是這樣問題雖然解決了,我們程式的效能可就堪憂了。

合理的方案應該是

按需禁用快取以及編譯最佳化

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

Java 記憶體模型是個很複雜的規範,可以從不同的視角來解讀,站在我們這些程式設計師的視角,本質上可以理解為,Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯最佳化的方法。具體來說,這些方法包括

volatile

synchronized

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。

/* 以下程式碼來源於【參考 1】 2 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 到底該如何理解。至於其他三項,我也會結合其他例子作以說明。

1. 程式的順序性規則

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

// 以下程式碼來源於【參考 1】 2 class VolatileExample { //int x = 0;volatile boolean v = false;public void writer(){ x = 42; v = true;}public void reader(){ if ( v == true ) {// 這裡 x 會是多少呢? // }}}

2. volatile 變數規則

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

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

3. 傳遞性

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

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

Java記憶體模型:看Java如何解決可見性和有序性問題

示例程式碼中的傳遞性規則

從圖中,我們可以看到:

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

寫變數“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 語義來搞定可見性的,這個在後面的內容中會詳細介紹。

4. 管程中鎖的規則

這條規則是指對一個鎖的解鎖 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。這個也是符合我們直覺的,應該不難理解。

5. 執行緒 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();

6. 執行緒 join() 規則

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

共享變數

的操作。

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

1 Thread B = new Thread(()->{2 // 此處對共享變數 var 修改3 var = 66;4 });5 // 例如此處對共享變數修改,6 // 則這個修改結果對執行緒 B 可見7 // 主執行緒啟動子執行緒8 B。start();9 B。join()10 // 子執行緒所有對共享變數的修改11 // 在主執行緒呼叫 B。join() 之後皆可見12 // 此例中,var==66

被我們忽視的 final

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

final 關鍵字

final 修飾變數時,初衷是告訴編譯器:這個變數生而不變,可以可勁兒最佳化。

Java 編譯器在 1。5 以前的版本的確最佳化得很努力,以至於都最佳化錯了。

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

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

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

1 // 以下程式碼來源於【參考 1】 2 final int x;3 // 錯誤的建構函式4 public FinalFieldExample() { 5 x = 3;6 y = 4;7 // 此處就是講 this 逸出,8 global。obj = this;9 }

總結

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 規則。相信經過本章的介紹,你應該對這部分內容已經有了深入的認識