[併發理論基礎] 02 | Java如何解決可見性和有序性

[併發理論基礎] 02 | Java內存模型:看Java如何解決可見性和有序性問題

我們已經知道,導致可見性的原因是緩存,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,但是這樣會對程序性能造成很大影響,合理的方案應該是按需禁用緩存以及編譯優化。所以,爲了解決可見性和有序性問題,只需要提供給程序員按需禁用緩存和編譯優化的方法即可。

Java 內存模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來說,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及六項 Happens-Before 規則。

一、使用 volatile 的困惑

volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言裏也有,它最原始的意義就是禁用 CPU 緩存。

例如,我們聲明一個 volatile 變量 volatile int x = 0,它表達的是:告訴編譯器,對這個變量的讀寫,不能使用 CPU 緩存,必須從內存中讀取或者寫入。

例如下面的示例代碼,假設線程 A 執行 writer() 方法,按照 volatile 語義,會把變量 “v=true” 寫入內存;假設線程 B 執行 reader() 方法,同樣按照 volatile 語義,線程 B 會從內存中讀取變量 v,如果線程 B 看到 “v == true” 時,那麼線程 B 看到的變量 x 是多少呢?

直覺上看,應該是 42,那實際應該是多少呢?這個要看 Java 的版本,如果在低於 1.5 版本上運行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上運行,x 就是等於 42。

class VolatileExample {
  int x = 0;
  volatile boolean v = false; //設置v爲 volatile

  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 這裏x會是多少呢?
    }
  }
}

分析一下,爲什麼 1.5 以前的版本會出現 x = 0 的情況呢?因爲變量 x 可能被 CPU 緩存而導致可見性問題。這個問題在 1.5 版本已經被圓滿解決了。Java 內存模型在 1.5 版本對 volatile 語義進行了增強。怎麼增強的呢?答案是一項 Happens-Before 規則。

二、Happens-Before 規則

Happens-Before 並不是說前面一個操作發生在後續操作的前面,它真正要表達的是:前面一個操作的結果對後續操作是可見的。Happens-Before 約束了編譯器的優化行爲,雖允許編譯器優化,但是要求編譯器優化後一定遵守 Happens-Before 規則。

Happens-Before 規則最初是在一篇叫做Time, Clocks, and the Ordering of Events in a Distributed System的論文中提出來的,在這篇論文中,Happens-Before 的語義是一種因果關係。在現實世界裏,如果 A 事件是導致 B 事件的起因,那麼 A 事件一定是先於(Happens-Before)B 事件發生的,這個就是 Happens-Before 語義的現實理解。
Instruction reordering & happens-before relationship in java [duplicate]

The Happens-Before Relation

1. 程序的順序性規則

這條規則是指在一個線程中,按照程序順序,前面的操作 Happens-Before 於後續的任意操作。這還是比較容易理解的,比如剛纔那段示例代碼,按照程序的順序,第 6 行代碼 “x = 42;” Happens-Before 於第 7 行代碼 “v = true;”,這就是規則 1 的內容,也比較符合單線程裏面的思維:程序前面對某個變量的修改一定是對後續操作可見的。

注意這一點不是禁止重排序,而是重排序要符合happens-before規則,在不影響執行結果的情況下,虛擬機可能會對命令重排。
happens-before不代表時間上的先發生,只要程序的執行結果能夠保證consistency就行。

2. volatile 變量規則

這條規則是指對一個 volatile 變量的寫操作, Happens-Before 於後續對這個 volatile 變量的讀操作。這條規則我們要關聯規則 3 來理解。

volatile強制所修飾的變量及它前邊的變量刷新至內存,並且volatile禁止了指令的重排序。

3. 傳遞性

這條規則是指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。

在這裏插入圖片描述
將規則 3 的傳遞性應用到我們的例子中,可以這麼理解:

  1. “x=42” Happens-Before 寫變量 “v=true” ,這是規則 1 的內容;
  2. 寫變量“v=true” Happens-Before 讀變量 “v=true”,這是規則 2 的內容 。

再根據這個傳遞性規則,我們得到結果:“x=42” Happens-Before 讀變量“v=true”。即如果線程 B 讀到了“v=true”,那麼線程 A 設置的“x=42”對線程 B 是可見的。也就是說,線程 B 能看到 “x == 42” 。這就是 1.5 版本對 volatile 語義的增強,這個增強意義重大,1.5 版本的併發工具包(java.util.concurrent)就是靠 volatile 語義來搞定可見性的。

4. 管程中鎖的規則

這條規則是指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。

管程是一種通用的同步原語,在 Java 中指的就是 synchronized,synchronized 是 Java 裏對管程的實現。管程中的鎖在 Java 裏是隱式實現的,例如下面的代碼,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實現的。

