volatile關鍵字的做用、原理

在只有雙重檢查鎖,沒有volatile的懶加載單例模式中,因爲指令重排序的問題,我確實不會拿到兩個不一樣的單例了,但我會拿到「半個」單例html

而發揮神奇做用的volatile,能夠當之無愧的被稱爲Java併發編程中「出現頻率最高的關鍵字」,經常使用於保持內存可見性和防止指令重排序。java

保持內存可見性

內存可見性(Memory Visibility):全部線程都能看到共享內存的最新狀態。面試

失效數據

如下是一個簡單的可變整數類:編程

public class MutableInteger { private int value; public int get(){ return value; } public void set(int value){ this.value = value; } }

MutableInteger不是線程安全的,由於getset方法都是在沒有同步的狀況下進行的。若是線程1調用了set方法,那麼正在調用的get的線程2可能會看到更新後的value值,也可能看不到設計模式

解決方法很簡單,將value聲明爲volatile變量:安全

private volatile int value;

神奇的volatile關鍵字

神奇的volatile關鍵字解決了神奇的失效數據問題。多線程

Java變量的讀寫

Java經過幾種原子操做完成工做內存主內存的交互:併發

  1. lock:做用於主內存,把變量標識爲線程獨佔狀態。
  2. unlock:做用於主內存,解除獨佔狀態。
  3. read:做用主內存,把一個變量的值從主內存傳輸到線程的工做內存。
  4. load:做用於工做內存,把read操做傳過來的變量值放入工做內存的變量副本中。
  5. use:做用工做內存,把工做內存當中的一個變量值傳給執行引擎。
  6. assign:做用工做內存,把一個從執行引擎接收到的值賦值給工做內存的變量。
  7. store:做用於工做內存的變量,把工做內存的一個變量的值傳送到主內存中。
  8. write:做用於主內存的變量,把store操做傳來的變量的值放入主內存的變量中。

volatile如何保持內存可見性

volatile的特殊規則就是:app

  • read、load、use動做必須連續出現
  • assign、store、write動做必須連續出現

因此,使用volatile變量可以保證:優化

  • 每次讀取前必須先從主內存刷新最新的值。
  • 每次寫入後必須當即同步回主內存當中。

也就是說,volatile關鍵字修飾的變量看到的隨時是本身的最新值。線程1中對變量v的最新修改,對線程2是可見的。

防止指令重排

在基於偏序關係Happens-Before內存模型中,指令重排技術大大提升了程序執行效率,但同時也引入了一些問題。

一個指令重排的問題——被部分初始化的對象

懶加載單例模式和競態條件

一個懶加載單例模式實現以下:

