Java記憶體模型相關原則詳解
- 2019 年 11 月 6 日
- 筆記
在《Java記憶體模型(JMM)詳解》一文中我們已經講到了Java記憶體模型的基本結構以及相關操作和規則。而Java記憶體模型又是圍繞著在並發過程中如何處理原子性、可見性以及有序性這三個特徵來構建的。本篇文章就帶大家了解一下相關概念、原則等內容。
原子性
原子性即一個操作或一系列是不可中斷的。即使是在多個執行緒的情況下,操作一旦開始,就不會被其他執行緒干擾。
比如,對於一個靜態變數int x兩條執行緒同時對其賦值,執行緒A賦值為1,而執行緒B賦值為2,不管執行緒如何運行,最終x的值要麼是1,要麼是2,執行緒A和執行緒B間的操作是沒有干擾的,這就是原子性操作,不可被中斷的。
Java記憶體模型對以下操作保證其原子性:read,load,assign,use,store,write。我們可以大致認為基本數據類型的訪問讀寫是具備原子性的(前面也提到了long和double類型的「半個變數」情況,不過幾乎不會發生)。
從Java記憶體模型底層來看有上面的原子性操作,但針對用戶來說,也就是我們編寫Java的程式,如果需要更大範圍的原子性保障,就需要同步關鍵字——synchronized來保障了。也就是說synchronized中的操作也具有原子性。
可見性
可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。
Java記憶體模型是通過變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體刷新變數值,將主記憶體作為傳遞媒介。可回顧一下上篇文章的圖。
無論普通變數還是volatile變數都是如此,只不過volatile變數保證新值能夠立馬同步到主記憶體,使用時也立即從主記憶體刷新,保證了多執行緒操作時變數的可見性。而普通變數不能夠保證。
除了volatile,synchronized和final也能夠實現可見性。
synchronized實現的可見性是通過「對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中」來保證的。
主要有兩個原則:執行緒解鎖前,必須把共享變數的最新值刷新到主記憶體中;執行緒加鎖時,將清空工作記憶體中共享變數的值,從而使用共享變數時需要從主記憶體中重新讀取最新的值。
final的可見性是指:被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把「this」的引用傳遞出去,那在其他執行緒中就能看見final的值。
有序性
在Java記憶體模型中有序性可歸納為這樣一句話:如果在本執行緒內觀察,所有操作都是有序的,如果在一個執行緒中觀察另一個執行緒,所有操作都是無序的。
有序性是指對於單執行緒的執行程式碼,執行是按順序依次進行的。但在多執行緒環境中,則可能出現亂序現象,因為在編譯過程會出現「指令重排」,重排後的指令與原指令的順序未必一致。
因此,上面歸納的前半句指的是執行緒內保證串列語義執行,後半句則指指「令重排現」象和「工作記憶體與主記憶體同步延遲」現象。
同樣,Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性。
指令重排
電腦執行指令經過編譯之後形成指令序列。一般情況,指令序列是會輸出確定的結果,且每一次的執行都有確定的結果。
CPU和編譯器為了提升程式執行的效率,會按照一定的規則允許進行指令優化。但程式碼邏輯之間是存在一定的先後順序,並發執行時按照不同的執行邏輯會得到不同的結果。
- 編譯器優化重排序:編譯器在不改變單執行緒程式語義的前提下,重新安排語句執行順序。
- 指令級並行重排序:處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應及其的執行順序。
- 記憶體系統的重排序:處理器使用快取和讀/寫緩衝區,使得載入和存儲操作看上去可能是亂序執行。
舉個例來說明一下多執行緒中可能出現的重排現象:
class ReOrderDemo { int a = 0; boolean flag = false; public void write() { a = 1; //1 flag = true; //2 } public void read() { if (flag) { //3 int i = a * a; //4 …… } } }
在上面的程式碼中,單執行緒執行時,read方法能夠獲得flag的值進行判斷,獲得預期結果。但在多執行緒的情況下就可能出現不同的結果。
比如,當執行緒A進行write操作時,由於指令重排,write方法中的程式碼執行順序可能會變成下面這樣:
flag = true; //2 a = 1; //1
也就是說可能會先對flag賦值,然後再對a賦值。這在單執行緒中並不影響最終輸出的結果。
但如果與此同時,B執行緒在調用read方法,那麼就有可能出現flag為true但a還是0,這時進入第4步操作的結果就為0,而不是預期的1了。
請記住,指令重排只會保證單執行緒中串列語義執行的一致性,不會關心多執行緒間語義的一致性。這也是為什麼在寫單例模式時需要考慮添加volatile關鍵詞來修飾,就是為了防止指令重排導致的問題。
JMM提供的解決方案
在了解了原子性、可見性以及有序性問題後,看看JMM是提供了什麼機制來保證這些特性的。
原子性問題,除了JVM自身提供的對基本數據類型讀寫操作的原子性外,對於方法級別或者程式碼塊級別的原子性操作,可以使用synchronized關鍵字或者重入鎖(ReentrantLock)保證程式執行的原子性。
而工作記憶體與主記憶體同步延遲現象導致的可見性問題,可以使用synchronized關鍵字或者volatile關鍵字解決。它們都可以使一個執行緒修改後的變數立即對其他執行緒可見。
對於指令重排導致的可見性問題和有序性問題,則可以利用volatile關鍵字解決。volatile的另一個作用就是禁止重排序優化。
除了靠sychronized和volatile關鍵字之外,JMM內部還定義一套happens-before(先行發生)原則來保證多執行緒環境下兩個操作間的原子性、可見性以及有序性。
先行發生原則
如果僅靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫並發程式會十分麻煩。為此在Java記憶體模型中,還提供了happens-before原則來輔助保證程式執行的原子性、可見性以及有序性的問題。該原則是判斷數據是否存在競爭、執行緒是否安全的依據。
happens-before規則:
- 程式次序規則:在一個執行緒內,程式前面的操作先於後面的操作。
- 監視器鎖規則:一個unlock操作先於後面對同一個鎖的lock操作發生。
- volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,也就是說讀取的值肯定是最新的。
- 執行緒啟動規則:Thread對象的start()方法調用先行發生於此執行緒的每一個動作。
- 執行緒加入規則:Thread對象的結束先行發生於join()方法返回。
- 執行緒中斷規則:對執行緒interrupt()方法的調用先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過interrupted()方法檢測到是否有中斷髮生。
- 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
- 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A先行發生於操作C。
還拿上面的具體程式碼來進行說明:
class ReOrderDemo { int a = 0; boolean flag = false; public void write() { a = 1; //1 flag = true; //2 } public void read() { if (flag) { //3 int i = a * a; //4 …… } } }
執行緒A調用write()方法,執行緒B調用read()方法,執行緒A先(時間上的先後)於執行緒B啟動,那麼執行緒B讀取到a的值是多少呢?
現在依據8條原則來進行對照。
兩個方法分別由執行緒A和執行緒B調用,不在同一個執行緒中,因此程式次序原則不適用。
沒有write()方法和read()方法都沒有使用同步手段,監視器鎖規則不適用。
沒有使用volatile關鍵字,volatile變數原則不適用。
與執行緒啟動、終止、中斷、對象終結規則、傳遞性都沒有關係,不適用。
因此,執行緒A和執行緒B的啟動時間雖然有先後,但執行緒B執行結果卻是不確定,也是說上述程式碼沒有適合8條原則中的任意一條,所以執行緒B讀取的值自然也是不確定的,換句話說就是執行緒不安全的。
修復這個問題的方式很簡單,要麼給write()方法和read()方法添加同步手段,如synchronized。或者給變數flag添加volatile關鍵字,確保執行緒A修改的值對執行緒B總是可見。
小結
在這篇文章中介紹了Java記憶體模型中一些原則,及其衍生出來保證這些原則的方式和方法。也是為我們下面學習volatile這個面試官最愛問的關鍵字的做好鋪墊。歡迎關注微信公眾號「程式新視界」繼續學習。
原文鏈接:《Java記憶體模型相關原則詳解》
《面試官》系列文章:
- 《JVM之記憶體結構詳解》
- 《面試官,不要再問我「Java GC垃圾回收機制」了》
- 《面試官,Java8 JVM記憶體結構變了,永久代到元空間》
- 《面試官,不要再問我「Java 垃圾收集器」了》
- 《Java虛擬機類載入器及雙親委派機制》
- 《Java記憶體模型(JMM)詳解》
- 《Java記憶體模型相關原則詳解》