1. 程式人生 > >面試官最愛的 volatile 關鍵字,這些問題你都搞懂了沒?

面試官最愛的 volatile 關鍵字,這些問題你都搞懂了沒?

前言

volatile相關的知識點,在面試過程中,屬於基礎問題,是必須要掌握的知識點,如果回答不上來會嚴重扣分的哦。

volatile關鍵字基本介紹

volatile可以看成是synchronized的一種輕量級的實現,但volatile並不能完全代替synchronized,volatile有synchronized可見性的特性,但沒有synchronized原子性的特性。

可見性即用volatile關鍵字修飾的成員變量表明該變數不存在工作執行緒的副本,執行緒每次直接都從主記憶體中讀取,每次讀取的都是最新的值,這也就保證了變數對其他執行緒的可見性。

另外,使用volatile還能確保變數不能被重排序,保證了有序性。

  • 當一個變數定義為volatile之後,它將具備兩種特性:

    • 保證此變數對所有執行緒的可見性
    • 禁止指令重排序優化
  • volatile與synchronized的區別:

    • 1、volatile只能修飾例項變數和類變數,而synchronized可以修飾方法,以及程式碼塊。
    • 2、volatile保證資料的可見性,但是不保證原子性; 而synchronized是一種排他(互斥)的機制,既保證可見性,又保證原子性。
    • 3、volatile不會造成執行緒的阻塞;synchronized可能會造成執行緒的阻塞。
    • 4、volatile可以看做是輕量版的synchronized,volatile不保證原子性,但是如果是對一個共享變數進行多個執行緒的賦值,而沒有其他的操作,那麼就可以用volatile來代替synchronized,因為賦值本身是有原子性的,而volatile又保證了可見性,所以就可以保證執行緒安全了。

保證此變數對所有執行緒的可見性:

當一條執行緒修改了這個變數的值,新值對於其他執行緒可以說是可以立即得知的。Java記憶體模型規定了所有的變數都儲存在主記憶體,每條執行緒還有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒使用到的變數在主記憶體的副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀取主記憶體中的變數。

知識拓展:記憶體可見性:

  • 概念:JVM記憶體模型:主記憶體 和 執行緒獨立的 工作記憶體。Java記憶體模型規定,對於多個執行緒共享的變數,儲存在主記憶體當中,每個執行緒都有自己獨立的工作記憶體(比如CPU的暫存器),執行緒只能訪問自己的工作記憶體,不可以訪問其它執行緒的工作記憶體。工作記憶體中儲存了主記憶體共享變數的副本,執行緒要操作這些共享變數,只能通過操作工作記憶體中的副本來實現,操作完畢之後再同步回到主記憶體當中。
  • 如何保證多個執行緒操作主記憶體的資料完整性是一個難題,Java記憶體模型也規定了工作記憶體與主記憶體之間互動的協議,定義了8種原子操作:
    • lock:將主記憶體中的變數鎖定,為一個執行緒所獨佔。
    • unclock:將lock加的鎖定解除,此時其它的執行緒可以有機會訪問此變數。
    • read:將主記憶體中的變數值讀到工作記憶體當中。
    •  load:將read讀取的值儲存到工作記憶體中的變數副本中。
    • use:將值傳遞給執行緒的程式碼執行引擎。
    • assign:將執行引擎處理返回的值重新賦值給變數副本。
    • store:將變數副本的值儲存到主記憶體中。
    • write:將store儲存的值寫入到主記憶體的共享變數當中。

通過上面Java記憶體模型的概述,我們會注意到這麼一個問題,每個執行緒在獲取鎖之後會在自己的工作記憶體來操作共享變數,操作完成之後將工作記憶體中的副本回寫到主記憶體,並且在其它執行緒從主記憶體將變數同步回自己的工作記憶體之前,共享變數的改變對其是不可見的。

即其他執行緒的本地記憶體中的變數已經是過時的,並不是更新後的值。volatile保證可見性的原理是在每次訪問變數時都會進行一次重新整理,因此每次訪問都是主記憶體中最新的版本。所以volatile關鍵字的作用之一就是保證變數修改的實時可見性。

即,volatile的特殊規則就是:

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

所以,使用volatile變數能夠保證:

  • 每次讀取前必須先從主記憶體重新整理最新的值。
  • 每次寫入後必須立即同步回主記憶體當中。

也就是說,volatile關鍵字修飾的變數看到的是自己的最新值。執行緒1中對變數v的最新修改,對執行緒2是可見的。

禁止指令重排序優化:

volatile boolean isOK = false;

//假設以下程式碼線上程A執行
A.init();
isOK=true;

