一、讓人又愛又恨的指令重排
瞭解過Java併發編程知識的童鞋都知道,Java內存模型是圍繞着併發過程中如何處理原子性、可見性和有序性3個特徵來建立的,其中有序性最爲複雜。
我們習慣性的認爲代碼總是從先到後、依次執行的,這在單線程的時候確實是沒錯的(至少程序是正確的運行的)。但在併發時,有時候給人感覺寫在後面的代碼,比寫在前面的代碼先執行,如同出現了幻覺。這就是鼎鼎大名的指令重排,指令重排是很有必要的,因爲大大提高了cpu處理性能。
然而,指令重排,在提高了性能的同時,也會發生一些意想不到的災難,舉個栗子:
class UnsafeOrderExample {
int x = 0;
boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 這裏 x 會是多少呢?
System.out.print(x);
}
}
}
對於上面的代碼,如果只有一個線程,先執行writer方法,然後再執行reader方法,會得到一個跟預期一致的結果是42。
但假設有兩個線程A、B,兩者同時分別執行writer和reader方法,最終reader會輸出什麼呢?很顯然,答案是不固定的,有可能輸出42,也有可能輸出0;這是因爲writer方法中的代碼有可能發生指令重排,導致v=true有可能會發生在x=42之前。這個類是線程不安全類。
指令重排是必要的,但同時它又帶來了一些麻煩,這可怎麼辦?別急,Java對此指定了Happens-Before規則,既然不能禁止指令重排,那就用規則對指令重排作約束,正所謂“愛,就是剋制”嘛。
二、Happens-Before規則
正如前面所說,雖然jvm和執行系統會對指令進行一定的重排,但也是建立在一些原則上的,並非所有指令都可以隨便改變執行位置。這些原則就是Happens-Before原則。Happens-Before可以直譯爲“先行發生”,但其想表達的更深層的意思是“前面操作的結果對後續操作是可見的”。所以比較正式的說法是:Happens-Before約束了編譯器的優化行爲,雖然允許編譯器優化,但是編譯器優化後一定要遵循Happens-Before原則。
1、程序順序規則
程序順序原則,指的是在一個線程內,按照程序代碼的順序,前面的代碼運行的結果能被後面的代碼可見。(準確的說,應該是控制流順序而不是程序代碼順序,因爲要考慮分支、循環等結構。)
舉個簡單栗子:
int a,b
a=1
b=a+1 //如果指令重排不遵循程序順序原則,則b有可能等於1
如果指令重排不遵循程序順序原則,以上的代碼的b最終有可能等於1,而不是我們期望的2。這個原則就保證了程序語義的正確性,重排指令不允許改掉原來的代碼語義。
2、傳遞性
傳遞性,指的是如果A Happens-Before於B,B Happens-Before於C,則A Happens-Before於C。這個是很好理解的。用白話說就是,如果A的操作結果對B可見,B操作結果對C可見,則A的操作結果對C也是可見的。
3、volatile變量規則
指對一個volatile變量的寫操作,Happens-Before於後續對這個volatile變量的讀操作。如果單單是理解這句話的意思,就是我們熟悉的禁用cpu緩存的意思,使得volatile修飾的變量讀到永遠是最新的值。
如果這個規則跟第二個規則“傳遞性”結合來看,會有什麼效果呢?我們可以通過改一下上面的例程來看看:
class UnsafeExample {
int x = 0;
volatile boolean v = false;//v用volatile修飾
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 這裏 x 會是多少呢?
System.out.print(x);
}
}
}
對比這段代碼跟第一個例子中的代碼,變化的只是成員變量v用了volatile修飾,如果僅僅是用“volatile變量規則”來看,如果同樣是線程A、B同時分別調用writer和reader,得到的不也是有42或者0兩個結果麼?
別慌,如果我們再結合“傳遞性”規則來看:
- x=42 Happens-Before 於寫變量v=true
- 寫變量v=true Happens-Before 於讀變量 v=true
根據“傳遞性”,可以得出x=42 Happens-Before 於讀變量v=true,是不是恍然大悟了呢?由此可以得出最終B線程執行的reader方法輸出的x=42而不是0。而這個結果,是靠“volatile變量規則”+“傳遞性”推導出來的,憑直覺是比較難看出來的。經過這樣一番修改後,這個類就變成了線程安全了。
4、鎖規則
鎖規則,指的是一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。
舉個栗子:
synchronized (this) { // 此處自動加鎖
// x 是共享變量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此處自動解鎖
假設線程A執行完synchronized代碼塊後,x的值變成了12,線程B進入代碼塊時,可以看到線程A對x的修改,也就是能讀到x==12。這個比較容易理解。
5、線程start()規則
指的是主線程A啓動子線程B後,子線程B能看到主線程在啓動線程B前的操作。
舉個栗子:
Thread B = new Thread(()->{
// 主線程調用 B.start() 之前
// 所有對共享變量的修改,此處皆可見
// 此例中,var==77
});
// 此處對共享變量 var 修改
var = 77;
// 主線程啓動子線程
B.start();
此處,線程B能讀到var==77。
6、線程join()規則
這個規則跟上一條規則有點類似,只不過這個規則是跟線程等待相關的。指的是主線程A等待子線程B完成(對B線程join()調用),當子線程B操作完成後,主線程A能看到B線程的操作。
舉個栗子:
Thread B = new Thread(()->{
// 此處對共享變量 var 修改
var = 66;
});
// 例如此處對共享變量修改,
// 則這個修改結果對線程 B 可見
// 主線程啓動子線程
B.start();
B.join()
// 子線程所有對共享變量的修改
// 在主線程調用 B.join() 之後皆可見
// 此例中,var==66
此處,主線程A能看到線程B對共享變量var的操作,也就是可以看到var==66。
7、線程的interrupt()規則
指的是線程A調用線程B的interrupt()方法,Happens-Before 於線程B檢測中斷事件(也就是Thread.interrupted()方法)。這個也很容易理解。
8、finalize()規則
指的是對象的構造函數執行、結束 Happens-Before 於finalize()方法的開始。
三、總結
Happens-Before原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要一句,依靠這個原則,我們可以解決併發環境下兩個操作之間是否存在衝突的所有問題。