Java 深刻理解volatile關鍵字

咱們知道Java 中volatile實現了修飾變量的原子性以及可見性,而且爲了實現多線程環境下的線程安全,禁止了指令重排。程序員

首先咱們先來了解一下happens-before原則、as-if-serial語義以及數據依賴性,引用自《Java併發編程的藝術》編程

happens-before簡介

從JDK 5開始,Java使用新的JSR-133內存模型(除非特別說明,本文針對的都是JSR-133內存模型)。JSR-133使用happens-before的概念來闡述操做之間的內存可見性。在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必需要存在happens-before關係。這裏提到的兩個操做既能夠是在一個線程以內,也能夠是在不一樣線程之間。緩存

與程序員密切相關的happens-before規則以下。安全

·程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。多線程

·監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。併發

·volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。app

·傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。jvm

注意 兩個操做之間具備happens-before關係,並不意味着前一個操做必需要在後一個優化

操做以前執行!happens-before僅僅要求前一個操做(執行的結果)對後一個操做可見,且前一個操做按順序排在第二個操做以前(the first is visible to and ordered before the second)。spa

-----------------------------------------------------------------------------------------------------------------------------------------

as-if-serial語義

as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。

爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就可能被編譯器和處理器重排序。

-----------------------------------------------------------------------------------------------------------------------------------------

數據依賴性

若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。數據依賴分爲下列3種類型,如表所示。  

上面3種狀況,只要重排序兩個操做的執行順序,程序的執行結果就會被改變。

編譯器和處理器可能會對操做作重排序。編譯器和處理器在重排序時,會遵照數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操做的執行順序 。

這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮。

-----------------------------------------------------------------------------------------------------------------------------------------

根據上面的一些描述,咱們或多或少的理解了一些事情:

  • JVM虛擬機會對咱們的代碼進行指令重排,以致於咱們的代碼執行順序並不必定與咱們的代碼順序一致,可是在單線程狀況下保證執行結果不變
  • JVM虛擬機在進行指令重排時,不會對有數據依賴關係的變量進行指令重排
  • 指令重排後執行的代碼在多線程環境下不保證結果不變

上面的狀況說明了咱們的程序在多線程的環境下運行,每次結果可能不相同,也就是線程不安全。

如今讓咱們來看看,重排序是否會改變多線程程序的執行結果。請看下面的示例代碼。

flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操做4時,可否看到線程A在操做1對共享變量a的寫入呢?

答案是:不必定能看到。

因爲操做1和操做2沒有數據依賴關係,編譯器和處理器能夠對這兩個操做重排序;一樣,操做3和操做4沒有數據依賴關係,編譯器和處理器也能夠對這兩個操做重排序。讓咱們先來看看,當操做1和操做2重排序時,可能會產生什麼效果?請看下面的程序執行時序圖,如圖所示。

如圖所示,操做1和操做2作了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。因爲條件判斷爲真,線程B將讀取變量a。此時,變量a尚未被線程A寫入,在這裏多線程程序的語義被重排序破壞了!

注意 本文統一用虛箭線標識錯誤的讀操做,用實箭線標識正確的讀操做。

下面再讓咱們看看,當操做3和操做4重排序時會產生什麼效果(藉助這個重排序,能夠順便說明控制依賴性)。下面是操做3和操做4重排序後,程序執行的時序圖,如圖所示。

在程序中,操做3和操做4存在控制依賴關係。當代碼中存在控制依賴性時,會影響指令序列執行的並行度。爲此,編譯器和處理器會採用猜想(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜想執行爲例,執行線程B的處理器能夠提早讀取並計算a*a,而後把計算結果臨時保存到一個名爲重排序緩衝(Reorder Buffer,ROB)的硬件緩存中。當操做3的條件判斷爲真時,就把該計算結果寫入變量i中。

從圖中咱們能夠看出,猜想執行實質上對操做3和4作了重排序。重排序在這裏破壞了多線程程序的語義!

在單線程程序中,對存在控制依賴的操做重排序,不會改變執行結果(這也是as-if-serial語義容許對存在控制依賴的操做作重排序的緣由);但在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。

那麼volatile是如何作到禁止指令重排的呢?

內存屏障

jvm經過內存屏障來禁止指令重排,內存屏障類型表以下

StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。現代的多處理器大多支持該屏障(其餘類型的屏障不必定被全部處理器支持)。執行該屏障開銷會很昂貴,由於當前處理器一般要把寫緩衝區中的數據所有刷新到內存中(Buffer Fully Flush)。

volatile內存語義的實現

下面來看看JMM如何實現volatile寫/讀的內存語義。

前文提到太重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。表3-5是JMM針對編譯器制定的volatile重排序規則表。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。

  • 在每一個volatile寫操做的前面插入一個StoreStore屏障。
  • 在每一個volatile寫操做的後面插入一個StoreLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadStore屏障。

上述內存屏障插入策略很是保守,但它能夠保證在任意處理器平臺,任意的程序中都能獲得正確的volatile內存語義。

下面是保守策略下,volatile寫插入內存屏障後生成的指令序列示意圖,如圖所示。

圖中的StoreStore屏障能夠保證在volatile寫以前,其前面的全部普通寫操做已經對任意處理器可見了。這是由於StoreStore屏障將保障上面全部的普通寫在volatile寫以前刷新到主內存。

這裏比較有意思的是,volatile寫後面的StoreLoad屏障。此屏障的做用是避免volatile寫與後面可能有的volatile讀/寫操做重排序。由於編譯器經常沒法準確判斷在一個volatile寫的後面是否須要插入一個StoreLoad屏障(好比,一個volatile寫以後方法當即return)。爲了保證能正確實現volatile的內存語義,JMM在採起了保守策略:在每一個volatile寫的後面,或者在每一個volatile讀的前面插入一個StoreLoad屏障。從總體執行效率的角度考慮,JMM最終選擇了在每一個volatile寫的後面插入一個StoreLoad屏障。由於volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫以後插入StoreLoad屏障將帶來可觀的執行效率的提高。從這裏能夠看到JMM在實現上的一個特色:首先確保正確性,而後再去追求執行效率。

下面是在保守策略下,volatile讀插入內存屏障後生成的指令序列示意圖,如圖所示。

圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

編譯器不會對volatile讀與volatile讀後面的任意內存操做重排序;編譯器不會對volatile寫與volatile寫前面的任意內存操做重排序。

上述volatile寫和volatile讀的內存屏障插入策略很是保守。在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器能夠根據具體狀況省略沒必要要的屏障。下面經過具體的示例代碼進行說明。

針對readAndWrite()方法,編譯器在生成字節碼時能夠作以下的優化。

注意,最後的StoreLoad屏障不能省略。由於第二個volatile寫以後,方法當即return。此時編譯器可能沒法準確判定後面是否會有volatile讀或寫,爲了安全起見,編譯器一般會在這裏插入一個StoreLoad屏障。上面的優化針對任意處理器平臺,因爲不一樣的處理器有不一樣「鬆緊度」的處理器內存模型,內存屏障的插入還能夠根據具體的處理器內存模型繼續優化。

有了這些內存屏障的保證,volatile繼而實現了禁止指令重排序。

參考:

《Java併發編程的藝術》

相關文章
相關標籤/搜索