面試中的volatile關鍵字


Java的面試當中,面試官最愛問的就是volatile關鍵字相關的內容。經過多次面試之后,你是否思考過,為什么他們那么愛問volatile關鍵字相關的問題?而對於你,如果作為面試官,是否也會考慮采用volatile關鍵字作為切入點呢?

為什么愛問volatile關鍵字

愛問volatile關鍵字的面試官,大多數情況都是有一定功底的,因為volatile作為切入點,往底層走可以切入Java內存模型(JMM),往並發方向走又可切入Java並發編程。當然,如果再深入追究,JVM的底層操作、字節碼的操作、單例都可以牽扯出來。

所以說懂的人提問都是有門道的。那么,先整體來看看volatile關鍵字都涉及到哪些點:內存可見性(JMM特性)、原子性(JMM特性)、禁止指令重排、線程並發、與synchronized的區別.....再往深層挖,可能涉及到字節碼和JVM等。

面試官:說說volatile關鍵字的特性

volatile修飾的共享變量,就具有了以下兩點特性:

  • 保證了不同線程對該變量操作的內存可見性
  • 禁止指令重排序

基本上大家看過面試題都可以回答出這兩點,點出了volatile關鍵字兩大特性。針對這兩大特性繼續深入。

面試官:什么是內存可見性?能否舉例說明?

該問題涉及到Java內存模型(JVM)和它的內存可見性。

內存模型:Java虛擬機規范試圖定義一種Java內存模型(JMM),來屏蔽掉各種硬件和操作系統的內存訪問差距,讓Java程序在各種平台上都能達到一致的內存訪問效果。

微信圖片_20200503170304.png

Java內存模型是通過變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值,將主內存作為傳遞媒介。可以舉例說明內存可見性的過程。

test

本地內存AB有主內存中共享變量x的副本,初始值都為0。線程A執行之后把x更新為1,存放在本地內存中A中。當線程A和線程B需要通信時,線程A首先會把本地內存中x=1值刷新到主內存中,主內存的值變為1。隨后,線程B到主內存中去讀取更新后的x值,線程B的本地內存的x值也變為了1

最后再說可見性:可見性是指一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。

無論普通變量還是volatile變量都是如此,只不過volatile
變量保證新值能夠立馬同步到主內存,使用時也立即從主內存中刷新,保證了多線程操作時變量的可見性。而普通變量不能夠保證。

面試官:提到JMM和可見性,能說說JMM的其他特性嗎?

我們知道JMM除了可見性,還有原子性和有序性。

原子性即一個操作或一系列操作是不可中斷的。即使是在多線程的情況下,操作一旦開始,就不會被其他線程干擾。

比如,對於一個靜態變量int x兩條線程同時對其賦值,線程A賦值為1,而線程B賦值為2,不管線程如何運行,最終值要么為1,要么是2,線程A和線程B間的操作是沒有干擾的,這就是原子性操作,是不可被中斷的。

Java內存模型中有序性可歸納為這樣一句話:如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另外一個線程,所有操作都是無序的。

有序性是指對於單線程的執行代碼,執行是按順序依次進行的。但在多線程環境中,則可能出現亂序現象,因為在編譯過程中會出現“指令重排”,重排后的指令與原指令的順序未必一致。

因此,上面歸納的前半句指的是線程內保證串行語義執行,后半句則指“指令重排”現象和“工作內存與主內存同步延遲”現象。

面試官:你多次提到指令重排,能舉例說明嗎?

CPU和編譯器為了提高程序執行的效率,會按照一定的規則允許進行指令優化。但代碼邏輯之間是存在一定的先后順序,並發執行時按照不同的執行邏輯會得到不同的結果。

舉例說明多線程中可能出現的重排現象:

public class  ReOrderDemo{
    int a = 0;
    boolean flag = false;

    public void write(){
        a = 1;              //1
        flag = true;        //2
    }

    public void read(){
        if (flag){          //3
            int i = a * a;  //4
        }
    }
}

