1. 程式人生 > >輕量級的同步機制——volatile語義詳解(可見性保證+禁止指令重排)

輕量級的同步機制——volatile語義詳解(可見性保證+禁止指令重排)

sta 指令重排 指向 runnable single 能夠 工作 star image

1.關於volatile

volatile是java語言中的關鍵字,用來修飾會被多線程訪問的共享變量,是JVM提供的輕量級的同步機制,相比同步代碼塊或者重入鎖有更好的性能。它主要有兩重語義,一是保證多個線程對共享變量訪問的可見性,二防止指令重排序。

2.語義一:內存可見性

2.1 一個例子

public class TestVolatile {

    public static void main(String[] args) throws InterruptedException {

        ThreadDemo threadDemo = new ThreadDemo();
        new Thread(threadDemo).start();
        threadDemo.flag = false;
        System.out.println("已將flag置為" + threadDemo.flag);

    }

    static class ThreadDemo implements Runnable {

        boolean flag = true;

        @Override
        public void run() {
            System.out.println("Flag=" + flag);
        }

    }
}

當你多次執行代碼時,有一定幾率會出現這種結果
技術分享圖片

在主線程將子線程實例的flag置為false後,子線程中的flag竟然還是true。這是怎麽回事?這就是多線程的內存可見性問題。對於一個沒有volatile修飾的的共享變量,當一個線程對其進行了修改,另一線程並不一定能馬上看見這個被修改後的值。為什麽會出現這種情況呢?這就要從java的內存模型談起。

2.2 java的內存模型(JMM)

java的內存模型定義了線程和主內存之間的抽象關系,它的內容主要包括:

  • 主要由多線程共享的主內存和各線程私有的工作內存組成(工作內存是個抽象概念,並不真實存在,是對緩沖區,cpu寄存器等的抽象)
  • 變量都存儲於主內存中,但是線程的工作內存中保存著要使用的變量在主內存中的副本。
  • 線程對變量的操作必須在工作內存中進行,不同的線程無法直接訪問對方的工作內存,相互通信必須經過主內存。

線程,主內存,工作內存三者的交互關系如圖所示
技術分享圖片

看看JMM模型會給我們在多線程環境下的讀寫帶來什麽樣的問題。

  • 當一個線程(記為A)對共享變量進行修改時,修改的並不是主內存中的變量,而是該線程對應的工作內存中該變量的一個副本。
  • 當主內存中的變量值已經被修改,另一個線程讀取的卻還是自己工作內存中的舊值。

這時就出現了共享變量在多線程環境下的可見性問題。如果把線程的工作內存當作主內存的緩存,這個問題的本質就在於如何解決緩存失效問題。那麽JMM中是如何解決可見性問題的?這就不得不提到happens-before規則。

2.3 happens-before規則

happens-before規則又叫先行發生規則。它定義了java內存模型中兩項操作的偏序關系,更確切的說,它定義了操作可見性之間的偏序關系。比如A操作 happens-before B操作,並不意味這A操作一定在B操作之前,而是A操作的影響能被操作B觀察到,這個影響包括改變了內存中共享變量的值,發送消息等。那麽JMM定義了哪些happens-before規則?

  • 1.程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。
  • 2.監視器鎖規則:對於一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • 3.volatile變量規則:對於一個volatile 變量的寫,happens-before於任意後續對這個volatile變量的讀。
    這裏對於我們而言重要的是第三點。即對於一個volatile變量,寫操作happens-before於讀操作,也就是說,一個線程對volatile變量做了修改,另一個線程能馬上讀到這個被修改後的值。
    這樣就能解決共享變量在多線程環境下的可見性問題了。結合JMM模型,我們可以繼續探討下volatile是如何做到這點的。

2.4 volatile解決內存可見性問題的原理

當一個變量被修飾為volatile後,對其的讀寫就會顯得比較特別

  • 1.寫一個volatile變量時,JMM首先修改工作內存中的變量值,並刷新到主內存中
    如圖所示
    技術分享圖片

  • 2.讀一個變量時,JMM會把該線程對應的本地內存置為無效,並從主內存中讀取共享變量。
    如圖所示
    技術分享圖片

對volatile變量的讀寫,可以說都是直接對主內存進行的操作,這樣雖然會犧牲一些性能,但是解決了“緩存一致性問題”,使得變量的在多線程間的可見行得到了很好的保證。

3. 語義二:禁止指令重排

3.1 為什麽會有指令重排

為了優化程序性能,編譯器和處理器會對java編譯後的字節碼和機器指令進行重排序,通俗的說代碼的執行順序和我們在程序中定義的順序會有些不同,只要不改變單線程環境下的執行結果就行。但是在多線程環境下,這麽做卻可能出現並發問題。比如下面的例子。

3.2 線程不安全的雙重檢查單例模式

技術分享圖片

運行這段代碼我們可能會得到一個匪夷所思的結果:我們獲得的單例對象是未初始化的。為什麽會出現這種情況?因為指令重排。首先要明確一點,同步代碼塊中的代碼也是能夠被指令重排的。然後來看問題的關鍵

 INSTANCE = new Singleton();

雖然在代碼中只有一行,編譯出的字節碼指令可以用如下三行表示

  • 1.為對象分配內存空間
  • 2.初始化對象
  • 3.將INSTANCE變量指向剛分配的內存地址
    由於步驟2,3交換不會改變單線程環境下的執行結果,故而這種重排序是被允許的。也就是我們在初始化對象之前就把INSTANCE變量指向了該對象。而如果這時另一個線程剛好執行到代碼所示的2處
if (INSTANCE == null)

那麽這時候有意思的事情就發生了:雖然INSTANCE指向了一個未被初始化的對象,但是它確實不為null了,所以這個判斷會返回false,之後它將return一個未被初始化的單例對象!整個過程的執行流程如下圖所示
技術分享圖片

由於重排序是編譯器和CPU自動進行的,那麽有什麽辦法能禁止這種重排序操作嗎?很簡單,給
INSTANCE變量加個volatile關鍵字就行,這樣編譯器就會根據一定的規則禁止對volatile變量的讀寫操作重排序了。而編譯出的字節碼,也會在合適的地方插入內存屏障,比如volatile寫操作之前和之後會分別插入一個StoreStore屏障和StoreLoad屏障,禁止CPU對指令的重排序越過這些屏障。

4. volatile的其他特性

對volatile變量的讀寫具有原子性,但是其他操作並不一定具有原子性,一個簡單的例子就是i++。由於該操作並不具有原子性,故而即使該變量被volatile修飾,多線程環境下也不能保證線程安全。

5.總結

volatile是jvm提供的輕量級同步工具。被volatile修飾的共享變量在多線程環境下可以獲得可見行保證。其次它還能禁止指令重排。由於對volatile的寫-讀與鎖的釋放-獲取具有相同的內存語義,故某些時候可以代替鎖來獲得更好的性能。但是和鎖不一樣,它不能保證任何時候都是線程安全的。

輕量級的同步機制——volatile語義詳解(可見性保證+禁止指令重排)