深入理解volatile

jarven發表於2018-10-03
在理解volatile之前,我們先來看下CPU的工作模式:

深入理解volatile


處理器這種工作產生的問題:
1、所有的變數在處理器運算期間都是變數對應值的一個副本,其它處理器無法感知其對變數的操作。
2、處理器為了高效利用暫存器而對指令的重排在多執行緒下將會產生無法預測的結果。
3、不同的處理器針對同一套編碼所產生的指令會有不同的執行策略。

為了解決上述三個問題JVM為了保證每個平臺程式碼執行結果的一致性提出了JMM(JAVA記憶體模型),目的是為了讓Java程式在各種平臺下都能達到一致性的結果。

JMM規範:
Happen-Before原則:
1、程式順序原則:一個執行緒內保證語意的序列化
2、volatile規則:volatile變數的寫先發生於讀,這保證了volatile變數的可見性
3、鎖規則:解鎖必然發生於加鎖前
4、傳遞性:A先於B,B先於C,A一定先於C
5、執行緒的start()方法先於它的每一個動作
6、執行緒的所有動作,先於執行緒的終結
7、執行緒的中斷先於被中斷的程式碼
8、物件的建構函式執行、結束先於finalize()方法

針對volatile的優化:
volatile能保證修改對其它執行緒可見。即修改了共享變數後肯定會刷回主記憶體,通知其它執行緒,但是為了使處理器的內部單元高效工作,處理器會對輸入的程式碼進行亂序即指令重排。對於volatile如果不做針對性的處理,那顯然volatile的可見性並不會有什麼意義。並不能保證結果的確定性。
針對volatileJVM做了大量的工作:
關於工作記憶體(針對硬體就是快取記憶體)JMM定義了8種操作來完成:
  • lock(加鎖): 作用於主記憶體,把一個變數標記為執行緒獨佔。
  • unlock(解鎖):作用於主記憶體,把一個已鎖定的變數釋放出來。
  • read(讀取):作用於主記憶體,將一個變數從主內從中傳輸到工作記憶體中,以便隨後的load。
  • load(載入):作用於工作記憶體,把read操作得到的變數放在工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體,把工作記憶體中的一個變數傳遞給執行引擎。
  • assign(賦值):作用於工作記憶體,把一個執行引擎接受的值賦值給工作記憶體的變數。
  • store(儲存):作用於工作記憶體,把工作記憶體中的一個變數的值傳輸到主記憶體,以便後續的write操作。
  • write(寫入):作用於主記憶體,把store操作從工作記憶體得到的值放回主記憶體中。
8中操作有如下關係:
  • 不允許load和read,store和write單獨出現。
  • 不允許一個執行緒丟棄它最近的assign操作,即變數在工作記憶體中改變,必須同步回主記憶體。
  • 不與許一個執行緒無原因的(沒有assign操作)把資料從工作記憶體同步回主記憶體。
  • 一個新的變數只能在主記憶體中誕生。
  • 一個變數只能同時有一個執行緒進行加鎖。lock可以被同一個執行緒加鎖多次,但是必須解鎖相同次數。這個變數才會被解鎖。
  • 對一個變數執行lock操作。將會先清空該執行緒的工作記憶體中的該變數的值。在執行引擎使用這個變數前,需要重新執行load或assign操作。
  • 一個變數被lock,不允許其它執行緒執行unlock。也不允許執行unlock被別的執行緒lock的變數。即一個執行緒自己lock的只有自己能unlock.
  • 一個變數unlock之前,工作記憶體中的資料必須同步回主記憶體。
這八種操作和其使用規則,決定了變數在工作記憶體和主記憶體之間的同步策略。

針對於volatile變數又有額外如下定義:
  1. volatile變數在use時,必須執行load操作。即每次使用volatile變數必須先從主記憶體中重新整理最新值。
  2. volatile變數在assign時,必須執行write操作。即每次對volatile進行賦值操作必須立馬同步回主記憶體。