synchronized (this) { // 此處自動加鎖
  // x 是共享變量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此處自動解鎖

所以結合規則 4——管程中鎖的規則,可以這樣理解:假設 x 的初始值是 10,線程 A 執行完代碼塊後 x 的值會變成 12(執行完自動釋放鎖),線程 B 進入代碼塊時,能夠看到線程 A 對 x 的寫操作,也就是線程 B 能夠看到 x==12。

5. 線程 start() 規則

這條是關於線程啓動的。它是指主線程 A 啓動子線程 B 後,子線程 B 能夠看到主線程在啓動子線程 B 前的操作。

換句話說就是,如果線程 A 調用線程 B 的 start() 方法(即在線程 A 中啓動線程 B),那麼該 start() 操作 Happens-Before 於線程 B 中的任意操作。具體可參考下面示例代碼。

Thread B = new Thread(()->{
  // 主線程調用 B.start() 之前
  // 所有對共享變量的修改,此處皆可見
  // 此例中,var==77
});
// 此處對共享變量 var 修改
var = 77;
// 主線程啓動子線程
B.start();
6. 線程 join() 規則

這條是關於線程等待的。它是指主線程 A 等待子線程 B 完成(主線程 A 通過調用子線程 B 的 join() 方法實現),當子線程 B 完成後(主線程 A 中 join() 方法返回),主線程能夠看到子線程的操作。當然所謂的“看到”,指的是對共享變量的操作。

換句話說就是,如果在線程 A 中,調用線程 B 的 join() 併成功返回,那麼線程 B 中的任意操作 Happens-Before 於該 join() 操作的返回。具體可參考下面示例代碼。

Thread B = new Thread(()->{
  // 此處對共享變量 var 修改
  var = 66;
});
// 例如此處對共享變量修改,
// 則這個修改結果對線程 B 可見
// 主線程啓動子線程
B.start();
B.join()
// 子線程所有對共享變量的修改
// 在主線程調用 B.join() 之後皆可見
// 此例中,var==66

在 Java 語言裏面,Happens-Before 的語義本質上是一種可見性,A Happens-Before B 意味着 A 事件對 B 事件來說是可見的,無論 A 事件和 B 事件是否發生在同一個線程裏。例如 A 事件發生在線程 1 上,B 事件發生在線程 2 上,Happens-Before 規則保證線程 2 上也能看到 A 事件的發生。

三、被我們忽視的 final

volatile 爲的是禁用緩存以及編譯優化,而final 關鍵字修飾變量時,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優化。

Java 編譯器在 1.5以前的版本的確優化得很努力,但是出現了問題,類似於第一講提到的利用雙重檢查方法創建單例,構造函數的錯誤重排導致線程可能看到 final 變量的值會變化。

在 1.5 以後 Java 內存模型對 final 類型變量的重排進行了約束。現在只要我們提供正確構造函數沒有“逸出”,就不會出問題了。在下面例子中,在構造函數裏面將 this 賦值給了全局變量 global.obj,這就是“逸出”,線程通過 global.obj 讀取 x 是有可能讀到 0 的,因爲有可能通過global.obj 可能訪問到還沒有初始化的this對象,將this賦值給global.obj時,this還沒有初始化完,因此我們一定要避免“逸出”。

final int x;
// 錯誤的構造函數
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此處就是講 this 逸出,
  global.obj = this;
}

思考題

有一個共享變量 abc,在一個線程裏設置了 abc 的值 abc=3,你思考一下,有哪些辦法可以讓其他線程能夠看到abc=3?

  1. 保證共享變量的可見性,使用volatile關鍵字修飾即可
  2. 保證共享變量是private,訪問變量使用set/get方法,使用synchronized對方法加鎖,此種方法不僅保證了可見性,也保證了線程安全
  3. 使用原子變量,例如:AtomicInteger等
  4. A線程啓動後,使用A.join()方法來完成運行,後續線程再啓動,則一定可以看到abc=3

總結

  • 爲什麼定義Java內存模型?因爲Java中不同的線程可能訪問同一個共享或共享變量。如果任由編譯器或處理器對這些訪問進行優化的話,很有可能出現問題,這裏稱爲編譯器的重排序。除了處理器的亂序執行、編譯器的重排序,還有內存系統的重排序。因此Java語言規範引入了Java內存模型,通過定義多項規則對編譯器和處理器進行限制,主要是針對可見性和有序性。

  • Java內存模型涉及的幾個關鍵詞:鎖、volatile字段、final修飾符

    • 鎖操作是具備happens-before關係的,解鎖操作happens-before之後對同一把鎖的加鎖操作。實際上,在解鎖的時候,JVM需要強制刷新緩存,使得當前線程所修改的內存對其他線程可見。
    • volatile字段可以看成是一種不保證原子性的同步但保證可見性的特性,其性能往往是優於鎖操作的。但是,頻繁地訪問 volatile字段也會出現因爲不斷地強制刷新緩存而影響程序的性能的問題。
    • final修飾的實例字段則是涉及到新建對象的發佈問題。當一個對象包含final修飾的實例字段時,其他線程能夠看到已經初始化的final實例字段,這是安全的。
  • Happens-Before的規則

    • 程序次序規則:在一個線程內,按照控制流順序,前面的操作happens-before後面的操作
    • 管程鎖定規則:一個unlock操作happens-before於後面對同一個鎖的lock操作。這裏必須強調的是同一個鎖,而"後面"是指時間上的先後順序。
    • .volatile變量規則:對一個volatile變量的寫操作happens-before於後面對這個變量的讀操作
    • 線程啓動規則:A線程的start()方法happens-before於start()之前的操作
    • 線程終止規則:線程中的所有操作都happens-before於對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
    • .線程中斷規則:對線程interrupt()方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.isInterrupted()方法檢測到是否有中斷髮生。
    • 對象終結規則:一個對象的初始化完成(構造函數執行結束)happens-before於它的finalize()方法的開始。
  • Java內存模型底層怎麼實現的?主要是通過內存屏障(memory barrier)禁止重排序的,即時編譯器根據具體的底層體系架構,將這些內存屏障替換成具體的 CPU 指令。對於編譯器而言,內存屏障將限制它所能做的重排序優化。而對於處理器而言,內存屏障將會導致緩存的刷新操作。比如,對於volatile,編譯器將在volatile字段的讀寫操作前後各插入一些內存屏障。

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