1. 程式人生 > >【併發程式設計】Volatile原理和使用場景解析

【併發程式設計】Volatile原理和使用場景解析

目錄

  • 一個簡單列子
  • Java記憶體模型
    • 快取不一致問題
    • 併發程式設計中的“三性”
  • 使用volatile來解決共享變數可見性
  • volatile和指令重排(有序性)
  • volatile和原子性
  • volatile使用場景
  • volatile使用總結
  • 參考

volatile是Java提供的一種輕量級的同步機制,在併發程式設計中,它也扮演著比較重要的角色。一個硬幣具有兩面,volatile不會造成上下文切換的開銷,但是它也並能像synchronized那樣保證所有場景下的執行緒安全。因此我們需要在合適的場景下使用volatile機制。

我們先使用一個列子來引出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最新的值重新整理到主存當中。

這個程式碼在單執行緒中執行是沒有任何問題的,但是在多執行緒中執行就會有問題了。在多核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