針對volatile和普通變數,或者volatile變數和volatile變數一起使用時。

JVM在編譯期間也會針對volatile的重排加以干涉,干涉規則如下:

深入理解volatile

  1. 如果第二個操作時volatile寫操作,不管第一操作是什麼操作,都不能重排。
  2. 如果第一個操作時volatile讀操作,不管第二個操作時什麼操作,都不能重排。
  3. volatile寫和volatile讀不能重排。

為了實現這個語意,JVM在生成位元組碼時,會在指令序列中插入記憶體屏障(memory barrier)來禁止特定型別的處理器指令重排,對於編譯器來說對所有的CPU來插入屏障數最小的方案几乎不可能,下面是基於保守策略的JMM記憶體屏障插入策略:
  1. 在每個volatile寫操作前面插入StoreStore屏障
  2. 在每個volatile寫操作後插入StoreLoad屏障
  3. 在每個volatile讀後面插入一個LoadLoad屏障
  4. 在每個volatile讀後面插入一個LoadStore屏障

這裡要說下記憶體屏障是是什麼東西:硬體層的記憶體屏障分為兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障,記憶體屏障的作用有兩個:
  • 阻止屏障兩側的的指令重排
  • 強制把快取記憶體中的資料更新或者寫入到主存中。Load Barrier負責更新快取記憶體, Store Barrier負責將高速緩衝區的內容寫回主存

LoadLoad,StoreStore,LoadStore,StoreLoad實際上是Java對上面兩種屏障的組合,來完成一系列的屏障和資料同步功能:
  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。


深入理解volatile

  • StoreStore屏障可以保證在volatile寫之前,所有的普通寫操作已經對所有處理器可見,StoreStore屏障保障了在volatile寫之前所有的普通寫操作已經重新整理到主存。
  • StoreLoad屏障避免volatile寫與下面有可能出現的volatile讀/寫操作重排。因為編譯器無法準確判斷一個volatile寫後面是否需要插入一個StoreLoad屏障(寫之後直接就return了,這時其實沒必要加StoreLoad屏障),為了能實現volatile的正確記憶體語意,JVM採取了保守的策略。在每個volatile寫之後或每個volatile讀之前加上一個StoreLoad屏障,而大多數場景是一個執行緒寫volatile變數多個執行緒去讀volatile變數,同一時刻讀的執行緒數量其實遠大於寫的執行緒數量。選擇在volatile寫後面加入StoreLoad屏障將大大提升執行效率(上面已經說了StoreLoad屏障的開銷是很大的)。

深入理解volatile

  • LoadLoad屏障保證了volatile讀不會與下面的普通讀發生重排
  • LoadStore屏障保證了volatile讀不回與下面的普通寫發生重排。

即使JMM對volatile做了這麼多的工作,它也僅僅只保證了volatile變數在原子性操作下多個執行緒之間的正確同步,對非原子操作,使用volatile仍然會發生無法預知的結果。
比如對i++操作,在多執行緒情況下結果依然是不定:
例子:

深入理解volatile


我們來使用 javap -c 來看下這個檔案的編譯指令:

深入理解volatile

increase方法的編譯指令我們可以看出 ++ 操作經歷了4步:
1、getstatic #10 獲取靜態變數num壓入棧頂 此時volatile保證值是對的。
2、iconst_1 int型常量1入棧
3、iadd 棧頂兩個int值相加,結果放入棧頂。
4、putstatic #10 把棧頂的值負值給指定域。
問題就出在2、3兩步,在做這兩步操作時,volatile變數有可能已經被其它執行緒修改。

根據volatile的記憶體語意我們可以總結出兩條安全使用volatile的方式:
  • 運算結果不依賴於volatile變數的當前值,或者能保證只有單一執行緒能修改變數的值
  • 變數不需要與其它的狀態變數共同參與不變性。



相關文章