天天看點

面試必問的 volatile 關鍵字,通俗易懂,看完還不懂你打我

volatile關鍵字是由JVM提供的最輕量級同步機制。與被濫用的synchronized不同,我們并不習慣使用它。想要正确且完全的了解它并不容易。

Java記憶體模型

Java記憶體模型由Java虛拟機規範定義,用來屏蔽各個平台的硬體差異。簡單來說:

所有變量儲存在主記憶體。

每條線程擁有自己的工作記憶體,其中儲存了主記憶體中線程使用到的變量的副本。

線程不能直接讀寫主記憶體中的變量,所有操作均在工作記憶體中完成。

線程,主記憶體,工作記憶體的互動關系如圖。

面試必問的 volatile 關鍵字,通俗易懂,看完還不懂你打我

記憶體間的互動操作有很多,和volatile有關的操作為:

read(讀取):作用于主記憶體變量,把一個變量值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用

load(載入):作用于工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。

use(使用):作用于工作記憶體的變量,把工作記憶體中的一個變量值傳遞給執行引擎,每當虛拟機遇到一個需要使用變量的值的位元組碼指令時将會執行這個操作。

assign(指派):作用于工作記憶體的變量,它把一個從執行引擎接收到的值指派給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作。

store(存儲):作用于工作記憶體的變量,把工作記憶體中的一個變量的值傳送到主記憶體中,以便随後的write的操作。

write(寫入):作用于主記憶體的變量,它把store操作從工作記憶體中一個變量的值傳送到主記憶體的變量中。

對被volatile修飾的變量進行操作時,需要滿足以下規則:

規則1:線程對變量執行的前一個動作是load時才能執行use,反之隻有後一個動作是use時才能執行load。線程對變量的read,load,use動作關聯,必須連續一起出現。-----這保證了線程每次使用變量時都需要從主存拿到最新的值,保證了其他線程修改的變量本線程能看到。

規則2:線程對變量執行的前一個動作是assign時才能執行store,反之隻有後一個動作是store時才能執行assign。線程對變量的assign,store,write動作關聯,必須連續一起出現。-----這保證了線程每次修改變量後都會立即同步回主記憶體,保證了本線程修改的變量其他線程能看到。

規則3:有線程T,變量V、變量W。假設動作A是T對V的use或assign動作,P是根據規則2、3與A關聯的read或write動作;動作B是T對W的use或assign動作,Q是根據規則2、3與B關聯的read或write動作。如果A先與B,那麼P先與Q。------這保證了volatile修飾的變量不會被指令重排序優化,代碼的執行順序與程式的順序相同。

使用volatile關鍵字的特性

1.被volatile修飾的變量保證對所有線程可見。

由上文的規則1、2可知,volatile變量對所有線程是立即可見的,在各個線程中不存在一緻性問題。那麼,我們是否能得出結論:volatile變量在并發運算下是線程安全的呢?

這确實是一個非常常見的誤解,寫個簡單的例子:

public class VolatileTest extends Thread{
    static volatile int increase = 0;
    static AtomicInteger aInteger=new AtomicInteger();//對照組
    static void increaseFun() {
        increase++;
        aInteger.incrementAndGet();
    }
    public void run(){
        int i=0;
        while (i < 10000) {
            increaseFun();
            i++;
        }
    }
    public static void main(String[] args) {
        VolatileTest vt = new VolatileTest();
        int THREAD_NUM = 10;
        Thread[] threads = new Thread[THREAD_NUM];
        for (int i = 0; i < THREAD_NUM; i++) {
            threads[i] = new Thread(vt, "線程" + i);
            threads[i].start();
        }
        //idea中會傳回主線程和守護線程,如果用Eclipse的話改為1
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("volatile的值: "+increase);
        System.out.println("AtomicInteger的值: "+aInteger);
    }
}      

這個程式我們跑了10個線程同時對volatile修飾的變量進行10000的自增操作(AtomicInteger實作了原子性,作為對照組),如果volatile變量是并發安全的話,運作結果應該為100000,可是多次運作後,每次的結果均小于預期值。顯然上文的說法是有問題的。

面試必問的 volatile 關鍵字,通俗易懂,看完還不懂你打我

volatile修飾的變量并不保值原子性,是以在上述的例子中,用volatile來保證線程安全不靠譜。我們用Javap對這段代碼進行反編譯,為什麼不靠譜簡直一目了然:

面試必問的 volatile 關鍵字,通俗易懂,看完還不懂你打我

getstatic指令把increase的值拿到了操作棧的頂部,此時由于volatile的規則,該值是正确的。

iconst_1和iadd指令在執行的時候increase的值很有可能已經被其他線程加大,此時棧頂的值過期。

putstatic指令接着把過期的值同步回主存,導緻了最終結果較小。

volatile關鍵字隻保證可見性,是以在以下情況中,需要使用鎖來保證原子性:

運算結果依賴變量的目前值,并且有不止一個線程在修改變量的值。

變量需要與其他狀态變量共同參與不變限制

那麼volatile的這個特性的使用場景是什麼呢?

模式1:狀态标志

模式2:獨立觀察(independent observation)

模式3:“volatile bean” 模式

模式4:開銷較低的“讀-寫鎖”政策

具體場景:

https://blog.csdn.net/vking_wang/article/details/9982709

2.禁止指令重排序優化。

由上文的規則3可知,volatile變量的第二個語義是禁止指令重排序。指令重排序是什麼?簡單點說就是

jvm會把代碼中沒有依賴指派的地方打亂執行順序,由于一些規則限定,我們在單線程内觀察不到打亂的現象(線程内表現為串行的語義),但是在并發程式中,從别的線程看另一個線程,操作是無序的。

一個非常經典的指令重排序例子:

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}      

總結

并發三特征可見性和有序性和原子性中,volatile通過新值立即同步到主記憶體和每次使用前從主記憶體重新整理機制保證了可見性。通過禁止指令重排序保證了有序性。無法保證原子性。

而我們知道,synchronized關鍵字通過lock和unlock操作保證了原子性,通過對一個變量unlock前,把變量同步回主記憶體中保證了可見性,通過一個變量在同一時刻隻允許一條線程對其進行lock操作保證了有序性。

他的“萬能”也間接導緻了我們對synchronized關鍵字的濫用,越泛用的控制,對性能的影響也越大,雖然jvm不斷的對synchronized關鍵字進行各種各樣的優化,但是我們還是要在合适的時候想起volatile關鍵字啊,哈哈哈哈。