1. 程式人生 > 程式設計 >Java Volatile關鍵字實現原理過程解析

Java Volatile關鍵字實現原理過程解析

volatile的用法

volatile通常被比喻成"輕量級的synchronized",也是Java併發程式設計中比較重要的一個關鍵字。和synchronized不同,volatile是一個變數修飾符,只能用來修飾變數。無法修飾方法及程式碼塊等。

volatile的用法比較簡單,只需要在宣告一個可能被多執行緒同時訪問的變數時,使用volatile修飾就可以了。

如以下程式碼,是一個比較典型的使用雙重鎖校驗的形式實現單例的,其中使用volatile關鍵字修飾可能被多個執行緒同時訪問到的singleton。

public class Singleton { 
  private volatile static Singleton singleton; 
  private Singleton (){} 
  public static Singleton getSingleton() { 
  if (singleton == null) { 
    synchronized (Singleton.class) { 
    if (singleton == null) { 
      singleton = new Singleton(); 
    } 
    } 
  } 
  return singleton; 
  } 
} 

volatile的原理

為了提高處理器的執行速度,在處理器和記憶體之間增加了多級快取來提升。但是由於引入了多級快取,就存在快取資料不一致問題。

但是,對於volatile變數,當對volatile變數進行寫操作的時候,JVM會向處理器傳送一條lock字首的指令,將這個快取中的變量回寫到系統主存中。

但是就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議

快取一致性協議:每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡。

所以,如果一個變數被volatile所修飾的話,在每次資料變化之後,其值都會被強制刷入主存。而其他處理器的快取由於遵守了快取一致性協議,也會把這個變數的值從主存載入到自己的快取中。這就保證了一個volatile在併發程式設計中,其值在多個快取中是可見的。

volatile與可見性

可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

Java記憶體模型規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了該執行緒中是用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數的傳遞均需要自己的工作記憶體和主存之間進行資料同步進行。所以,就可能出現執行緒1改了某個變數的值,但是執行緒2不可見的情況。

前面的關於volatile的原理中介紹過了,Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。因此,可以使用volatile來保證多執行緒操作時變數的可見性。

volatile與有序性

有序性即程式執行的順序按照程式碼的先後順序執行。

除了引入了時間片以外,由於處理器優化和指令重排等,CPU還可能對輸入程式碼進行亂序執行,比如load->add->save 有可能被優化成load->save->add 。這就是可能存在有序性問題。

而volatile除了可以保證資料的可見性之外,還有一個強大的功能,那就是他可以禁止指令重排優化等。

普通的變數僅僅會保證在該方法的執行過程中所依賴的賦值結果的地方都能獲得正確的結果,而不能保證變數的賦值操作的順序與程式程式碼中的執行順序一致。

volatile可以禁止指令重排,這就保證了程式碼的程式會嚴格按照程式碼的先後順序執行。這就保證了有序性。被volatile修飾的變數的操作,會嚴格按照程式碼順序執行,load->add->save 的執行順序就是:load、add、save。

volatile與原子性原子性是指一個操作是不可中斷的,要全部執行完成,要不就都不執行。

執行緒是CPU排程的基本單位。CPU有時間片的概念,會根據不同的排程演算法進行執行緒排程。當一個執行緒獲得時間片之後開始執行,在時間片耗盡之後,就會失去CPU使用權。所以在多執行緒場景下,由於時間片線上程間輪換,就會發生原子性問題。

為了保證原子性,需要通過位元組碼指令monitorenter和monitorexit,但是volatile和這兩個指令之間是沒有任何關係的。

所以,volatile是不能保證原子性的。

在以下兩個場景中可以使用volatile來代替synchronized:

1、運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒會修改變數的值。

2、變數不需要與其他狀態變數共同參與不變約束。

除以上場景外,都需要使用其他方式來保證原子性,如synchronized或者concurrent包。

我們來看一下volatile和原子性的例子:

public class Test {
  public volatile int inc = 0;
 
  public void increase() {
    inc++;
  }
 
  public static void main(String[] args) {
    final Test test = new Test();
    for(int i=0;i<10;i++){
      new Thread(){
        public void run() {
          for(int j=0;j<1000;j++)
            test.increase();
        };
      }.start();
    }
 
    while(Thread.activeCount()>1) //保證前面的執行緒都執行完
      Thread.yield();
    System.out.println(test.inc);
  }
}

