Happens-Before原則

一、讓人又愛又恨的指令重排

瞭解過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原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要一句,依靠這個原則,我們可以解決併發環境下兩個操作之間是否存在衝突的所有問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章