上一期我們講到在并發場景中可見性、原子性、有序性導致的問題常常會違背我們的直覺,從而成為并發的BUG之源。這三者在編程領域屬于共性問題,所有的編程語言都會用到,Java 在誕生之初就支持多線程,自然也有針對這三者的技術方案,而且在編程語言領域處于領先地位。理解Java 解決并發問題的解決方案,對于解決其他語言的解決方案有觸類旁通的效果。
今天 我們來看下解決可見性、有序性導致的問題,也就是今天的主角:--Java 內存模型
Java 內存模型這個概念,在職場很多面試中都會考核到,是一個熱門的考點,也是一個人并發水平的具體體現,原因是當并發程序出問題時,需要一行一行的檢查代碼,這個時候只有掌握了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 是多少呢?
// 以下代碼來源于【參考1】
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、程序的順序性規則
這條規則指在一個線程中,按照程序順序,前面的操作Happens-Before 于后續的任意操作。這還是比較容易理解的,比如剛才那段示例代碼,按照程序的順序,第 6 行代碼 “x = 42;” Happens-Before 于第 7 行代碼 “v = true;”,這就是規則 1 的內容,也比較符合單線程里面的思維:程序前面對某個變量的修改一定是對后續操作可見的。
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的傳遞應用到我們的例子中,會發生什么呢?可以看下面這幅圖:
從圖中,我們可以看到:
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 語義來搞定可見性的,這個在后面的內容中會詳細介紹。4. 管程中鎖的規則
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能夠看到主線程在啟動子線程前的操作。
換句話說,如果線程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() 操作的返回。 具體可以參考下面的代碼。
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 的。因此我們一定要避免“逸出”。
// 以下代碼來源于【參考1】
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 規則。