Java中的可見性問題


前言

編程中可見性、原子性、有序性導致的問題常常會違背我們的直覺,從而成為並發編程的 Bug 之源。這三者在編程領域屬於共性問題,所有的編程語言都會遇到,Java 在誕生之初就支持多線程,自然也有針對這三者的技術方案,而且在編程語言領域處於領先地位。理解 Java 解決並發問題的解決方案,對於理解其他語言的解決方案有觸類旁通的效果。

那我們就先來聊聊如何解決其中的可見性和有序性導致的問題,這也就引出來了今天的主角——Java 內存模型

什么是 Java 內存模型?

你已經知道,導致可見性的原因是緩存,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,但是這樣問題雖然解決了,我們程序的性能可就堪憂了。

合理的方案應該是按需禁用緩存以及編譯優化。那么,如何做到“按需禁用”呢?對於並發程序,何時禁用緩存以及編譯優化只有程序員知道,那所謂“按需禁用”其實就是指按照程序員的要求來禁用。所以,為了解決可見性和有序性問題,只需要提供給程序員按需禁用緩存和編譯優化的方法即可。

Java 內存模型是個很復雜的規范,可以從不同的視角來解讀,站在我們這些程序員的視角,本質上可以理解為,Java 內存模型規范了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來說,這些方法包括 volatilesynchronizedfinal 三個關鍵字,以及六項 Happens-Before 規則,這也正是本期的重點內容。掌握這些方法,我們就可以按需地禁用緩存和編譯優化了,也就掌握了Java內存模型最核心的東西。

程序的順序性

在分析Happens-Before規則之前,一定要搞懂程序的順序性。程序的順序性指在編譯器以及CPU優化情況下(編譯器&CPU優化會破壞程序執行順序),保證程序以單線程方式執行時,其結果的不變性。舉個例子,在單線中,下面的程序不論重復多少次(先執行writer后執行reader),最后始終會輸出42。這只對單線程程序而言,多線程程序(一個線程執行writer,另一個線程執行reader)中可能會輸出0。

class Example {

  int x = 0;
  boolean v = false;
  
  public void writer() {
    this.x = 42;
    this.v = true;
  }

  public void reader() {
    while (true) {
      if (v) {
        print(x);
        return;
      }
    }
  }
}

程序的順序性在任何CPU,任何編程語言中都需遵守的基本規則,因此Java語言或者說Java內存模型天然支持的。因此,Happens-Before規則其實應用於多線程場景,我們在編寫多線程程序時一定要考慮到Happens-Before規則的使用。

Happens-Before 規則

如何理解 Happens-Before 呢?如果望文生義(很多網文也都愛按字面意思翻譯成“先行發生”),那就南轅北轍了,Happens-Before 並不是說前面一個操作發生在后續操作的前面,它真正要表達的是:前面一個操作的結果對后續操作是可見的。就像有心靈感應的兩個人,雖然遠隔千里,一個人心之所想,另一個人都看得到。Happens-Before 規則就是要保證線程之間的這種“心靈感應”。所以比較正式的說法是:Happens-Before 約束了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化后一定遵守 Happens-Before 規則。

Happens-Before 規則應該是 Java 內存模型里面最晦澀的內容了,和程序員相關的規則一共有如下七項,都是關於可見性的。

恰好前面示例代碼涉及到這六項規則中的前三項,為便於你理解,我也會分析上面的示例代碼,來看看規則 1、2 和 3 到底該如何理解。至於其他三項,我也會結合其他例子作以說明。

volatile 變量規則

這條規則是指對一個 volatile 變量的寫操作, Happens-Before 於后續對這個 volatile 變量的讀操作,即被volatile修飾的變量寫對讀是可見的,確保線程在執行讀操作時始終拿到的是新值。同時volatile禁止重排序功能,被volatile修飾的變量在進行讀寫時,這些變量是不能重排序;volatile變量前后的讀寫操作不能重排序

volatile int a;
volatile int b;
volatile int c;
int d;
int e;
int f;

//(1)(2)(3)不能重排序
a=10;     (1)
b=20;     (2)
c=30;     (3)

//將(5)看成一堵障礙,其前后的操作不能跨越障礙
//因此(4)不能滯后於(5)執行,(6)(7)不能先於(5執行),但(6)(7)可以重排序執行
d=40;     (4)
b=50;     (5)
e=60;     (6)
f=70;     (7)

傳遞性規則

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

在解釋傳遞性規則之前先修改開始出現的那段代碼,用volatile修飾v變量

class Example {

  int x = 0;
  volatile boolean v = false;
  
  public void writer() {
    this.x = 42;
    this.v = true;
  }

  public void reader() {
    while (true) {
      if (v) {
        print(x);
        return;
      }
    }
  }
}

我們將傳遞性應用到我們的例子中,會發生什么呢?可以看下面這幅圖:

11

示例代碼中的傳遞性規則

從圖中,我們可以看到:

“x=42” Happens-Before 寫變量 “v=true” ,這是volatile規則的內容;

寫變量“v=true” Happens-Before 讀變量 “v=true”,這是volatile規則的內容 。

再根據這個傳遞性規則,我們得到結果:“x=42” Happens-Before 讀變量“v=true”。這意味着什么呢?

如果線程 B 讀到了“v=true”,那么線程 A 設置的“x=42”對線程 B 是可見的。也就是說,線程 B 能看到 “x == 42”

管程中鎖的規則

這條規則是指對一個鎖的解鎖 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。

線程 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();

線程 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

線程中斷規則

對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生

//線程A此處對共享變量var修改
var = 66

//中斷線程B
B.interrupt()

線程B被中斷后,線程B看到共享變量var的值應為66

final規則

前面我們講 volatile 為的是禁用緩存以及編譯優化,我們再從另外一個方面來看,有沒有辦法告訴編譯器優化得更好一點呢?這個可以有,就是 final 關鍵字

final 修飾變量時,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優化。Java 編譯器在 1.5 以前的版本的確優化得很努力,以至於都優化錯了。但是final使用不當會導致對象“逸出”。

“逸出”有點抽象,我們還是舉個例子吧,在下面例子中,在構造函數里面將 this 賦值給了全局變量 global.obj,這就是“逸出”,線程通過 global.obj 讀取 x 是有可能讀到 0 的。因此我們一定要避免“逸出”。

因此在編程時最好不要在構造函數中把this賦值給一個全局變量。

final int x;

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

總結

Java 的內存模型是並發編程領域的一次重要創新,之后 C++、C#、Golang 等高級語言都開始支持內存模型。Java 內存模型里面,最晦澀的部分就是 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 語義的現實理解。

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

Java 內存模型主要分為兩部分,一部分面向你我這種編寫並發程序的應用開發人員,另一部分是面向 JVM 的實現人員的,我們可以重點關注前者,也就是和編寫並發程序相關的部分,這部分內容的核心就是 Happens-Before 規則。相信經過本章的介紹,你應該對這部分內容已經有了深入的認識。

參考

JSR-133: JavaTMMemory Model and Thread Specification
Java 內存模型 FAQ


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM