02|Java內存模型:看Java如何解決可見性和有序性問題

上一期我們講到在并發場景中可見性、原子性、有序性導致的問題常常會違背我們的直覺,從而成為并發的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的傳遞應用到我們的例子中,會發生什么呢?可以看下面這幅圖:

img

從圖中,我們可以看到:

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 規則。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容