以上程式碼比較簡單,就是建立10個執行緒,然後分別執行1000次i++操作。正常情況下,程式的輸出結果應該是10000,但是,多次執行的結果都小於10000。這其實就是volatile無法滿足原子性的原因。

為什麼會出現這種情況呢,那就是因為雖然volatile可以保證inc在多個執行緒之間的可見性。但是無法inc++的原子性。

總結與思考

我們介紹過了volatile關鍵字和synchronized關鍵字。現在我們知道,synchronized可以保證原子性、有序性和可見性。而volatile卻只能保證有序性和可見性。

我們知道volatile關鍵字的作用是保證變數在多執行緒之間的可見性,它是java.util.concurrent包的核心,沒有volatile就沒有這麼多的併發類給我們使用。

本文詳細解讀一下volatile關鍵字如何保證變數在多執行緒之間的可見性,在此之前,有必要講解一下CPU快取的相關知識,掌握這部分知識一定會讓我們更好地理解volatile的原理,從而更好、更正確地地使用volatile關鍵字。

CPU快取

CPU快取的出現主要是為了解決CPU運算速度與記憶體讀寫速度不匹配的矛盾,因為CPU運算速度要比記憶體讀寫速度快得多,舉個例子:

  • 一次主記憶體的訪問通常在幾十到幾百個時鐘週期
  • 一次L1快取記憶體的讀寫只需要1~2個時鐘週期
  • 一次L2快取記憶體的讀寫也只需要數十個時鐘週期

這種訪問速度的顯著差異,導致CPU可能會花費很長時間等待資料到來或把資料寫入記憶體。

基於此,現在CPU大多數情況下讀寫都不會直接訪問記憶體(CPU都沒有連線到記憶體的管腳),取而代之的是CPU快取,CPU快取是位於CPU與記憶體之間的臨時儲存器,它的容量比記憶體小得多但是交換速度卻比記憶體快得多。而快取中的資料是記憶體中的一小部分資料,但這一小部分是短時間內CPU即將訪問的,當CPU呼叫大量資料時,就可先從快取中讀取,從而加快讀取速度。

按照讀取順序與CPU結合的緊密程度,CPU快取可分為:

  • 一級快取:簡稱L1 Cache,位於CPU核心的旁邊,是與CPU結合最為緊密的CPU快取
  • 二級快取:簡稱L2 Cache,分內部和外部兩種晶片,內部晶片二級快取執行速度與主頻相同,外部晶片二級快取執行速度則只有主頻的一半
  • 三級快取:簡稱L3 Cache,部分高階CPU才有

每一級快取中所儲存的資料全部都是下一級快取中的一部分,這三種快取的技術難度和製造成本是相對遞減的,所以其容量也相對遞增。

當CPU要讀取一個數據時,首先從一級快取中查詢,如果沒有再從二級快取中查詢,如果還是沒有再從三級快取中或記憶體中查詢。一般來說每級快取的命中率大概都有80%左右,也就是說全部資料量的80%都可以在一級快取中找到,只剩下20%的總資料量才需要從二級快取、三級快取或記憶體中讀取。

使用CPU快取帶來的問題

用一張圖表示一下CPU-->CPU快取-->主記憶體資料讀取之間的關係:

Java Volatile關鍵字實現原理過程解析

當系統執行時,CPU執行計算的過程如下:

程式以及資料被載入到主記憶體指令和資料被載入到CPU快取CPU執行指令,把結果寫到快取記憶體快取記憶體中的資料寫回主記憶體

如果伺服器是單核CPU,那麼這些步驟不會有任何的問題,但是如果伺服器是多核CPU,那麼問題來了,以Intel Core i7處理器的快取記憶體概念模型為例(圖片摘自《深入理解計算機系統》):

Java Volatile關鍵字實現原理過程解析

試想下面一種情況:

核0讀取了一個位元組,根據區域性性原理,它相鄰的位元組同樣被被讀入核0的快取核3做了上面同樣的工作,這樣核0與核3的快取擁有同樣的資料核0修改了那個位元組,被修改後,那個位元組被寫回核0的快取,但是該資訊並沒有寫回主存核3訪問該位元組,由於核0並未將資料寫回主存,資料不同步

為了解決這個問題,CPU製造商制定了一個規則:當一個CPU修改快取中的位元組時,伺服器中其他CPU會被通知,它們的快取將視為無效。於是,在上面的情況下,核3發現自己的快取中資料已無效,核0將立即把自己的資料寫回主存,然後核3重新讀取該資料。

