面試官最愛的 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的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM