Java中的volatile


Java的關鍵字 volatile 用於將變量標記爲“存儲於主內存中”。更確切地說,對 volatile 變量的每次讀操作都會直接從計算機的主存中讀取,而不是從 cpu 緩存中讀取;同樣,每次對 volatile 變量的寫操作都會直接寫入到主存中,而不僅僅寫入到 cpu 緩存裏。

實際上,從 Java 5 開始關鍵字 volatile 除了能確保 volatile 變量直接從主存中進行讀寫,還有以下幾個作用。

可見性保證

關鍵字 volatile 能確保數據變化在線程之間的可見性。

在多線程的應用中多個線程對 non-volatile 變量進行操作,線程在對它們進行操作的時候爲了提高性能會將變量從主存複製到 cpu 緩存中。如果你的電腦包含的 cpu 不止一個, 那麼每個線程可能會運行於不同的 cpu 上。這意味着,不同線程會將變量複製到不同 cpu 的緩存裏。如下圖:

在這裏插入圖片描述
no-volatile 變量不能保證 Java 虛擬機(JVM)何時從主存中將數據讀入cpu 緩存,也不能保證何時將數據從 cpu 緩存寫入到主存中。這會帶來一些問題,我將在下面解釋。

想象一個場景,兩個或兩個以上線程可訪問同一個共享對象,這個對象含有一個如下的計數器變量:

public class SharedObject{
  public int counter = 0;
}

再假設有2個線程 Thread1 和 Thread2,只有 Thread1 能增大 counter ,而 Thread1 和 Thread2 都可以在任何時刻讀取 counter 的值。
如果 counter 沒被聲明爲 volatile ,將不能保證什麼時候 counter 變量的值會從 cpu 緩存回寫到主存內。也就是說,變量 counter 在 cpu 緩存中的值可能和主存內的不一致。 如下圖:

在這裏插入圖片描述
這種由於線程還未將變量的值回寫到主存而導致其他線程不能看到該變量的最新值的問題,稱爲可見性問題。一個線程的更新操作對其他線程不可見。

通過將變量 counter 聲明爲 volatile ,對其進行的所有寫操作都會馬上回寫至主存中。同時,所有 counter 的讀操作也將直接在主存中進行。聲明方式如下:

public class SharedObject{
  public volatile int counter = 0;
}

這樣,將變量聲明爲 volatile 保證了寫操作對其他線程的可見性。

Happens-before 保證

自 Java 5 之後,關鍵字 volatile 不僅僅保證變量寫入主存和從主存中的讀取。實際上,volatile 保證了以下幾點:

如果線程A寫 volatile 變量(下文用 volatile 簡稱 volatile 變量), 然後線程B 讀取這個 volatile ,那麼在寫 volatile 之前對線程A可見的變量也將在線程B 讀取這個 volatile 之後可見。
對 volatile 變量的讀取和寫入指令不能被 JVM 重排序(只要 JVM 識別出程序的行爲在重排序後不會改變,它就會對指令進行重排序以提高性能)。操作volatile 之前和之後的指令可以重排序,但是不能將其和這些指令混在一起重排序。任何發生在 volatile 的讀寫操作之後的指令一定發生在讀寫操作之後。(具體的可以看本文底部的 “正確使用volatile” 裏的說明)
我們來做對以上敘述做進一步的說明:
當線程寫入 volatile 時,不單單是將這個 volatile 寫入主存中。這個線程在寫此
volatile 變量之前改變的所有的變量也將刷新到主存中。當另一個線程讀取這個 volatile 變量時,它也能從主存中讀取到隨 volatile 一起被刷入主存的其他所有變量。

看看這個例子:

Thread A:
  sharedObject.nonVolatile = 123;
  sharedObject.counter = sharedObject.counter + 1; // volatile
Thread B:
  int counter = sharedObject.counter;
  int nonVolatile = sharedObject.nonVolatile;

由於線程A 在寫 volatile 的變量 sharedObject.counter 之前寫 non-volatile 變量 sharedObject.nonVolatile,sharedObject.counter 和 sharedObject.nonVolatile 會在 寫 sharedObject.counter 的時候一起寫入到主存中。
由於線程B 開始時先讀取 volatile 變量 sharedObject.counter, 那麼 sharedObject.counter 和 sharedObject.nonVolatile 會直接從主存讀取到供線程B 使用的 cpu 緩存中 。這個時候,線程B 讀到的 sharedObject.nonVolatile 就是線程A 寫入的新值。

開發人員可以利用這擴展的可見性保證來優化線程間變量的可見性。只將一個或者幾個變量聲明爲 volatile ,而不是把每個變量都聲明成 volatile, 比如常用的標記位變量 flag 就可以放心的在處理完相應才操作後置爲 true 了 。利用這個原則來簡單地重寫 Exchanger 類:

public class Exchanger{
  private Object object = null;
  private volatile boolean hasNewObject = false;
  
  public void put(Object newObject){
    while(hasNewObject){
      // 等待 , 不要去覆蓋object字段
      System.out.println("等待put");
    }  
    object = newObject;     // 在寫 volatile 之前進行的普通寫
    hasNewObject =  true; // 寫 volatile 
  }

  public Object take(){
    while(! hasNewObject){ // 讀 volatile
       // 等待, 不獲取舊的 object或null
       System.out.println("等待take");
    }
    Object obj = object;      // 再寫 volatile 之前進行的普通讀
    hasNewObject = false; // 寫 volatile
    return obj;
  }
}

