1. 程式人生 > 其它 >Java併發程式設計:Java如何解決可見性和有序性問題

Java併發程式設計:Java如何解決可見性和有序性問題

什麼是JMM

執行緒安全需要保證多執行緒併發執行程式的三種特性:

  1. 原子性
  2. 可見性
  3. 有序性

現代計算機體系大部是採用的對稱多處理器的體系架構。每個處理器均有獨立的暫存器組和快取,多個處理器可同時執行同一程序中的不同執行緒,並且因為不同指令的處理時長各自不相同,為了提高處理器的處理效能,引入了流水線的方式,對指令進行重排序來實現處理速度的優化,這裡稱為處理器層面的亂序執行。

在Java中,不同的執行緒可能訪問同一個共享變數。如果任由編譯器或處理器對這些訪問進行優化的話,很有可能讀取到錯誤的變數資料。因此Java語言規範引入了Java記憶體模型,通過定義多項規則對編譯器和處理器進行限制,主要是針對可見性和有序性。

多執行緒對某共享變數不可見的原因就是為了解決記憶體和CPU的速度差異引入的快取,而有序性是為了CPU為了利用流水線處理指令發生的編譯優化。

解決可見性、有序性的方式其實就是禁用快取和禁用編譯優化,但是一刀切往往是不可取的方式。所以我們需要的是按需禁用快取和禁用編譯優化。那麼“按需”一詞背後涉及到的策略就需要我們程式設計師來控制。我們需要JVM給我們提供一套“方法”。

Java記憶體模型做的事情就是:規範了JVM能夠提供的禁用快取和編譯優化的方法。這些方法主要涉及到三個關鍵字和六個happens-before規則。三個關鍵字包含volatile、synchronized和final。

volatile關鍵字

volatile關鍵字的原始語義就是禁用cpu快取,在其他語言裡面也有類似的關鍵字。

volatile修飾的關鍵字,可以告訴編譯器:對這個變數的讀寫,不能作用於CPU快取,必須從主存當中讀寫。比如對於下面的程式碼,volatile關鍵字可以保證“如果writer方法先執行修改了v變數使其=true,那麼reader方法後續讀到的x變數一定是42而不是0”。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 這裡x會是多少呢?
    }
  }
}

但是上面這句話保證的內容在JDK5之前不一定成立,而對於後面的JDK版本來說是一定成立的,原因就是對volatile語義加強之後,加入了一項Happens-Before規則。

Happens-Before規則

Happens-Before翻譯過來是“前面操作的結果-對後續操作可見”。這個規則的設立正對的物件是編譯器。也就是告訴編譯器,你優化編譯的結果必須要符合這些規則。

總共有如下幾個規則:

規則1:同線程的順序性

還是上面那段VolatileExample類的程式碼。看到writer方法(在同一個執行緒內)的 x = 42;v = true;兩行程式碼。x=42這個修改,對於v=true這個後續操作來說,它的操作就是可見的。

這個規則比較易於理解:假設v=true這裡有一個人正在觀測x的變動,當v=true指令將要執行之前,他將“有能力”看到記憶體中x變數的地址上值的變動。

規則2:寫操作的影響讀操作可見

對一個 volatile 變數的寫操作, Happens-Before 於後續對這個 volatile 變數的讀操作。

可以關聯規則3去理解。

規則3:傳遞性

假設Happens-Before是一個操作符(類似+/-/×/÷)。那麼:

如果:

- A Happens-Before B
- B Happens-Before C

則一定:

- A Happens-Before C

使用write和reader方法裡面的幾個操作分別套用進去這個式子。

A如果是x=42(寫操作),B是讀v=true,C是任意(讀或者寫)對x的操作。

對於wirter方法來說,就是A的操作對B可見,這個在“順序性”規則裡面已經說明了。那麼對於reader方法執行的執行緒來說,一旦讀取到volatile關鍵字修飾的v=true的時候,就說明,在writer執行緒裡面操作的x=42,對於reader執行緒來說也是可見的,執行緒B可以讀取到x==42。