在上面的代碼中,單線程執行時,read方法能夠獲取flag的值進行判斷,獲得預期的結果。但在多線程的情況下就可能出現不同的結果。比如,當線程A進行write操作時,由於指令重排,write中的代碼執行順序可能會變成下面這樣:

a = 1;              //1
flag = true;        //2

也就是說可能會先對flag賦值,然后再對a賦值。這在單線程並不影響最終輸出的結果。

但如果與此同時,B線程在調用read方法,那么就有可能出現flagtruea還是0,這時進入第4步操作的結果就為0,而不是預期的1了。

volatile關鍵字修飾的變量,會禁止指令重排的操作,從而在一定程度上避免了多線程中的問題。

面試官:volatile能保證原子性嗎?

volatile保證了可見性和有序性(禁止指令重排),那么能否保證原子性呢?

volatile不能保證原子性,它只是對單個volatile變量的讀/寫具有原子性,但是對於類似i++的復合操作就無法保證了。

如下代碼,從直觀上來講,感覺輸出結果為100,但實際上並不能保證,就是因為inc++操作屬於復合操作。

public class Test {
    public volatile int inc = 0;

    public void increase(){
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10; j++) {
                    test.increase();
                }
            }).start();
        }

        //保證前面的進程都執行完
        while (Thread.activeCount() > 2){
            Thread.yield();
        }

        System.out.println(test.inc);

    }
}

假設線程A,讀取了inc的值為10,然后被阻塞, 因未對變量進行修改,未觸發volatile規則。線程B此時也讀取inc的值,主存里的值依舊是10,做自增,然后立刻寫會主存,值為11。此時線程A執行,由於工作內存里保存的是10,所以繼續做自增,再寫回主存,11此時又被寫了一遍。所以雖然兩個線程執行了兩次increase(),結果卻只加了一次。

有人說,volatile不是會使緩存行無效的嗎?但是這里線程A讀取之后並沒有修改inc值,線程B讀取時依舊會是10。又有人說,線程B11寫會內存,不會把線程A的緩存行設為無效嗎?只有在做讀取操作時,發現自己緩存行無效,才會去讀主存的值,而線程A的讀取操作在線程B寫入之前已經做過了,所以這里線程A只能繼續做自增了。

針對這種情況,只能使用synchronizedLock或並發包下的atomic的原子操作類。

面試官:剛提到synchronized,能說說他們之間的區別嗎?

  • volatile本質是在告訴JVM當前變量寄存器(工作內存)中的值是不確定的,需要從主存中讀取;synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。
  • volatile僅能使用在變量級別;synchronized則可以使用在變量、方法和類級別上
  • volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性
  • volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞
  • volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化

面試官:還能舉出其他例子說明volatile的作用嗎?

單例模式的實現,典型的雙重檢查鎖定(DCL):

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton(){
    }

    public static Singleton getInstance(){
        if (instance == null){                  //1
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new Singleton(); //2
                }
            }
        }
        return instance;
    }
}

這是一種懶漢的單例模型,使用時才創建對象,而且為了避免初始化操作的指令重排序,給instance加上了volatile

為什么用了synchronized還要用volatile?具體來說就是synchronized雖然保證了原子性,但卻沒保證指令重排序的正確性,會出現A線程執行初始化,但可能因為構造函數里面的操作太多了,所以A線程的instance還沒有造出來,但已經被賦值了(即代碼中2操作,先分配內存空間后構建對象)。

B線程這時過來了(代碼1操作,發現instance不為null),錯以為instance已經被實例化出來,一用才發現instance尚未被初始化。要知道我們的線程雖然可以保證原子性,但程序可能是在多核CPU上執行。

總結

當然,針對volatile關鍵字還有其他方面的拓展,比如講到JMM時可拓展到JMMJava內存模型的區別,講到原子性時可拓展到如何如何查看class字節碼,講到並發可拓展到線程並發。

其實,不僅面試如此,在學習知識時也可以參考這種面試思維,多問幾個為什么。將一個點,通過為什么展成面,這樣就可以形成自己的知識網絡。


免責聲明!

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



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