Java記憶體模型以及happens-before規則

你聽___發表於2018-04-30

Java記憶體模型以及happens-before規則

1. JMM的介紹

在上一篇文章中總結了執行緒的狀態轉換和一些基本操作,對多執行緒已經有一點基本的認識了,如果多執行緒程式設計只有這麼簡單,那我們就不必費勁周折的去學習它了。在多執行緒中稍微不注意就會出現執行緒安全問題,那麼什麼是執行緒安全問題?我的認識是,在多執行緒下程式碼執行的結果與預期正確的結果不一致,該程式碼就是執行緒不安全的,否則則是執行緒安全的。雖然這種回答似乎不能獲取什麼內容,可以google下。在<<深入理解Java虛擬機器>>中看到的定義。原文如下: 當多個執行緒訪問同一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲取正確的結果,那這個物件是執行緒安全的。

關於定義的理解這是一個仁者見仁智者見智的事情。出現執行緒安全的問題一般是因為主記憶體和工作記憶體資料不一致性重排序導致的,而解決執行緒安全的問題最重要的就是理解這兩種問題是怎麼來的,那麼,理解它們的核心在於理解java記憶體模型(JMM)。

在多執行緒條件下,多個執行緒肯定會相互協作完成一件事情,一般來說就會涉及到多個執行緒間相互通訊告知彼此的狀態以及當前的執行結果等,另外,為了效能優化,還會涉及到編譯器指令重排序和處理器指令重排序。下面會一一來聊聊這些知識。

2. 記憶體模型抽象結構

執行緒間協作通訊可以類比人與人之間的協作的方式,在現實生活中,之前網上有個流行語“你媽喊你回家吃飯了”,就以這個生活場景為例,小明在外面玩耍,小明媽媽在家裡做飯,做晚飯後準備叫小明回家吃飯,那麼就存在兩種方式:

小明媽媽要去上班了十分緊急這個時候手機又沒有電了,於是就在桌子上貼了一張紙條“飯做好了,放在...”小明回家後看到紙條如願吃到媽媽做的飯菜,那麼,如果將小明媽媽和小明作為兩個執行緒,那麼這張紙條就是這兩個執行緒間通訊的共享變數,通過讀寫共享變數實現兩個執行緒間協作;

還有一種方式就是,媽媽的手機還有電,媽媽在趕去坐公交的路上給小明打了個電話,這種方式就是通知機制來完成協作。同樣,可以引申到執行緒間通訊機制。

通過上面這個例子,應該有些認識。在併發程式設計中主要需要解決兩個問題:1. 執行緒之間如何通訊;2.執行緒之間如何完成同步(這裡的執行緒指的是併發執行的活動實體)。通訊是指執行緒之間以何種機制來交換資訊,主要有兩種:共享記憶體和訊息傳遞。這裡,可以分別類比上面的兩個舉例。java記憶體模型是共享記憶體的併發模型,執行緒之間主要通過讀-寫共享變數來完成隱式通訊。如果程式設計師不能理解Java的共享記憶體模型在編寫併發程式時一定會遇到各種各樣關於記憶體可見性的問題。

1.哪些是共享變數

在java程式中所有例項域,靜態域和陣列元素都是放在堆記憶體中(所有執行緒均可訪問到,是可以共享的),而區域性變數,方法定義引數和異常處理器引數不會線上程間共享。共享資料會出現執行緒安全的問題,而非共享資料不會出現執行緒安全的問題。關於JVM執行時記憶體區域在後面的文章會講到。

2.JMM抽象結構模型

我們知道CPU的處理速度和主存的讀寫速度不是一個量級的,為了平衡這種巨大的差距,每個CPU都會有快取。因此,共享變數會先放在主存中,每個執行緒都有屬於自己的工作記憶體,並且會把位於主存中的共享變數拷貝到自己的工作記憶體,之後的讀寫操作均使用位於工作記憶體的變數副本,並在某個時刻將工作記憶體的變數副本寫回到主存中去。JMM就從抽象層次定義了這種方式,並且JMM決定了一個執行緒對共享變數的寫入何時對其他執行緒是可見的。

JMM記憶體模型的抽象結構示意圖

如圖為JMM抽象示意圖,執行緒A和執行緒B之間要完成通訊的話,要經歷如下兩步:

  1. 執行緒A從主記憶體中將共享變數讀入執行緒A的工作記憶體後並進行操作,之後將資料重新寫回到主記憶體中;
  2. 執行緒B從主存中讀取最新的共享變數

