寫在前面
上一篇文章併發 Bug 之源有三,請睜大眼睛看清它們 談到了可見性/原子性/有序性
三個問題,這些問題通常違揹我們的直覺和思考模式,也就導致了很多併發 Bug
- 為了解決 CPU,記憶體,IO 的短板,增加了快取,但這導致了可見性問題
- 編譯器/處理器
擅自
優化 ( Java程式碼在編譯後會變成 Java 位元組碼, 位元組碼被類載入器載入到 JVM 裡, JVM 執行位元組碼, 最終需要轉化為彙編指令在 CPU 上執行) ,導致有序性問題
初衷是好的,但引發了新問題,最有效的辦法就禁止快取和編譯優化,問題雖然能解決,但「又回到最初的起點,呆呆地站在鏡子前」是很尷尬的,我們程式的效能就堪憂了.
解決方案
- 作為我們程式猿不想寫出 bug 影響 KPI,所以希望記憶體模型易於理解、易於程式設計。這就需要基於一個強記憶體模型來編寫程式碼
- 作為編譯器和處理器不想讓外人說它處理速度很慢,所以希望記憶體模型對他們束縛越少越好,可以由他們擅自優化,這就需要基於一個弱記憶體模型
俗話說:「沒有什麼事是開會解決不了的,如果有,那就再開一次」?
JSR-133 的專家們就有了新想法,既然不能完全禁止快取和編譯優化,那就按需禁用快取和編譯優化,按需就是要加一些約束,約束中就包括了上一篇文章簡單提到過的 volatile,synchronized,final 三個關鍵字,同時還有你可能聽過的 Happens-Before 原則(包含可見性和有序性的約束),Happens-before 規則也是本章的主要內容
為了滿足二者的強烈需求,照顧到雙方的情緒,於是乎: JMM 就對程式猿說了一個善意的謊言: 「會嚴格遵守 Happpen-Befores 規則,不會重排序」讓程式猿放心,私下卻有自己的策略:
- 對於會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
- 對於不會改變程式執行結果的重排序, JMM對編譯器和處理器不做要求 (JMM允許這種重排序)。
我們來用個圖說明一下:
這就是那個善意的謊言,雖是謊言,但還是照顧到了程式猿的利益,所以我們只需要瞭解 happens-before 規則就能得到保證 (圖畫了好久,不知道是否說明了謊言的所在?,歡迎留言)
Happens-before
Happens-before 規則主要用來約束兩個操作,兩個操作之間具有 happens-before 關係, 並不意味著前一個操作必須要在後一個操作之前執行,happens-before 僅僅要求前一個操作(執行的結果)對後一個操作可見, (the first is visible to and ordered before the second)
說了這麼多,先來看一小段程式碼帶你逐步走進 Happen-Befores 原則,看看是怎樣用該原則解決 可見性 和 有序性 的問題:
class ReorderExample {
int x = 0;
boolean flag = false;
public void writer() {
x = 42; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
System.out.println(x); //4
}
}
}
假設 A 執行緒執行 writer 方法,B 執行緒執行 reader 方法,列印出來的 x 可能會是 0,上一篇文章說明過: 因為程式碼 1 和 2 沒有資料依賴關係,所以可能被重排序
flag = true; //2
x = 42; //1
所以,執行緒 A 將 flag = true
寫入但沒有為 x 重新賦值時,執行緒 B 可能就已經列印了 x 是 0
那麼為 flag 加上 volatile 關鍵字試一下:
volatile boolean flag = false;
即便加上了 volatile 關鍵字,這個問題在 java1.5 之前還是沒有解決,但 java1.5 和其之後的版本對 volatile 語義做了增強,問題得以解決,這就離不開 Happens-before 規則的約束了,總共有 6 個規則,且看
程式順序性規則
一個執行緒中的每個操作, happens-before 於該執行緒中的任意後續操作
第一感覺這個原則是一個在理想狀態下的"廢話",並且和上面提到的會出現重排序的情況是矛盾的,注意這裡是一個執行緒中的操作,其實隱含了「as-if-serial」語義: 說白了就是隻要執行結果不被改變,無論怎麼"排序",都是對的
這個規則是一個基礎規則,happens-before 是多執行緒的規則,所以要和其他規則約束在一起才能體現出它的順序性,彆著急,繼續向下看
volatile變數規則
對一個 volatile 域的寫, happens-before 於任意後續對這個 volatile 域的讀
我將上面的程式新增兩行程式碼作說明:
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
}
}
}
這裡涉及到了 volatile 的記憶體增強語義,先來看個表格:
能否重排序 | 第二個操作 | 第二個操作 | 第二個操作 |
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile 讀 | volatile 寫 |
普通讀/寫 | - | - | NO |
volatile 讀 | NO | NO | NO |
volatile 寫 | - | NO | NO |
從這個表格 最後一列 可以看出:
如果第二個操作為 volatile 寫,不管第一個操作是什麼,都不能重排序,這就確保了 volatile 寫之前的操作不會被重排序到 volatile 寫之後
拿上面的程式碼來說,程式碼 1 和 2 不會被重排序到程式碼 3 的後面,但程式碼 1 和 2 可能被重排序 (沒有依賴也不會影響到執行結果),說到這裡和 程式順序性規則是不是就已經關聯起來了呢?
從這個表格的 倒數第二行 可以看出:
如果第一個操作為 volatile 讀,不管第二個操作是什麼,都不能重排序,這確保了 volatile 讀之後的操作不會被重排序到 volatile 讀之前
拿上面的程式碼來說,程式碼 4 是讀取 volatile 變數,程式碼 5 和 6 不會被重排序到程式碼 4 之前
volatile 記憶體語義的實現是應用到了 「記憶體屏障」,因為這完全夠單獨寫一章的內容,這裡為了不掩蓋主角 Happens-before 的光環,保持理解 Happens-before 的連續性,先不做過多說明
到這裡,看這個規則,貌似也沒解決啥問題,因為它還要聯合第三個規則才起作用
傳遞性規則
如果 A happens-before B, 且 B happens-before C, 那麼 A happens-before C
直接上圖說明一下上面的例子
從上圖可以看出
-
x =42
和y = 50
Happens-beforeflag = true
, 這是規則 1 - 寫變數(程式碼 3)
flag=true
Happens-before 讀變數(程式碼 4)if(flag)
,這是規則 2
根據規則 3傳遞性規則,x =42
Happens-before 讀變數 if(flag)
謎案要揭曉了: 如果執行緒 B 讀到了 flag 是 true,那麼x =42
和y = 50
對執行緒 B 就一定可見了,這就是 Java1.5 的增強 (之前版本是可以普通變數寫和 volatile 變數寫的重排序的)
通常上面三個規則是一種聯合約束,到這裡你懂了嗎?規則還沒完,繼續看
監視器鎖規則
對一個鎖的解鎖 happens-before 於隨後對這個鎖的加鎖
這個規則我覺得你應該最熟悉了,就是解釋 synchronized 關鍵字的,來看
public class SynchronizedExample {
private int x = 0;
public void synBlock(){
// 1.加鎖
synchronized (SynchronizedExample.class){
x = 1; // 對x賦值
}
// 3.解鎖
}
// 1.加鎖
public synchronized void synMethod(){
x = 2; // 對x賦值
}
// 3. 解鎖
}
先獲取鎖的執行緒,對 x 賦值之後釋放鎖,另外一個再獲取鎖,一定能看到對 x 賦值的改動,就是這麼簡單,請小夥伴用下面命令檢視上面程式,看同步塊和同步方法被轉換成彙編指令有何不同?
javap -c -v SynchronizedExample
這和 synchronized 的語義相關,小夥伴可以先自行了解一下,鎖的內容時會做詳細說明
start()規則
如果執行緒 A 執行操作 ThreadB.start() (啟動執行緒B), 那麼 A 執行緒的 ThreadB.start() 操作 happens-before 於執行緒 B 中的任意操作,也就是說,主執行緒 A 啟動子執行緒 B 後,子執行緒 B 能看到主執行緒在啟動子執行緒 B 前的操作,看個程式就秒懂了
public class StartExample {
private int x = 0;
private int y = 1;
private boolean flag = false;
public static void main(String[] args) throws InterruptedException {
StartExample startExample = new StartExample();
Thread thread1 = new Thread(startExample::writer, "執行緒1");
startExample.x = 10;
startExample.y = 20;
startExample.flag = true;
thread1.start();
System.out.println("主執行緒結束");
}
public void writer(){
System.out.println("x:" + x );
System.out.println("y:" + y );
System.out.println("flag:" + flag );
}
}
執行結果:
主執行緒結束
x:10
y:20
flag:true
Process finished with exit code 0
執行緒 1 看到了主執行緒呼叫 thread1.start() 之前的所有賦值結果,這裡沒有列印「主執行緒結束」,你知道為什麼嗎?這個守護執行緒知識有關係
join()規則
如果執行緒 A 執行操作 ThreadB.join() 併成功返回, 那麼執行緒 B 中的任意操作 happens-before 於執行緒 A 從 ThreadB.join() 操作成功返回,和 start 規則剛好相反,主執行緒 A 等待子執行緒 B 完成,當子執行緒 B 完成後,主執行緒能夠看到子執行緒 B 的賦值操作,將程式做個小改動,你也會秒懂的
public class JoinExample {
private int x = 0;
private int y = 1;
private boolean flag = false;
public static void main(String[] args) throws InterruptedException {
JoinExample joinExample = new JoinExample();
Thread thread1 = new Thread(joinExample::writer, "執行緒1");
thread1.start();
thread1.join();
System.out.println("x:" + joinExample.x );
System.out.println("y:" + joinExample.y );
System.out.println("flag:" + joinExample.flag );
System.out.println("主執行緒結束");
}
public void writer(){
this.x = 100;
this.y = 200;
this.flag = true;
}
}
執行結果:
x:100
y:200
flag:true
主執行緒結束
Process finished with exit code 0
「主執行緒結束」這幾個字列印出來嘍,依舊和執行緒何時退出有關係
總結
- Happens-before 重點是解決前一個操作結果對後一個操作可見,相信到這裡,你已經對 Happens-before 規則有所瞭解,這些規則解決了多執行緒程式設計的可見性與有序性問題,但還沒有完全解決原子性問題(除了 synchronized)
- start 和 join 規則也是解決主執行緒與子執行緒通訊的方式之一
- 從記憶體語義的角度來說, volatile 的
寫-讀
與鎖的釋放-獲取
有相同的記憶體效果;volatile 寫和鎖的釋放有相同的記憶體語義; volatile 讀與鎖的獲取有相同的記憶體語義,⚠️⚠️⚠️(敲黑板了) volatile 解決的是可見性問題,synchronized 解決的是原子性問題,這絕對不是一回事,後續文章也會說明
附加說明
- 訪問個人部落格 https://dayarch.top/ 提前發現更多精彩
- 多執行緒系列文章整體會按照我的大綱節奏來寫,但是如果大家有什麼疑問,也歡迎到我單獨建立的多執行緒系列問題留言彙總 文章中留言,我會統一回復,如果共通疑問很多,我會插入相關章節單獨做說明
- 併發文章的相關程式碼,我也會同步上傳到程式碼庫,公眾號回覆「demo」,點選連結,找到concurrency 子專案即可
- 如果文章對你有幫助,煩請小夥伴轉發分享給更多朋友,我們一起進步
靈魂追問
- 同步塊和同步方法在編譯成 CPU 指令後有什麼不同?
- 執行緒有 Daemon(守護執行緒)和非 Daemon 執行緒,你知道執行緒的退出策略嗎?
- 關於 Happens-before 你還有哪些疑惑呢?
提高效率工具
MarkDown 表格生成器
本文的好多表格是從官網貼上的,如何將其直接轉換成 MD table 呢?那麼 https://www.tablesgenerator.c... 就可以幫到你了,無論是生成 MD table,還是貼上內容生成 table 和內容都是極好的,當然了不止 MD table,自己發現吧,更多工具,公眾號回覆 「工具」獲得
推薦閱讀
- 每天用SpringBoot,還不懂RESTful API返回統一資料格式是怎麼實現的?
- 雙親委派模型:大廠高頻面試題,輕鬆搞定
- 面試還不知道BeanFactory和ApplicationContext的區別?
- 如何設計好的RESTful API
- 紅黑樹,超強動靜圖詳解,簡單易懂
歡迎持續關注公眾號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總 | 回覆「工具」
- 面試問題分析與解答
- 技術資料領取 | 回覆「資料」
以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本著將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......