//假設以下程式碼線上程B執行
while(!isOK){
  sleep();
}
B.init();

 

A執行緒在初始化的時候,B執行緒處於睡眠狀態,等待A執行緒完成初始化的時候才能夠進行自己的初始化。這裡的先後關係依賴於isOK這個變數。

如果沒有volatile修飾isOK這個變數,那麼isOK的賦值就可能出現在A.init()之前(指令重排序,Java虛擬機器的一種優化措施),此時A沒有初始化,而B的初始化就破壞了它們之前形成的那種依賴關係,可能就會出錯。

知識拓展:指令重排序:

  • 概念:指令重排序是JVM為了優化指令,提高程式執行效率,在不影響 單執行緒程式 執行結果的前提下,儘可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單執行緒。多執行緒的情況下指令重排序就會給程式帶來問題。

不同的指令間可能存在資料依賴。比如下面的語句:

  int l = 3; // (1)
  int w = 4; // (2)
  int s = l * w; // (3)

 

面積的計算依賴於l與w兩個變數的賦值指令。而l與w無依賴關係。

重排序會遵守兩個規則:

  • as-if-serial規則:as-if-serial規則是指不管如何重排序(編譯器與處理器為了提高並行度),(單執行緒)程式的結果不能被改變。這是編譯器、Runtime、處理器必須遵守的語義。
  • happens-before規則:
    • 程式順序規則:一個執行緒中的每個操作,happens-before於執行緒中的任意後續操作。
    • 監視器鎖規則:一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
    • volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
    • 傳遞性:如果(A)happens-before(B),且(B)happens-before(C),那麼(A)happens-before(C)。
    • 執行緒start()規則:主執行緒A啟動執行緒B,執行緒B中可以看到主執行緒啟動B之前的操作。也就是start() happens-before 執行緒B中的操作。
    • 執行緒join()規則:主執行緒A等待子執行緒B完成,當子執行緒B執行完畢後,主執行緒A可以看到執行緒B的所有操作。也就是說,子執行緒B中的任意操作,happens-before join()的返回。
    • 中斷規則:一個執行緒呼叫另一個執行緒的interrupt,happens-before於被中斷的執行緒發現中斷。
    • 終結規則:一個物件的建構函式的結束,happens-before於這個物件finalizer的開始。
    • 概念:前一個操作的結果可以被後續的操作獲取。講直白點就是前面一個操作把變數a賦值為1,那後面一個操作肯定能知道a已經變成了1。
    • happens-before(先行發生)規則如下:

雖然,(1)-happensbefore ->(2),(2)-happens before->(3),但是計算順序(1)(2)(3)與(2)(1)(3)對於l、w、area變數的結果並無區別。編譯器、Runtime在優化時可以根據情況重排序(1)與(2),而絲毫不影響程式的結果。

  • volatile使用場景:
    • 1、對變數的寫操作不依賴當前變數的值。
    • 2、該變數沒有包含在其他變數的不變式中。
    • 如果正確使用volatile的話,必須依賴下以下種條件:

也可以這樣理解,就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程式在併發時能夠正確執行。

第一個條件的限制使 volatile 變數不能用作執行緒安全計數器。雖然增量操作(i++)看上去類似一個單獨操作,實際上它是一個由(讀取-修改-寫入)操作序列組成的組合操作,必須以原子方式執行,而 volatile 不能提供必須的原子特性。

實現正確的操作需要使 i 的值在操作期間保持不變,而 volatile 變數無法實現這點。

  • 在以下兩種情況下都必須使用volatile:
    • 1、狀態的改變。
    • 2、讀多寫少的情況。

具體如下:

// 場景一:狀態改變

/**
 * 雙重檢查(DCL)
 */
public class Sun {
  private static volatile Sun sunInstance;

  private Sun() {
  }

  public static Sun getSunInstance() {
    if (sunInstance == null) {
      synchronized (Sun.class) {
        if (sunInstance == null){
          sunInstance = new Sun();
        }
      }
    }
    return sunInstance;
  }
}

// 場景二:讀多寫少

public class VolatileTest {
    private volatile int value;

    //讀操作,沒有synchronized,提高效能
    public int getValue() {
        return value;
    }

    //寫操作,必須synchronized。因為x++不是原子操作
    public synchronized int increment() {
        return value++;
    }
}

 

問題來了,volatile是如何防止指令重排序優化的呢?

答:

volatile關鍵字通過 “記憶體屏障” 的方式來防止指令被重排序,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。大多數的處理器都支援記憶體屏障的指令。

對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略:

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

知識拓展:記憶體屏障:

記憶體屏障(Memory Barrier,或有時叫做記憶體柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和記憶體可見性問題。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的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。