反彙編Java位元組碼,檢視彙編層面對volatile關鍵字做了什麼

有了上面的理論基礎,我們可以研究volatile關鍵字到底是如何實現的。首先寫一段簡單的程式碼:

  /**
  * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7048693.html
  */
 public class LazySingleton {
 
   private static volatile LazySingleton instance = null;
   public static LazySingleton getInstance() {
     if (instance == null) {
       instance = new LazySingleton();
     }
     return instance;
   }
   public static void main(String[] args) {
     LazySingleton.getInstance();
   }
   
 }

首先反編譯一下這段程式碼的.class檔案,看一下生成的位元組碼:

Java Volatile關鍵字實現原理過程解析

沒有任何特別的。要知道,位元組碼指令,比如上圖的getstatic、ifnonnull、new等,最終對應到作業系統的層面,都是轉換為一條一條指令去執行,我們使用的PC機、應用伺服器的CPU架構通常都是IA-32架構的,這種架構採用的指令集是CISC(複雜指令集),而組合語言則是這種指令集的助記符。

因此,既然在位元組碼層面我們看不出什麼端倪,那下面就看看將程式碼轉換為彙編指令能看出什麼端倪。Windows上要看到以上程式碼對應的彙編碼不難(吐槽一句,說說不難,為了這個問題我找遍了各種資料,差點就準備安裝虛擬機器,在Linux系統上搞了),訪問hsdis工具路徑可直接下載hsdis工具,下載完畢之後解壓,將hsdis-amd64.dll與hsdis-amd64.lib兩個檔案放在%JAVA_HOME%\jre\bin\server路徑下即可,如下圖:

Java Volatile關鍵字實現原理過程解析

然後跑main函式,跑main函式之前,加入如下虛擬機器引數:

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance

這麼長長的彙編程式碼,可能大家不知道CPU在哪裡做了手腳,沒事不難,定位到59、60兩行:

0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance
; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)

之所以定位到這兩行是因為這裡結尾寫明瞭line 14,line 14即volatile變數instance賦值的地方。後面的add dword ptr [rsp],0h都是正常的彙編語句,意思是將雙位元組的棧指標暫存器+0,這裡的關鍵就是add前面的lock指令,後面詳細分析一下lock指令的作用和為什麼加上lock指令後就能保證volatile關鍵字的記憶體可見性。

lock指令做了什麼

之前有說過IA-32架構,關於CPU架構的問題大家有興趣的可以自己查詢一下,這裡查詢一下IA-32手冊關於lock指令的描述,沒有IA-32手冊的可以去這個地址下載IA-32手冊下載地址,是個中文版本的手冊。

我摘抄一下IA-32手冊中關於lock指令作用的一些描述(因為lock指令的作用在手冊中散落在各處,並不是在某一章或者某一節專門講):

在修改記憶體操作時,使用LOCK字首去呼叫加鎖的讀-修改-寫操作,這種機制用於多處理器系統中處理器之間進行可靠的通訊,具體描述如下:

(1)在Pentium和早期的IA-32處理器中,LOCK字首會使處理器執行當前指令時產生一個LOCK#訊號,這種總是引起顯式匯流排鎖定出現

(2)在Pentium4、Inter Xeon和P6系列處理器中,加鎖操作是由快取記憶體鎖或匯流排鎖來處理。如果記憶體訪問有快取記憶體且隻影響一個單獨的快取記憶體行,那麼操作中就會呼叫快取記憶體鎖,而系統匯流排和系統記憶體中的實際區域內不會被鎖定。同時,這條總線上的其它Pentium4、Intel Xeon或者P6系列處理器就回寫所有已修改的資料並使它們的快取記憶體失效,以保證系統記憶體的一致性。如果記憶體訪問沒有快取記憶體且/或它跨越了快取記憶體行的邊界,那麼這個處理器就會產生LOCK#訊號,並在鎖定操作期間不會響應匯流排控制請求

32位IA-32處理器支援對系統記憶體中的某個區域進行加鎖的原子操作。這些操作常用來管理共享的資料結構(如訊號量、段描述符、系統段或頁表),兩個或多個處理器可能同時會修改這些資料結構中的同一資料域或標誌。處理器使用三個相互依賴的機制來實現加鎖的原子操作:

1、保證原子操作

