【併發編程】Volatile原理和使用場景解析


volatile是Java提供的一種輕量級的同步機制,在併發編程中,它也扮演着比較重要的角色。一個硬幣具備兩面,volatile不會形成上下文切換的開銷,可是它也並能像synchronized那樣保證全部場景下的線程安全。所以咱們須要在合適的場景下使用volatile機制。java

咱們先使用一個列子來引出volatile的使用場景。編程


一個簡單列子

public class VolatileDemo {

    boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }

    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        Thread startThread = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.startSystem();
            }
        });
        startThread.setName("start-Thread");

        Thread checkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

上面的列子中,一個線程來改變started的狀態,另一個線程不停地來檢測started的狀態,若是是true就輸出系統啓動,若是是false就輸出系統未啓動。那麼當start-Thread線程將狀態改爲true後,check-Thread線程在執行時是否能當即「看到」這個變化呢?答案是不必定能當即看到。這邊我作了不少測試,大多數狀況下是能「感知」到started這個變量的變化的。可是偶爾會存在感知不到的狀況。請看下下面日誌記錄:緩存

上面的現象可能會讓人比較困惑,爲何有時候check-Thread線程能感知到狀態的變化,有時候又感知不到變化呢?這個要從Java的內存模型提及。安全

Java內存模型

咱們知道,計算機在執行程序時,每條指令都是在CPU中執行的。而執行指令過程當中,勢必涉及到數據的讀取和寫入。程序運行過程當中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,因爲CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,所以若是任什麼時候候對數據的操做都要經過和內存的交互來進行,會大大下降指令執行的速度。爲了解決這個問題,「巨人們」就設計了CPU高速緩存。多線程

下面舉個列子來講明下CPU高速緩存的工做原理:併發

i = i+1;

當線程執行這個語句時,會先從主存當中讀取i的值,而後複製一份到高速緩存當中,而後CPU執行指令對i進行加1操做,而後將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。ide

這個代碼在單線程中運行是沒有任何問題的,可是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不一樣的CPU中,所以每一個線程運行時有本身的高速緩存(對單核CPU來講,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。本文咱們以多核CPU爲例,下面舉個列子:性能

同時有2個線程執行上面這段代碼,假如初始時i的值爲0,那麼從直觀上看最後i的結果應該是2。可是事實可能不是這樣。
可能存在下面一種狀況:初始時,兩個線程分別讀取i的值存入各自所在的CPU的高速緩存當中,而後線程1進行加1操做,而後把i的最新值1寫入到內存。此時線程2的高速緩存當中i的值仍是0,進行加1操做以後,i的值爲1,而後線程2把i的值寫入內存。最終結果i的值是1,而不是2。這就是著名的緩存一致性問題。一般稱這種被多個線程訪問的變量爲共享變量。測試

緩存不一致問題

上面的列子說明了共享變量在CPU中可能會出現緩存不一致問題。爲了解決緩存不一致性問題,一般來講有如下2種解決方法:

  • 經過在總線加LOCK#鎖的方式;
  • 經過緩存一致性協議;

這2種方式都是硬件層面上提供的方式。

在早期的CPU當中,是經過在總線上加LOCK#鎖的形式來解決緩存不一致的問題的。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。好比上面例子中 若是一個線程在執行 i = i +1,若是在執行這段代碼的過程當中,在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從變量i所在的內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。可是上面的方式會有一個問題,因爲在鎖住總線期間,其餘CPU沒法訪問內存,致使效率低下

因此就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。

經過上面對Java內存模型的講解,咱們發現每一個線程都有各自對共享變量的副本拷貝,代碼執行是對共享變量的修改,其實首先修改的是CPU中高速緩存中副本的值。而這個修改對其餘線程是不可見的,只有當這個修改刷新回主存中(刷新的時機不必定)而且其餘線程從新讀取這個主存中的值時,這個修改纔對其餘線程可見。這個也就解釋了上面列子中的現象。check-Thread線程緩存了started的值是false,start-Thread線程將started副本的值改變成true後並無立馬刷新到主存中去,因此當check-Thread線程再次執行時拿到的started值仍是false。

併發編程中的「三性」

在正式講volatile以前,咱們先來解釋下併發編程中常常遇到的「三性」。

  1. 可見性
    可見性是指當多個線程訪問同一個共享變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。

  2. 原子性
    原子性是指一個操做或者多個操做要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。

  3. 有序性
    有序性是指程序執行的順序按照代碼的前後順序執行。

使用volatile來解決共享變量可見性

上面的列子中存在的問題是:start-Thread線程將started狀態改變以後,check-Thread線程不能立馬感知這個變化。也就是說這個共享變量的變化在線程之間是不可見的。那怎麼來解決共享變量的可見性問題呢?Java中提供了volatile關鍵字這種輕量級的方式來解決這個問題的。volatile的使用很是簡單,只須要用這個關鍵字修飾你的共享變量就好了:

private volatile boolean started = false;

volatile能達到下面兩個效果:

  • 當一個線程寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量值強制刷新到主內存中去;
  • 這個寫會操做會致使其餘線程中的這個共享變量的緩存失效,重新去主內存中取值。

volatile和指令重排(有序性)

volatile還有一個特性:禁止指令重排序優化。
重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行排序的一種手段。可是重排序也須要遵照必定規則:

  1. 重排序操做不會對存在數據依賴關係的操做進行重排序
    好比:a=1;b=a; 這個指令序列,因爲第二個操做依賴於第一個操做,因此在編譯時和處理器運行時這兩個操做不會被重排序。

  2. 重排序是爲了優化性能,可是無論怎麼重排序,單線程下程序的執行結果不能被改變
    好比:a=1;b=2;c=a+b這三個操做,第一步(a=1)和第二步(b=2)因爲不存在數據依賴關係,因此可能會發生重排序,可是c=a+b這個操做是不會被重排序的,由於須要保證最終的結果必定是c=a+b=3。

重排序在單線程模式下是必定會保證最終結果的正確性,可是在多線程環境下,可能就會出問題。仍是用上面相似的列子:

public class VolatileDemo {

    int value = 1;
    private boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        value = 2;
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            //關注點
            int var = value+1;  
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }
}