class Singleton { private static Singleton instance; private Singleton(){} public static Singleton getInstance() { if ( instance == null ) { //這裏存在競態條件 instance = new Singleton(); } return instance; } }

競態條件會致使instance引用被屢次賦值,使用戶獲得兩個不一樣的單例。

DCL和被部分初始化的對象

爲了解決這個問題,可使用synchronized關鍵字將getInstance方法改成同步方法;但這樣串行化的單例是不能忍的。因此我猿族前輩設計了DCL(Double Check Lock,雙重檢查鎖)機制,使得大部分請求都不會進入阻塞代碼塊:

class Singleton { private static Singleton instance; private Singleton(){} public static Singleton getInstance() { if ( instance == null ) { //當instance不爲null時,仍可能指向一個「被部分初始化的對象」 synchronized (Singleton.class) { if ( instance == null ) { instance = new Singleton(); } } } return instance; } }

「看起來」很是完美:既減小了阻塞,又避免了競態條件。不錯,但實際上仍然存在一個問題——當instance不爲null時,仍可能指向一個"被部分初始化的對象"

問題出在這行簡單的賦值語句:

instance = new Singleton();

它並非一個原子操做。事實上,它能夠」抽象「爲下面幾條JVM指令:

memory = allocate();    //1:分配對象的內存空間 initInstance(memory); //2:初始化對象 instance = memory; //3:設置instance指向剛分配的內存地址

上面操做2依賴於操做1,可是操做3並不依賴於操做2,因此JVM能夠以「優化」爲目的對它們進行重排序,通過重排序後以下:

memory = allocate();    //1:分配對象的內存空間 instance = memory; //3:設置instance指向剛分配的內存地址(此時對象還未初始化) ctorInstance(memory); //2:初始化對象

能夠看到指令重排以後,操做 3 排在了操做 2 以前,即引用instance指向內存memory時,這段嶄新的內存尚未初始化——即,引用instance指向了一個"被部分初始化的對象"。此時,若是另外一個線程調用getInstance方法,因爲instance已經指向了一塊內存空間,從而if條件判爲false,方法返回instance引用,用戶獲得了沒有完成初始化的「半個」單例。
解決這個該問題,只須要將instance聲明爲volatile變量:

private static volatile Singleton instance;

也就是說,在只有DCL沒有volatile的懶加載單例模式中,仍然存在着併發陷阱。我確實不會拿到兩個不一樣的單例了,但我會拿到「半個」單例(未完成初始化)。
然而,許多面試書籍中,涉及懶加載的單例模式最多深刻到DCL,卻隻字不提volatile。這「看似聰明」的機制,曾經被我廣大初入Java世界的猿胞大加吹捧——我在大四實習面試跟誰學的時候,也得意洋洋的從飽漢、餓漢講到Double Check,如今看來真是傻逼。對於考查併發的面試官而言,單例模式的實現就是一個很好的切入點,看似考查設計模式,其實指望你從設計模式答到併發和內存模型。

volatile如何防止指令重排

volatile關鍵字經過「內存屏障」來防止指令被重排序。

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

下面是基於保守策略的JMM內存屏障插入策略:

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

進階

在一次回答上述問題時,忘記了解釋一個很容易引發疑惑的問題:

若是存在這種重排序問題,那麼synchronized代碼塊內部不是也可能出現相同的問題嗎?

即這種狀況:

class Singleton { ... if ( instance == null ) { //可能發生不指望的指令重排 synchronized (Singleton.class) { if ( instance == null ) { instance = new Singleton(); System.out.println(instance.toString()); //程序順序規則發揮效力的地方 } } } ... }

難道調用instance.toString()方法時,instance也可能未完成初始化嗎?

首先還請放寬心,synchronized代碼塊內部雖然會重排序,但不會在代碼塊的範圍內致使線程安全問題

Happens-Before內存模型和程序順序規則

程序順序規則:若是程序中操做A在操做B以前,那麼線程中操做A將在操做B以前執行。

前面說過,只有在Happens-Before內存模型中才會出現這樣的指令重排序問題。Happens-Before內存模型維護了幾種Happens-Before規則,程序順序規則最基本的規則。程序順序規則的目標對象是一段程序代碼中的兩個操做A、B,其保證此處的指令重排不會破壞操做A、B在代碼中的前後順序,但與不一樣代碼甚至不一樣線程中的順序無關

所以,在synchronized代碼塊內部,instance = new Singleton()仍然會指令重排序,但重排序以後的全部指令,仍然可以保證在instance.toString()以前執行。進一步的,單線程中,if ( instance == null )能保證在synchronized代碼塊以前執行;但多線程中,線程1中的if ( instance == null )卻與線程2中的synchronized代碼塊之間沒有偏序關係,所以線程2中synchronized代碼塊內部的指令重排對於線程1是不指望的,致使了此處的併發陷阱。

相似的Happens-Before規則還有volatile變量規則監視器鎖規則等。程序猿能夠藉助(Piggyback)現有的Happens-Before規則來保持內存可見性和防止指令重排。

注意點

上面簡單講解了volatile關鍵字的做用和原理,但對volatile的使用過程當中很容易出現的一個問題是:

錯把volatile變量當作原子變量。

出現這種誤解的緣由,主要是volatile關鍵字使變量的讀、寫具備了「原子性」。然而這種原子性僅限於變量(包括引用)的讀和寫,沒法涵蓋變量上的任何操做,即:

  • 基本類型的自增(如count++)等操做不是原子的。
  • 對象的任何非原子成員調用(包括成員變量成員方法)不是原子的。

若是但願上述操做也具備原子性,那麼只能採起鎖、原子變量更多的措施。

總結

綜上,其實volatile保持內存可見性和防止指令重排序的原理,本質上是同一個問題,也都依靠內存屏障獲得解決。更多內容請參見JVM相關書籍。

參考:https://www.cnblogs.com/monkeysayhi/p/7654460.html

相關文章
相關標籤/搜索