從橫向去看看,執行緒A和執行緒B就好像通過共享變數在進行隱式通訊。這其中有很有意思的問題,如果執行緒A更新後資料並沒有及時寫回到主存,而此時執行緒B讀到的是過期的資料,這就出現了“髒讀”現象。可以通過同步機制(控制不同執行緒間操作發生的相對順序)來解決或者通過volatile關鍵字使得每次volatile變數都能夠強制重新整理到主存,從而對每個執行緒都是可見的。

3. 重排序

一個好的記憶體模型實際上會放鬆對處理器和編譯器規則的束縛,也就是說軟體技術和硬體技術都為同一個目標而進行奮鬥:在不改變程式執行結果的前提下,儘可能提高並行度。JMM對底層儘量減少約束,使其能夠發揮自身優勢。因此,在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排序。一般重排序可以分為如下三種:

從原始碼到最終執行的指令序列的示意圖

  1. 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序;
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序;
  3. 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行的。

如圖,1屬於編譯器重排序,而2和3統稱為處理器重排序。這些重排序會導致執行緒安全的問題,一個很經典的例子就是DCL問題,這個在以後的文章中會具體去聊。針對編譯器重排序,JMM的編譯器重排序規則會禁止一些特定型別的編譯器重排序針對處理器重排序,編譯器在生成指令序列的時候會通過插入記憶體屏障指令來禁止某些特殊的處理器重排序

那麼什麼情況下,不能進行重排序了?下面就來說說資料依賴性。有如下程式碼:

double pi = 3.14 //A

double r = 1.0 //B

double area = pi * r * r //C

這是一個計算圓面積的程式碼,由於A,B之間沒有任何關係,對最終結果也不會存在關係,它們之間執行順序可以重排序。因此可以執行順序可以是A->B->C或者B->A->C執行最終結果都是3.14,即A和B之間沒有資料依賴性。具體的定義為:如果兩個操作訪問同一個變數,且這兩個操作有一個為寫操作,此時這兩個操作就存在資料依賴性這裡就存在三種情況:1. 讀後寫;2.寫後寫;3. 寫後讀,者三種操作都是存在資料依賴性的,如果重排序會對最終執行結果會存在影響。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序

另外,還有一個比較有意思的就是as-if-serial語義。

as-if-serial

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提供並行度),(單執行緒)程式的執行結果不能被改變。編譯器,runtime和處理器都必須遵守as-if-serial語義。as-if-serial語義把單執行緒程式保護了起來,遵守as-if-serial語義的編譯器,runtime和處理器共同為編寫單執行緒程式的程式設計師建立了一個幻覺:單執行緒程式是按程式的順序來執行的。比如上面計算圓面積的程式碼,在單執行緒中,會讓人感覺程式碼是一行一行順序執行上,實際上A,B兩行不存在資料依賴性可能會進行重排序,即A,B不是順序執行的。as-if-serial語義使程式設計師不必擔心單執行緒中重排序的問題干擾他們,也無需擔心記憶體可見性問題。

4. happens-before規則

上面的內容講述了重排序原則,一會是編譯器重排序一會是處理器重排序,如果讓程式設計師再去了解這些底層的實現以及具體規則,那麼程式設計師的負擔就太重了,嚴重影響了併發程式設計的效率。因此,JMM為程式設計師在上層提供了六條規則,這樣我們就可以根據規則去推論跨執行緒的記憶體可見性問題,而不用再去理解底層重排序的規則。下面以兩個方面來說。

4.1 happens-before定義

happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有興趣的可以google一下。JSR-133使用happens-before的概念來指定兩個操作之間的執行順序。由於這兩個操作可以在一個執行緒之內,也可以是在不同執行緒之間。因此,JMM可以通過happens-before關係向程式設計師提供跨執行緒的記憶體可見性保證(如果A執行緒的寫操作a與B執行緒的讀操作b之間存在happens-before關係,儘管a操作和b操作在不同的執行緒中執行,但JMM向程式設計師保證a操作將對b操作可見)。具體的定義為:

1)如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

上面的1)是JMM對程式設計師的承諾。從程式設計師的角度來說,可以這樣理解happens-before關係:如果A happens-before B,那麼Java記憶體模型將向程式設計師保證——A操作的結果將對B可見,且A的執行順序排在B之前。注意,這只是Java記憶體模型向程式設計師做出的保證!

上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。JMM這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是程式執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關係本質上和as-if-serial語義是一回事。

下面來比較一下as-if-serial和happens-before:

as-if-serial VS happens-before

  1. as-if-serial語義保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結果不被改變。
  2. as-if-serial語義給編寫單執行緒程式的程式設計師創造了一個幻境:單執行緒程式是按程式的順序來執行的。happens-before關係給編寫正確同步的多執行緒程式的程式設計師創造了一個幻境:正確同步的多執行緒程式是按happens-before指定的順序來執行的。
  3. as-if-serial語義和happens-before這麼做的目的,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。

