【Java併發基礎】Java記憶體模型解決有序性和可見性
前言
解決併發程式設計中的可見性和有序性問題最直接的方法就是禁用CPU快取和編譯器的優化。但是,禁用這兩者又會影響程式效能。於是我們要做的是按需禁用CPU快取和編譯器的優化。
如何按需禁用CPU快取和編譯器的優化就需要提到Java記憶體模型。Java記憶體模型是一個複雜的規範。其中最為重要的便是Happens-Before
規則。下面我們先介紹如何利用Happens-Before
規則解決可見性和有序性問題,然後我們再擴充套件簡單介紹下Java記憶體模型以及我們前篇文章提到的重排序概念。
volatile
在前一篇文章介紹編譯優化帶來的有序性問題時,給出的一個解決辦法時將共享變數使用volatile
volatile關鍵字不是Java語言中的特產,C語言中也有,其最原始的意義就是禁用CPU快取,使得每次訪問均需要直接從記憶體中讀寫。
如果我們宣告一個volatile變數,那麼也就會讓編譯器不能從CPU快取中去讀取這個變數,而必須從記憶體中讀取。
class VolatileExample { int x = 0; // 1 volatile boolean v = false; //2 public void writer() { //3 x = 42; v = true; } public void reader() { //4 if (v == true) { // 這裡 x 會是多少呢? } } }
在這段程式碼中,假設執行緒A執行了3即writer()方法,設定了x=42和v=true。執行緒B執行了4即reader()方法,執行緒B可以看見執行緒A設定的v為true,那麼B讀到的x值會是多少呢?(想一想再點選我)
這要分Java版本來說,在1.5之前,會出現x=0的情況。由於可見性問題,執行緒A修改的x可能儲存在CPU快取中對執行緒B是不可見的,於是執行緒B獲取到的x為0。
在Java1.5之後,執行緒B獲取到的x一定就是42。
這是因為Java記憶體模型對volatile語義進行了增強。增強體現在Java記憶體模型中的Happens-Before規則上。
Happens-Before規則
Happends-Before規則表達的是:前面一個操作的結果對之後操作是可見的,描述的是兩個操作的記憶體可見性。
Happens-Before約束了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器遵循一定的Happens-Before規則進行優化。
Happens-Before規則包括:
程式順序規則
在一個執行緒中,前面的操作Happens-Before於後續的任意操作。
volatile變數規則
對volatile變數的寫操作相對於之後對該volatile變數的讀操作是可見的。(這個語義可等價適用於原子變數)
對volatile變數的寫操作 Happens-Before 對該volatile變數的讀操作傳遞性
如果操作A Happens-Before 操作B並且操作B Happens-Before 操作C, 那麼操作A Happens-Bofore 操作C。
利用程式順序規則、volatile變數規則、傳遞性規則說明例子
根據程式順序規則,在一個執行緒中,之前的操作是Happens-Before後續的操作,所以x=42;
Happens-Before v=true;
;根據volatile變數規則,對volatile變數的寫操作相對於之後對該volatile變數的讀操作是可見的,於是寫變數v=ture;
Happens-Before讀變數v==true;
;根據傳遞性,得出x=42;
Happens-Before讀變數v==true;
於是,最終讀出的x值會是42。
管程中的鎖規則
對同一個鎖的解鎖 Happens-Before 後續對這個鎖的加鎖。
(管程:是一種同步原語,在Java中就是指synchronized。)執行緒啟動規則
執行緒的啟動操作(即Thread.start()) Happens-Before 該執行緒的第一個操作。
主執行緒A啟動子執行緒B,那麼子執行緒B能夠看到執行緒A在啟動B之前的任意操作。Thread B = new Thread(()->{ // 主執行緒呼叫 B.start() 之前 // 所有對共享變數的修改,此處皆可見 // 此例中,var==77 }); // 此處對共享變數 var 修改 var = 77; // 主執行緒啟動子執行緒 B.start();
執行緒結束規則
執行緒的最後一個操作 Happens-Before 它的終止事件。
主執行緒A等待子執行緒B完成(A呼叫B.join())。當B完成之後(主執行緒A中的join()返回),主執行緒A可以看見子執行緒的操作。看到針對的是對共享變數。Thread B = new Thread(()->{ // 此處對共享變數 var 修改 var = 66; }); // 例如此處對共享變數修改, // 則這個修改結果對執行緒 B 可見 // 主執行緒啟動子執行緒 B.start(); B.join() // 子執行緒所有對共享變數的修改 // 在主執行緒呼叫 B.join() 之後皆可見 // 此例中,var==66
中斷規則
執行緒對其他執行緒的中斷操作 Happens-Before被中斷執行緒所收到中斷事件。
一個執行緒在另一個執行緒上呼叫interrupt,必須在被中斷執行緒檢測到interrupt呼叫之前執行。(被中斷執行緒的InterruptedException異常,或者第三個執行緒針對被中斷執行緒的Thread.interrupted或者Thread.isInterrupted呼叫)析構器規則
構造器中的最後一個操作 Happens-Before 析構器的第一個操作
或者說,物件的構造器必須在啟動該物件的析構器之前執行完成。
需要注意,A操作 Happens-Before B操作,但並不意味著A操作必須要在B操作之前執行。
Happens-Before表達的是前一個操作執行後的結果是對後續一個操作是可見的,且前一個操作按順序排在第二個操作之前。
Java記憶體模型的抽象
共享變數可指代儲存與堆記憶體中的例項域、靜態域和陣列元素,共享變數是執行緒間共享的。區域性變數、方法定義引數和異常處理器引數不會線上程之間共享,所以,它們不會有記憶體可見性問題。
Java執行緒之間的通訊由Java記憶體模型(Java Memory Model, JMM)控制,JMM決定了一個執行緒對共享變數的寫入何時對另一個執行緒可見。
從抽象角度看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(main memory)中,每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體儲存了該執行緒以讀/寫共享變數的副本。
本地記憶體是JMM的一個抽象概念,實際並不存在,它主要是指代快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。
Java記憶體模型的抽象示意圖如下:(圖來自程曉明的深入理解Java記憶體模型)
從上圖來看,如果執行緒A和執行緒B要進行通訊,需要進行兩步:
- 執行緒A將本地記憶體A中更新過的共享變數重新整理到主記憶體中
- 執行緒B從主記憶體中去讀取執行緒A更新到主記憶體的共享變數
執行緒A和B的通訊過過了主記憶體,JMM通過控制主記憶體和每個執行緒的本地記憶體之間的互動,來為Java程式設計師提供記憶體可見性的保證。
重排序
重排序分類
在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序處理。加上前面提到的編譯器優化,重排序可以分為三種類型:
- 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。即在單執行緒中,重排序指令後執行的結果與未重排序執行的結果一致,那麼就可以允許這種優化。
- 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器便可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。因為快取可能會改變將寫入變數提交到主記憶體的次序。
as-if-serial屬性:在單執行緒情況下,雖然有可能不是順序執行,但是經過重排序的執行結果要和順序執行的結果一致。 編譯器和處理器需要保證程式能夠遵守as-if-serial屬性。
資料依賴性:如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。編譯器和處理器不能對“存在資料依賴關係的兩個操作”執行重排序。
從Java原始碼到最終執行的指令序列,會經歷下面的三種重排序:(圖來自程曉明的深入理解Java記憶體模型)
第一個屬於編譯器重排序,第二三個屬於處理器重排序。這些重排序都可能會導致奪多執行緒出現記憶體可見性問題。
針對編譯器的重排序,JMM會有編譯器重排序規則禁止特定型別的編譯器重排序,不會禁止所有型別的編譯器重排序。
針對處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障(memory barriers)指令,來禁止特定型別的處理器重排序。
JMM屬於語言級的記憶體模型,它確保在不同的編譯器和處理器平臺上,通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。
記憶體屏障
處理器架構提供了一些特殊的指令(稱為記憶體屏障)用來在需要共享資料時實現儲存協調。JMM使編譯器在適當的位置插入記憶體屏障指令來禁止特定型別的處理器重排序。
記憶體屏障指令可分為下列四類:
屏障型別 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 確保Load1資料的裝載,之前於Load2及所有後續裝載指令的裝載。 |
StoreStore Barriers | Store1;StoreStore;Store2 | 確保Store1資料對其他處理器可見(重新整理到記憶體),之前於Store2及所有後續儲存指令的儲存。 |
LoadStore Barriers | Load1;LoadStore;Store2 | 確保Load1資料裝載,之前於Store2及所有後續的儲存指令重新整理到記憶體。 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 確保Store1資料對其他處理器變得可見(重新整理到記憶體),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有記憶體訪問指令(儲存和裝載指令)完成之後,才執行該屏障之後的記憶體訪問指令。 |
JMM、Happens-Before和重排序規則之間的關係
(圖來自程曉明的深入理解Java記憶體模型)
看圖中的概括,一個Happens-Before規則對應於一個或者多個編譯器和處理器重排序規則。
對Java程式設計師來說,只需要熟悉Happens-Before規則,就可以使程式避免遭受記憶體可見性問題,並且不用為了理解JMM提供的記憶體可見性保證而學習複雜的重排序規則以及這些規則的具體實現。
再談volatile
為了不打亂前面的行文思路,於是就在後面補充關於volatile的知識。
volatile變數是Java語言提供的一種較弱的同步機制,用來確保將變數的更新操作都通知到其他執行緒。將變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數是共享的,不會將該變數上的操作與其他記憶體操作一起重排序,即我們前面所說的保證程式有序性。volatile變數不會被快取在暫存器或者CPU快取中對其他處理器不可見,讀取volatile型別的變數時總會返回最新寫入值,即我們前面說的保證程式可見性。然而,頻繁地訪問 volatile 欄位也會因為不斷地強制重新整理快取而嚴重影響程式的效能。
從記憶體可見性角度來看,寫入volatile變數相當於退出同步程式碼塊,而讀取volatile變數相當於進入同步程式碼塊。然而,並不建議過度依賴volatile變數提供的可見性。如果在程式碼中依賴volatile變數來控制狀態的可見性,通常比使用鎖的程式碼更脆弱也更加難以理解。(下一篇文章將介紹Java併發中的同步機制)
僅當volatile變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用。
volatile變數的正確使用方式包括:確保自身狀態的可見性,確保它們所引用物件的狀態的可見性以及標識一些重要的程式生命週期事件的發生(例如,初始化或者關閉)。
下面的例子是volatile變數的一種典型用法:檢查某個狀態標記以判斷是否退出迴圈。
volatile boolean asleep;
...
while(!asleep)
countSomeSheep();
為了能使這個程式正確執行,alseep必須要為volatile變數。否則,當asleep被另外一個執行緒修改時,執行判斷的執行緒卻發現不了。後面也會講用鎖操作也可以確保asleep更新操作的可見性,但是這將會使程式碼變得複雜。
需要注意,儘管volatile變數經常用於表示某種狀態資訊如某個操作完成、發生中斷或者標記,但是volatile的語義是不足以確保遞增操作(count++)的原子性 ,除非確保只有一個執行緒對變數執行寫操作。後面將要介紹的同步機制中的加鎖機制既可以確保可見性又可以確保原子性,而volatile變數只能確保可見性。
小結
行文思路總體看起來有點亂ε(┬┬﹏┬┬)3,不過這也不是有意為之。本打算是重點介紹Happens-Before規則,然後稍微介紹一點Java記憶體模型。可奈何中途瞥見了一個網友力推程曉明的深入理解Java記憶體模型,於是就去拜讀了一遍。看完發現還是要補充介紹一些東西,於是補著補著就亂了。唉,也怪我這深入淺出介紹知識的能力不夠,各位看官擇其所需看看就好。
參考:
[1]極客時間專欄王寶令《Java併發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2016
[3]程曉明.深入理解Java記憶體模型.https://www.infoq.cn/article/java_memory_mo