2、匯流排加鎖,使用LOCK#訊號和LOCK指令字首

3、快取記憶體相干性協議,確保對快取記憶體中的資料結構執行原子操作(快取記憶體鎖)。這種機制存在於Pentium4、Intel Xeon和P6系列處理器中

IA-32處理器提供有一個LOCK#訊號,會在某些關鍵記憶體操作期間被自動啟用,去鎖定系統匯流排。當這個輸出訊號發出的時候,來自其他處理器或匯流排代理的控制請求將被阻塞。軟體能夠通過預先在指令前新增LOCK字首來指定需要LOCK語義的其它場合。

在Intel386、Intel486、Pentium處理器中,明確地對指令加鎖會導致LOCK#訊號的產生。由硬體設計人員來保證系統硬體中LOCK#訊號的可用性,以控制處理器間的記憶體訪問。

對於Pentinum4、Intel Xeon以及P6系列處理器,如果被訪問的記憶體區域是在處理器內部進行快取記憶體的,那麼通常不發出LOCK#訊號;相反,加鎖只應用於處理器的快取記憶體。

為顯式地強制執行LOCK語義,軟體可以在下列指令修改記憶體區域時使用LOCK字首。當LOCK字首被置於其它指令之前或者指令沒有對記憶體進行寫操作(也就是說目標運算元在暫存器中)時,會產生一個非法操作碼異常(#UD)。

【1】位測試和修改指令(BTS、BTR、BTC)
【2】交換指令(XADD、CMPXCHG、CMPXCHG8B)
【3】自動假設有LOCK字首的XCHG指令
【4】下列單運算元的算數和邏輯指令:INC、DEC、NOT、NEG
【5】下列雙運算元的算數和邏輯指令:ADD、ADC、SUB、SBB、AND、OR、XOR

一個加鎖的指令會保證對目標運算元所在的記憶體區域加鎖,但是系統可能會將鎖定區域解釋得稍大一些。軟體應該使用相同的地址和運算元長度來訪問訊號量(用作處理器之間傳送訊號的共享記憶體)。例如,如果一個處理器使用一個字來訪問訊號量,其它處理器就不應該使用一個位元組來訪問這個訊號量。匯流排鎖的完整性不收記憶體區域對齊的影響。加鎖語義會一直持續,以滿足更新整個運算元所需的匯流排週期個數。但是,建議加鎖訪問應該對齊在它們的自然邊界上,以提升系統性能:

【1】任何8位訪問的邊界(加鎖或不加鎖)
【2】鎖定的字訪問的16位邊界
【3】鎖定的雙字訪問的32位邊界
【4】鎖定的四字訪問的64位邊界

對所有其它的記憶體操作和所有可見的外部事件來說,加鎖的操作都是原子的。所有取指令和頁表操作能夠越過加鎖的指令。加鎖的指令可用於同步一個處理器寫資料而另一個處理器讀資料的操作。

IA-32架構提供了幾種機制用來強化或弱化記憶體排序模型,以處理特殊的程式設計情形。這些機制包括:

【1】I/O指令、加鎖指令、LOCK字首以及序列化指令等,強制在處理器上進行較強的排序

【2】SFENCE指令(在Pentium III中引入)和LFENCE指令、MFENCE指令(在Pentium4和Intel Xeon處理器中引入)提供了

某些特殊型別記憶體操作的排序和序列化功能
...(這裡還有兩條就不寫了)

這些機制可以通過下面的方式使用。

總線上的記憶體對映裝置和其它I/O裝置通常對向它們緩衝區寫操作的順序很敏感,I/O指令(IN指令和OUT指令)以下面的方式對這種訪問執行強寫操作的排序。在執行了一條I/O指令之前,處理器等待之前的所有指令執行完畢以及所有的緩衝區都被都被寫入了記憶體。只有取指令和頁表查詢能夠越過I/O指令,後續指令要等到I/O指令執行完畢才開始執行。

反覆思考IA-32手冊對lock指令作用的這幾段描述,可以得出lock指令的幾個作用:

鎖匯流排,其它CPU對記憶體的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖快取替代鎖匯流排,因為鎖匯流排的開銷比較大,鎖匯流排期間其他CPU沒法訪問記憶體lock後的寫操作會回寫已修改的資料,同時讓其它CPU相關快取行失效,從而重新從主存中載入最新的資料不是記憶體屏障卻能完成類似記憶體屏障的功能,阻止屏障兩遍的指令重排序

(1)中寫了由於效率問題,實際後來的處理器都採用鎖快取來替代鎖匯流排,這種場景下多快取的資料一致是通過快取一致性協議來保證的,我們來看一下什麼是快取一致性協議。

快取一致性協議

講快取一致性之前,先說一下快取行的概念:

快取是分段(line)的,一個段對應一塊儲存空間,我們稱之為快取行,它是CPU快取中可分配的最小儲存單元,大小32位元組、64位元組、128位元組不等,這與CPU架構有關,通常來說是64位元組。當CPU看到一條讀取記憶體的指令時,它會把記憶體地址傳遞給一級資料快取,一級資料快取會檢查它是否有這個記憶體地址對應的快取段,如果沒有就把整個快取段從記憶體(或更高一級的快取)中載入進來。注意,這裡說的是一次載入整個快取段,這就是上面提過的區域性性原理

上面說了,LOCK#會鎖匯流排,實際上這不現實,因為鎖匯流排效率太低了。因此最好能做到:使用多組快取,但是它們的行為看起來只有一組快取那樣。快取一致性協議就是為了做到這一點而設計的,就像名稱所暗示的那樣,這類協議就是要使多組快取的內容保持一致。

快取一致性協議有多種,但是日常處理的大多數計算機裝置都屬於"嗅探(snooping)"協議,它的基本思想是:

所有記憶體的傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條匯流排:快取本身是獨立的,但是記憶體是共享資源,所有的記憶體訪問都要經過仲裁(同一個指令週期中,只有一個CPU快取可以讀寫記憶體)。

CPU快取不僅僅在做記憶體傳輸的時候才與匯流排打交道,而是不停在嗅探總線上發生的資料交換,跟蹤其他快取在做什麼。所以當一個快取代表它所屬的處理器去讀寫記憶體時,其它處理器都會得到通知,它們以此來使自己的快取保持同步。只要某個處理器一寫記憶體,其它處理器馬上知道這塊記憶體在它們的快取段中已失效。

MESI協議是當前最主流的快取一致性協議,在MESI協議中,每個快取行有4個狀態,可用2個bit表示,它們分別是:

Java Volatile關鍵字實現原理過程解析

這裡的I、S和M狀態已經有了對應的概念:失效/未載入、乾淨以及髒的快取段。所以這裡新的知識點只有E狀態,代表獨佔式訪問,這個狀態解決了"在我們開始修改某塊記憶體之前,我們需要告訴其它處理器"這一問題:只有當快取行處於E或者M狀態時,處理器才能去寫它,也就是說只有在這兩種狀態下,處理器是獨佔這個快取行的。當處理器想寫某個快取行時,如果它沒有獨佔權,它必須先發送一條"我要獨佔權"的請求給匯流排,這會通知其它處理器把它們擁有的同一快取段的拷貝失效(如果有)。只有在獲得獨佔權後,處理器才能開始修改資料----並且此時這個處理器知道,這個快取行只有一份拷貝,在我自己的快取裡,所以不會有任何衝突。

反之,如果有其它處理器想讀取這個快取行(馬上能知道,因為一直在嗅探匯流排),獨佔或已修改的快取行必須先回到"共享"狀態。如果是已修改的快取行,那麼還要先把內容回寫到記憶體中。

由lock指令回看volatile變數讀寫

相信有了上面對於lock的解釋,volatile關鍵字的實現原理應該是一目瞭然了。首先看一張圖:

Java Volatile關鍵字實現原理過程解析

工作記憶體Work Memory其實就是對CPU暫存器和快取記憶體的抽象,或者說每個執行緒的工作記憶體也可以簡單理解為CPU暫存器和快取記憶體。

那麼當寫兩條執行緒Thread-A與Threab-B同時操作主存中的一個volatile變數i時,Thread-A寫了變數i,那麼:

Thread-A發出LOCK#指令發出的LOCK#指令鎖匯流排(或鎖快取行),同時讓Thread-B快取記憶體中的快取行內容失效Thread-A向主存回寫最新修改的i

Thread-B讀取變數i,那麼:

Thread-B發現對應地址的快取行被鎖了,等待鎖的釋放,快取一致性協議會保證它讀取到最新的值

由此可以看出,volatile關鍵字的讀和普通變數的讀取相比基本沒差別,差別主要還是在變數的寫操作上。

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援我們。