上一篇文章併發 Bug 之源有三,請睜大眼睛看清它們 談到了可見性/原子性/有序性
三個問題,這些問題一般違背咱們的直覺和思考模式,也就致使了不少併發 Bugjava
擅自
優化 ( Java代碼在編譯後會變成 Java 字節碼, 字節碼被類加載器加載到 JVM 裏, JVM 執行字節碼, 最終須要轉化爲彙編指令在 CPU 上執行) ,致使有序性問題初衷是好的,但引起了新問題,最有效的辦法就禁止緩存和編譯優化,問題雖然能解決,但「又回到最初的起點,呆呆地站在鏡子前」是很尷尬的,咱們程序的性能就堪憂了.面試
俗話說:「沒有什麼事是開會解決不了的,若是有,那就再開一次」😂shell
JSR-133 的專家們就有了新想法,既然不能徹底禁止緩存和編譯優化,那就按需禁用緩存和編譯優化,按需就是要加一些約束,約束中就包括了上一篇文章簡單提到過的 volatile,synchronized,final 三個關鍵字,同時還有你可能聽過的 Happens-Before 原則(包含可見性和有序性的約束),Happens-before 規則也是本章的主要內容編程
爲了知足兩者的強烈需求,照顧到雙方的情緒,因而乎: JMM 就對程序猿說了一個善意的謊話: 「會嚴格遵照 Happpen-Befores 規則,不會重排序」讓程序猿放心,私下卻有本身的策略:緩存
咱們來用個圖說明一下:markdown
這就是那個善意的謊話,雖是謊話,但仍是照顧到了程序猿的利益,因此咱們只須要了解 happens-before 規則就能獲得保證 (圖畫了很久,不知道是否說明了謊話的所在😅,歡迎留言)多線程
Happens-before 規則主要用來約束兩個操做,兩個操做之間具備 happens-before 關係, 並不意味着前一個操做必需要在後一個操做以前執行,happens-before 僅僅要求前一個操做(執行的結果)對後一個操做可見, (the first is visible to and ordered before the second)併發
說了這麼多,先來看一小段代碼帶你逐步走進 Happen-Befores 原則,看看是怎樣用該原則解決 可見性 和 有序性 的問題:app
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 域的寫, 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-before flag = true
, 這是規則 1flag=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 的語義相關,小夥伴能夠先自行了解一下,鎖的內容時會作詳細說明
若是線程 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() 以前的全部賦值結果,這裏沒有打印「主線程結束」,你知道爲何嗎?這個守護線程知識有關係
若是線程 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
複製代碼
「主線程結束」這幾個字打印出來嘍,依舊和線程什麼時候退出有關係
寫-讀
與鎖的釋放-獲取
有相同的內存效果;volatile 寫和鎖的釋放有相同的內存語義; volatile 讀與鎖的獲取有相同的內存語義,⚠️⚠️⚠️(敲黑板了) volatile 解決的是可見性問題,synchronized 解決的是原子性問題,這絕對不是一回事,後續文章也會說明本文的好多表格是從官網粘貼的,如何將其直接轉換成 MD table 呢?那麼 www.tablesgenerator.com/markdown_ta… 就能夠幫到你了,不管是生成 MD table,仍是粘貼內容生成 table 和內容都是極好的,固然了不止 MD table,本身發現吧,更多工具,公衆號回覆 「工具」得到
歡迎持續關注公衆號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總 | 回覆「工具」
- 面試問題分析與解答
- 技術資料領取 | 回覆「資料」
以讀偵探小說思惟輕鬆趣味學習 Java 技術棧相關知識,本着將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......