volatile就是規定了編譯器在這裡的編譯優化必須符合這個規則。這裡也可以和規則2關聯一下,因為x=42是寫操作,寫操作的影響必須對讀操作v==true?可見,所以reader執行緒肯定會因為在wirter發生了對volatile變數v的修改,導致writer執行緒能夠看到x=42這樣一個操作的修改。

規則4:管程中鎖的規則

這個規則規定了編譯器:對一個鎖資源的解鎖產生的影響對後續對這個鎖資源的加鎖可見。

標題裡的管程是什麼?管程是一種通用的同步原語,在Java語言的實現就是synchronized關鍵字。

synchronized (this) { //此處自動加鎖
 
} //此處自動解鎖

對於鎖資源的加鎖和釋放的位置如上所示。

說回來之前的固定:假設執行緒A在synchronized程式碼塊裡面執行完之後解鎖了鎖資源,在程式碼塊裡產生的影響會對後續得到鎖的執行緒B可見。

這個其實沒什麼難理解的,不多解釋了。

規則5:“start方法”規則

標題很隱晦,解釋一下就是:main執行緒start()了一個Thread,那麼對於這Thread來說,main方法裡面產生的修改操作對start()方法執行時、執行後的子執行緒可見。

Thread B = new Thread(()->{
  // 主執行緒呼叫B.start()之前
  // 所有對共享變數的修改,此處皆可見
  // 此例中,var==77
});
// 此處對共享變數var修改
var = 77;
// 主執行緒啟動子執行緒
B.start();

即使B執行緒執行邏輯(run方法)在程式碼的定義優先於var=77的賦值,但是由於此規則的存在,編譯器會保證B執行緒在start之後,能夠看到主執行緒對var的賦值。

規則6:“join方法”規則

main執行緒使用join等待子執行緒執行完畢,對於join來說,JVM要求:“子執行緒的任何操作產生的影響”,比如下面的程式碼:

Thread B = new Thread(()->{
  // 此處對共享變數var修改
  var = 66;
});
// 例如此處對共享變數修改,
// 則這個修改結果對執行緒B可見
// 主執行緒啟動子執行緒
B.start();
B.join()
// 子執行緒所有對共享變數的修改
// 在主執行緒呼叫B.join()之後皆可見
// 此例中,var==66

對於上面Thread裡面定義的lambda表示式裡面的邏輯,對於在主執行緒內呼叫得join方法來說,都是在之前執行的,主執行緒在join呼叫的時候都能看見。

Happens-Before規則總結

對上述說的Happens-Before規則進行總結一下,A Happens-Before B的含義就是A產生的操作影響對B可見。套入到六個規則裡面,挨個理解如下:

  1. 首先從單執行緒角度來說,Happens-Before保證volatile變數的讀寫之前的任何變數的寫操作都會對volatile變數可見,也就是保證編譯器不將前面的修改操作提前。
  2. 從多執行緒參與的角度來說
    1. 同一個volatile變數的讀和寫操作來說,寫操作產生的變化對後續的讀取操作可見。
    2. Happens-Before規則有傳遞性,也就是假如A、B操作符合Happens-Before當中的順序性,並且B、C操作符合Happens-Before當中的寫對讀可見性,那麼傳遞性會保證A操作的修改能對C操作可見。
  3. 從鎖機制的角度來看:JMM還能規定synchronized關鍵字會保證前面執行緒釋放鎖之後,前面執行緒操作產生的變化對後面得到鎖的執行緒可見。
  4. 按照Thread類的兩個API來分類:
    1. 對於start啟動的子執行緒來說,start執行之前的主執行緒的修改會對start啟動的子執行緒可見。
    2. 對於join收集的子執行緒結果來說,join執行之前的子執行緒的修改會對join和join之後的操作可見。

final關鍵字

volatile是JVM為了按需禁用快取和編譯器優化對上層(程式設計師開發向)提供的一個關鍵字,JVM為了更好的編譯器優化效果,還提供了另外一個關鍵字——final。

final關鍵字的存在是為了告訴編譯器這個變數是不變的,尤其是在JDK5之後,對final型別的變數的重拍序機制進行了優化。程式設計師顯式的告訴編譯器這個變數是不變的,意味著編譯器可以自由的對這個變數進行優化。