Java記憶體模型之有序性問題
本部落格系列是學習併發程式設計過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。
併發程式設計系列部落格傳送門
前言
之前的文章中講到,JMM是記憶體模型規範在Java語言中的體現。JMM保證了在多核CPU多執行緒程式設計環境下,對共享變數讀寫的原子性、可見性和有序性。
本文就具體來講講JMM是如何保證共享變數訪問的有序性的。
指令重排
在說有序性之前,我們必須先來聊下指令重排,因為如果沒有指令重拍的話,也就不存在有序性問題了。
指令重排是指編譯器和處理器在不影響程式碼單執行緒執行結果的前提下,對原始碼的指令進行重新排序執行。這種重排序執行是一種優化手段,目的是為了處理器內部的運算單元能儘量被充分利用,提升程式的整體執行效率。
重排序分為以下幾種:
- 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。
通過指令重排的定義可以看出:指令重拍只能保證單執行緒執行下的正確性,在多執行緒環境下,指令重排會帶來一定的問題(一個硬幣具有兩面性,指令重排帶來效能提升的同時也增加了程式設計的複雜性)。下面我們就來展示一個列子,看看指令重排是怎麼影響程式執行結果的。
public class Demo { int value = 1; private boolean started = false; public void startSystem(){ System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis()); value = 2; started = true; System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis()); } public void checkStartes(){ if (started){ //關注點 int var = value+1; System.out.println("system is running, time:"+System.currentTimeMillis()); }else { System.out.println("system is not running, time:"+System.currentTimeMillis()); } } }
對於上面的程式碼,假如我們開啟一個執行緒呼叫startSystem
,再開啟一個執行緒不斷呼叫checkStartes
方法,我們並不能保證程式碼執行到“關注點”處,var變數的值一定是3。因為在startSystem方法中的兩個賦值語句並不存在依賴關係,所以在編譯器進行程式碼編譯時可能進行指令重排。所以真實的執行順序可能是下面這樣的。
started = true;
value = 2;
也就是先執行started = true;
執行完這個語句後,執行緒立馬執行checkStartes方法,此時value值還是1,那麼最後在關注點處的var值就是2,而不是我們想象中的3。
有序性
有序性定義:即程式執行的順序按照程式碼的先後順序執行。
在JMM中,提供了以下三種方式來保證有序性:
- happens-before原則
- synchronized機制
- volatile機制
happens-before原則
happens-before原則是Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到。“影響”包括修改了記憶體中共享變數的值、 傳送了訊息、 呼叫了方法等。
下面是Java記憶體模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。 如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序:
- 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。 準確地說,應該是控制流順序而不是程式程式碼順序,因為要考慮分支、 迴圈等結構。
- 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。 這裡必須強調的是同一個鎖,而“後面”是指時間上的先後順序。
- volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後順序。
- 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
- 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法結束、 Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
- 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
- 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
- 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
這邊舉個列子來幫助理解happens-before原則:
private int value=0;
pubilc void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}
假設兩個執行緒A和B,執行緒A先(在時間上先)呼叫了這個物件的setValue(1),接著執行緒B呼叫getValue方法,那麼B的返回值是多少?
對照著hp原則,上面的操作不滿下面的任何條件:
- 不是同一個執行緒,所以不涉及:程式次序規則;
- 不涉及同步,所以不涉及:管程鎖定規則;
- 沒有volatile關鍵字,所以不涉及:volatile變數規則
- 沒有執行緒的啟動,中斷,終止,所以不涉及:執行緒啟動規則,執行緒終止規則,執行緒中斷規則
- 沒有物件的創建於終結,所以不涉及:物件終結規則
- 更沒有涉及到傳遞性
所以一條規則都不滿足,儘管執行緒A在時間上與執行緒B具有先後順序,但是,卻並不滿足hp原則,也就是有序性並不會保障,所以執行緒B的資料獲取是不安全的!!
時間先後順序與先行發生原則之間基本沒有太大的關係,所以我們衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。只有真正滿足了happens-before原則,才能保障安全。
如果不能滿足happens-before原則,就需要使用下面的synchronized機制和volatile機制機制來保證有序性。
synchronized機制
volatile機制
volatile的底層是使用記憶體屏障來保證有序性的。
記憶體屏障有兩個能力:
- 就像一套柵欄分割前後的程式碼,阻止柵欄前後的沒有資料依賴性的程式碼進行指令重排序,保證程式在一定程度上的有序性。
- 強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,讓快取中相應的資料失效,保證資料的可見性。
簡單總結
特性 | volatile關鍵字 | synchronized關鍵字 | Lock介面 | Atomic變數 |
---|---|---|---|---|
原子性 | 無法保障 | 可以保障 | 可以保障 | 可以保障 |
可見性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 一定程度保障 | 可以保障 | 可以保障 | 無法保障 |
參考
https://cloud.tencent.com/developer/article/1350256
https://www.cnblogs.com/ssl-bl/p/11076232.html
https://baijiahao.baidu.com/s?id=1628346233476376109&wfr=spider&for=pc
https://www.cnblogs.com/noteless/p/10401193.html