寫在前面
在 可見性有序性,Happens-before來搞定 文章中,happens-before 的原則之一: volatile變數規則
對一個 volatile 域的寫, happens-before 於任意後續對這個 volatile 域的讀
按理說了解了這個規則,對 volatile 的使用就已經足夠了,但是面試官可是喜歡刨根問到底的,為了更透徹的瞭解 volatile 的記憶體語義與讀寫語義,為了面試多一些談資進而獲得一些加分項,同時儘早填補前序文章留下的坑,於是乎這篇文章就這樣尷尬的誕生了
happens-before 之 volatile 變數規則
下面的表格你還記得嗎?(是的,你記得?)
能否重排序 | 第二個操作 | 第二個操作 | 第二個操作 |
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile 讀 | volatile 寫 |
普通讀/寫 | - | - | NO |
volatile 讀 | NO | NO | NO |
volatile 寫 | - | NO | NO |
上面的表格是 JMM 針對編譯器定製的 volatile 重排序的規則,那 JMM 是怎樣禁止重排序的呢?答案是記憶體屏障
記憶體屏障 (Memory Barriers / Fences)
無論你聽過這個名詞與否都沒關係,很簡單,且看
為了實現 volatile 的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序
這句話有點抽象,試著想象記憶體屏障是一面高牆,如果兩個變數之間有這個屏障,那麼他們就不能互換位置(重排序)了,變數有讀(Load)有寫(Store),操作有前有後,JMM 就將記憶體屏障插入策略分為 4 種:
- 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障
- 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障
- 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障
- 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障
1 和 2 用圖形描述以及對應表格規則就是下面這個樣子了:
3 和 4 用圖形描述以及對應表格規則就是下面這個樣子了:
其實圖形也是表格內容的體現,只不過告訴大家記憶體屏障是如何禁止指令重排序的,所以大家只要牢記表格內容即可
一段程式的讀寫通常不會像上面兩種情況這樣簡單,這些屏障組合起來如何使用呢?其實一點都不難,我們只需要將這些指令帶入到文章開頭的表格中,然後再按照程式順序拼接指令就好了
來看一小段程式:
public class VolatileBarrierExample {
private int a;
private volatile int v1 = 1;
private volatile int v2 = 2;
void readAndWrite(){
int i = v1; //第一個volatile讀
int j = v2; //第二個volatile讀
a = i + j; //普通寫
v1 = i + 1; //第一個volatile寫
v2 = j * 2; //第二個volatile寫
}
}
將屏障指令帶入到程式就是這個樣子:
我們將上圖分幾個角度來看:
- 彩色是將屏障指令帶入到程式中生成的全部內容,也就是編譯器生成的「最穩妥」的方案
- 顯然有很多屏障是重複多餘的,右側虛線框指向的屏障是可以被「優化」刪除掉的屏障
到這裡你應該瞭解了 volatile 是如何通過記憶體屏障保證程式不被"擅自"排序的,那 volatile 是如何保證可見性的呢?
volatile 寫-讀的記憶體語義
回顧一下之前文章內容中的程式,假定執行緒 A 先執行 writer 方法,隨後執行緒 B 執行 reader 方法,:
public class ReorderExample {
private int x = 0;
private int y = 1;
private volatile boolean flag = false;
public void writer(){
x = 42; //1
y = 50; //2
flag = true; //3
}
public void reader(){
if (flag){ //4
System.out.println("x:" + x); //5
System.out.println("y:" + y); //6
}
}
}
到這裡你是否還記得之前說過的 JMM,是的,你還記得?,當執行緒 A 執行 writer 方法時,且看下圖:
執行緒 A 將本地記憶體更改的變數寫回到主記憶體中
volatile 讀的記憶體語義:
當讀一個 volatile 變數時, JMM 會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。
所以當執行緒 B 執行 reader 方法時,圖形結構就變成了這個樣子:
執行緒 B 本地記憶體變數無效,從主記憶體中讀取變數到本地記憶體中,也就得到了執行緒 A 更改後的結果,這就是 volatile 是如何保證可見性的
如果你看過前面的文章你就不難理解上面的兩張圖了,綜合起來說:
- 執行緒 A 寫一個volatile變數, 實質上是執行緒 A 向接下來將要讀這個 volatile 變數的某個執行緒發出了(其對共享變數所做修改的)訊息
- 執行緒 B 讀一個 volatile 變數,實質上是執行緒 B 接收了之前某個執行緒發出的(在寫這個 volatile 變數之前對共享變數所做修改的)訊息。
- 執行緒 A 寫一個 volatile 變數, 隨後執行緒 B 讀這個 volatile 變數, 這個過程實質上是執行緒 A 通過主記憶體向執行緒B 傳送訊息。
到這裡,面試 volatile 時,你應該有一些談資了,同時也對 volatile 的語義有了更深層次的瞭解
彩蛋
之前的文章提到過這樣一句話:
從記憶體語義的角度來說, volatile 的寫-讀
與鎖的釋放-獲取
有相同的記憶體效果;volatile 寫和鎖的釋放有相同的記憶體語義; volatile 讀與鎖的獲取有相同的記憶體語義
記住文中最後兩張圖, 當我們說到 synchronized 的時候,你就會猛的理解這句話的含義了, 感興趣的可以自己先了解 synchronized 的寫-讀語義
接下來我們就聊一聊鎖相關的內容了,敬請期待...
靈魂追問
- 如果 volatile 寫之後直接 return,那還會生成 StoreLoad 指令嗎?
- synchronized 是怎樣逐步被優化的?
提高效率工具
tool.lu
https://tool.lu 是一款整合了非常多功能的線上工具,基本滿足日常開發所需
推薦閱讀
- 這次走進併發的世界,請不要錯過
- 學併發程式設計,透徹理解這三個核心是關鍵
- 併發Bug之源有三,請睜大眼睛看清它們
- 可見性有序性,Happens-before來搞定
- 解決原子性問題?你首先需要的是巨集觀理解
歡迎持續關注公眾號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總 | 回覆「工具」
- 面試問題分析與解答
- 技術資料領取 | 回覆「資料」
以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本著將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......