上面的代碼咱們並不能保證代碼執行到「關注點」處,var變量的值必定是3。由於在startSystem方法中的兩個複製語句並不存在依賴關係,因此在編譯器進行代碼編譯時可能進行指令重排。也就是先執行
started = true;執行完這個語句後,線程立馬執行checkStartes方法,此時value值仍是1,那麼最後在關注點處的var值就是2,而不是咱們想象中的3。

使用volatile關鍵字修飾共享變量即可以禁止這種重排序。若用volatile修飾共享變量,在編譯時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。volatile禁止指令重排序也有一些規則:

  • 當第二個操做是voaltile寫時,不管第一個操做是什麼,都不能進行重排序

  • 當地一個操做是volatile讀時,無論第二個操做是什麼,都不能進行重排序

  • 當第一個操做是volatile寫時,第二個操做是volatile讀時,不能進行重排序

volatile和原子性

volatile並非在全部場景下都能保證線程安全的。下面舉個列子:

public class Counter {
    public static volatile int num = 0;
    //使用CountDownLatch來等待計算線程執行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //開啓30個線程進行累加操做
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num++;//自加操做
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待計算線程執行完
        countDownLatch.await();
        System.out.println(num);
    }
}

上面的代碼中,每一個線程都對共享變量num加了10000次,一共有30個線程,那麼感受上num的最後應該是300000。可是執行下來,大機率最後的結果不是300000(你們能夠本身執行下這個代碼)。這是由於什麼緣由呢?

問題就出在num++這個操做上,由於num++不是個原子性的操做,而是個複合操做。咱們能夠簡單講這個操做理解爲由這三步組成:

  • step1:從主存中讀取最新的num值,並在CPU中存一份副本;
  • step2:對CPU中的num的副本值加1;
  • step3:賦值。

加入如今有兩個線程在執行,線程1在執行到step2的時候被阻斷了,CPU切換給線程2執行,線程2成功地將num值加1並刷新到內存。CPU又切會線程1繼續執行step2,可是此時不會再去拿最新的num值,step2中的num值是已通過期的num值。

上面代碼的執行結果和咱們預期不符的緣由就是相似num++這種操做並非原子操做,而是分幾步完成的。這些執行步驟可能會被打斷。在中狀況下volatile就不能保證線程安全了,須要使用鎖等同步機制來保證線程安全。

volatile使用場景

 synchronized關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程序執行效率,而volatile關鍵字在某些狀況下性能要優於synchronized,可是要注意volatile關鍵字是沒法替代synchronized關鍵字的,由於volatile關鍵字沒法保證操做的原子性。一般來講,使用volatile必須具有如下2個條件:

  • 對變量的寫操做不依賴於當前值;
  • 該變量沒有包含在具備其餘變量的不變式中。

下面列舉兩個使用場景

  • 狀態標記量(本文中代碼的列子)
  • 雙重檢查(單例模式)
class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

volatile使用總結

  • volati是Java提供的一種輕量級同步機制,能夠保證共享變量的可見性和有序性(禁止指令重排);
  • volatile對於單個的共享變量的讀/寫(好比a=1;這種操做)具備原子性,可是像num++這種複合操做,volatile沒法保證其原子性;
  • volatile的使用場景不是不少,使用時須要深刻考慮下當前場景是否適用volatile。常見的使用場景有多線程下的狀態標記量和雙重檢查等。

參考

  • https://www.cnblogs.com/dolphin0520/p/3920373.html
  • https://www.cnblogs.com/chengxiao/p/6528109.html
相關文章
相關標籤/搜索