執行場景爲線程A 不斷調用 put() 方法塞入新對象,線程B 不斷調用 take() 方法獲取新對象。如果僅有線程A 調用 put()並且僅有線程B 調用 take(), 那麼這個 Exchanger 只要使用 volatile 變量就能正常運行了(不需要使用 synchronized 同步代碼塊)。

然而,如果 JVM 對指令進行重排序後不影響其執行語義,它就會對 Java 指令進行重排序以提高性能。如果 JVM 調整 put() 和
take() 內讀寫指令的執行順序,會發生什麼? 如果 put() 實際上是按如下順序執行的會怎樣?

while(hasNewObject){
  // 等待 , 不要去覆蓋object字段
}
hasNewObject = true; // 寫 volatile
object = newObject;

注意,現在上面示例中寫 volatile 在新的 object 賦值前就執行了。對 JVM 來說這也許看起來完全合法,因爲這兩個寫操作的值彼此之間沒有依賴。
不過, 以上的重排序會損害 volatile 變量 object 的可見性。首先,線程B 可能在線程給變量 object 賦新值之前就看到 hasNewObject 已經是 true 了。其次,現在已經不能保證 object 的新值被回寫到主存了(也許是下次線程A 在某處寫volatile的時候)。
爲了阻止如上情形的發生,關鍵字 volatile 還提供了 happes before 保證。happens-before 保證對 volatile 變量的讀寫指令不會被重排序。可以重排序在其之前和之後發生的指令,但是對 volatile 變量的讀寫指令不能同先於或後於它發生的任何指令一起重排序。
看看如下例子:

sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile = true;  // volatile 變量

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM 會重排序前面的3個指令,只要保證它們都在 寫 volatile 之前發生就可以(它們必須在寫 volatile 指令前執行)。

類似地,JVM 也會重排序最後的 3 個指令,只要保證它們都在寫 volatile 之後發生就可以。最後的 3 個指令都不能重排序至 寫volatile指令之前。
這是 Java volatile happens-before 保證的基本含義。

volatile對於重排序的禁止操作主要是java編譯器在生成指令序列時會在適當的位置插入內存屏障來阻止重排序,具體說明可以參考《java併發編程藝術》3.1 和 3.4.3小節。

volatile 並不能滿足所有場景

即使關鍵字 volatile 能保證對它的所有讀操作都是直接從主存中讀取,所有寫操作也都是直接寫入主存中,還是有些僅將變量聲明爲 volatile 不能滿足的場景。

在前面討論的例子中只有線程A 對共享變量 counter 進行寫操作,將 counter 聲明爲 volatile 就足以保證線程B 能看到新寫入的值。

實際上,多線程甚至在同時對共享的 volatile 變量進行寫操作時,只要新值的寫入不依賴它之前的值,就仍然能保持主存中的值是正確的。換句話說,一個線程將一個值寫入共享 volatile 變量中,不需要首先讀取原來值來計算下一個新值。

只要線程需要先讀取 volatile 的值, 然後基於這個值來生成新值,那麼這個 volatile 變量就不再能保證其正確的可見性。讀取 volatile 和寫入新值之間的時間間隙產生了 競態條件 ,多個線程可能讀取到相同的值, 然後生成新值,接着在將值回寫到主存中的時候就會覆蓋彼此的值了。

多線程增加同一個計數器 counter 就恰好是 volatile 不夠用的一個場景。接下來我們來更詳細的解釋這個例子。

假設線程1 讀取值爲0的共享變量 counter 到它的 cpu 緩存中,增加值到1,但還沒把改變的值回寫到主存中。 線程2 接着也能從主存中讀取值還是0這個 counter,並將其存入它自己的 cpu 緩存裏。接着線程2 也將 counter 的值增加到1,同樣也還沒回寫主存。這個場景如下圖:

在這裏插入圖片描述

線程1 和線程2 現在就是切實的不同步。共享變量 counter 實際的值應該 2,但是每個線程在各自的 cpu 緩存中的值爲 1,主存中的值還是 0。亂成一團了!即使最後線程都把它們持有的值回寫到主存中,counter 的值也是錯的。

什麼時候單單使用 volatile 就夠了?

就如之前提到的,如果兩個線程都對共享變量進行讀寫,那麼只使用關鍵字 volatile 就不能滿足要求了。這種情況你需要用 synchronized 來保證讀寫變量的原子性。對 volatile 變量的讀寫操作並不會阻塞其他線程的讀寫。如果需要阻塞,你就必須在臨界區周圍使用關鍵字 synchronized 。

如果不想用 synchronized 代碼塊,你可以從包java.util.concurrent中找到很多有用的原子數據類型,如 AtomicLong,AtomicReference 或者其他。

假設只有一個線程同時讀寫 volatile 變量,其他線程只讀取,那麼只讀線程一定能看到最新寫入到 volatile 變量的值。如果不將變量聲明爲 volatile ,這就的不到保證。

關鍵字 volatile 確定能在 32位和64位變量上正常運行。

volatile 的性能考慮

對 volatile 變量的讀取和寫入操作導致變量直接在主存中讀寫。從主存中讀取和寫入到主存中比在 cpu 緩存中代價更高 。訪問 volatile 變量也阻止了常規的性能優化技術對指令的重排序。所以,你應該只在確實需要加強變量的可見性的時候使用 volatile。

references:

轉載:https://www.jianshu.com/p/3893fb35240f

發佈了100 篇原創文章 · 獲贊 46 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章