4.2 具體規則

具體的一共有六項規則:

  1. 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
  2. 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  3. volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  4. 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  5. start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。
  6. join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
  7. 程式中斷規則:對執行緒interrupted()方法的呼叫先行於被中斷執行緒的程式碼檢測到中斷時間的發生。
  8. 物件finalize規則:一個物件的初始化完成(建構函式執行結束)先行於發生它的finalize()方法的開始。

下面以一個具體的例子來講下如何使用這些規則進行推論

依舊以上面計算圓面積的進行描述。利用程式順序規則(規則1)存在三個happens-before關係:1. A happens-before B;2. B happens-before C;3. A happens-before C。這裡的第三個關係是利用傳遞性進行推論的。A happens-before B,定義1要求A執行結果對B可見,並且A操作的執行順序在B操作之前,但與此同時利用定義中的第二條,A,B操作彼此不存在資料依賴性,兩個操作的執行順序對最終結果都不會產生影響,在不改變最終結果的前提下,允許A,B兩個操作重排序,即happens-before關係並不代表了最終的執行順序。

5. 總結

上面已經聊了關於JMM的兩個方面:1. JMM的抽象結構(主記憶體和執行緒工作記憶體);2. 重排序以及happens-before規則。接下來,我們來做一個總結。從兩個方面進行考慮。1. 如果讓我們設計JMM應該從哪些方面考慮,也就是說JMM承擔哪些功能;2. happens-before與JMM的關係;3. 由於JMM,多執行緒情況下可能會出現哪些問題?

5.1 JMM的設計

JMM層級圖

JMM是語言級的記憶體模型,在我的理解中JMM處於中間層,包含了兩個方面:(1)記憶體模型;(2)重排序以及happens-before規則。同時,為了禁止特定型別的重排序會對編譯器和處理器指令序列加以控制。而上層會有基於JMM的關鍵字和J.U.C包下的一些具體類用來方便程式設計師能夠迅速高效率的進行併發程式設計。站在JMM設計者的角度,在設計JMM時需要考慮兩個關鍵因素:

  1. 程式設計師對記憶體模型的使用 程式設計師希望記憶體模型易於理解、易於程式設計。程式設計師希望基於一個強記憶體模型來編寫程式碼。
  2. 編譯器和處理器對記憶體模型的實現 編譯器和處理器希望記憶體模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高效能。編譯器和處理器希望實現一個弱記憶體模型。

另外還要一個特別有意思的事情就是關於重排序問題,更簡單的說,重排序可以分為兩類:

  1. 會改變程式執行結果的重排序。
  2. 不會改變程式執行結果的重排序。

JMM對這兩種不同性質的重排序,採取了不同的策略,如下。

  1. 對於會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
  2. 對於不會改變程式執行結果的重排序,JMM對編譯器和處理器不做要求(JMM允許這種 重排序)

JMM的設計圖為:

JMM設計示意圖

從圖可以看出:

  1. JMM向程式設計師提供的happens-before規則能滿足程式設計師的需求。JMM的happens-before規則不但簡單易懂,而且也向程式設計師提供了足夠強的記憶體可見性保證(有些記憶體可見性保證其實並不一定真實存在,比如上面的A happens-before B)。
  2. JMM對編譯器和處理器的束縛已經儘可能少。從上面的分析可以看出,JMM其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。例如,如果編譯器經過細緻的分析後,認定一個鎖只會被單個執行緒訪問,那麼這個鎖可以被消除。再如,如果編譯器經過細緻的分析後,認定一個volatile變數只會被單個執行緒訪問,那麼編譯器可以把這個volatile變數當作一個普通變數來對待。這些優化既不會改變程式的執行結果,又能提高程式的執行效率。

5.2 happens-before與JMM的關係

happens-before與JMM的關係

一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程式設計師來說,happens-before規則簡單易懂,它避免Java程式設計師為了理解JMM提供的記憶體可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法

5.3 今後可能需要關注的問題

從上面記憶體抽象結構來說,可能出在資料“髒讀”的現象,這就是資料可見性的問題,另外,重排序在多執行緒中不注意的話也容易存在一些問題,比如一個很經典的問題就是DCL(雙重檢驗鎖),這就是需要禁止重排序,另外,在多執行緒下原子操作例如i++不加以注意的也容易出現執行緒安全的問題。但總的來說,在多執行緒開發時需要從原子性,有序性,可見性三個方面進行考慮。J.U.C包下的併發工具類和併發容器也是需要花時間去掌握的,這些東西在以後得文章中多會一一進行討論。

參考文獻

《java併發程式設計的藝術》

相關文章