【並發編程】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){
                    System.out.println("loop check...");
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

上面的列子中,一個線程來改變started的狀態,另外一個線程不停地來檢測started的狀態,如果是true就輸出系統啟動,如果是false就輸出系統未啟動。那么當start-Thread線程將狀態改成true后,check-Thread線程在執行時是否能立即“看到”這個變化呢?答案是不一定能立即看到。這邊我做了很多測試,大多數情況下是能“感知”到started這個變量的變化的。但是偶爾會存在感知不到的情況。請看下下面日志記錄:


start-Thread begin to start system, time:1577079553515
start-Thread success to start system, time:1577079553516  
loop check...
system is not running, time:1577079553516   ==>此處start-Thread線程已經將狀態設置成true,但是check-Thread線程還是沒檢測到
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519

上面的現象可能會讓人比較困惑,為什么有時候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) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

上述的Instance類變量是沒有用volatile關鍵字修飾的,會導致這樣一個問題:
在線程執行到第1行的時候,代碼讀取到instance不為null時,instance引用的對象有可能還沒有完成初始化。
造成這種現象主要的原因是重排序。重排序是指編譯器和處理器為了優化程序性能而對指令序列進行重新排序的一種手段。

第二行代碼可以分解成以下幾步

emory = allocate();  // 1:分配對象的內存空間
ctorInstance(memory); // 2:初始化對象
instance = memory;  // 3:設置instance指向剛分配的內存地址

根源在於代碼中的2和3之間,可能會被重排序。例如:


memory = allocate();  // 1:分配對象的內存空間
instance = memory;  // 3:設置instance指向剛分配的內存地址
// 注意,此時對象還沒有被初始化!
ctorInstance(memory); // 2:初始化對象

這種重排序可能就會導致一個線程拿到的instance是非空的但是還沒初始化完全。

volatile的實現原理

通過上面的介紹,我們知道volatile可以實現內存的可見性和防止指令重排序。那么volatile的這些功能是怎么實現的呢?其實volatile的這些內存語意是通過內存屏障技術實現的。

為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。同時內存屏障還能保證內存的可見性。

關於內存屏障的具體內容,要講的話需要花很大的篇幅來講解。這邊就不具體展開了。大家感興趣的可以自己了解下。

volatile使用總結

  • volati是Java提供的一種輕量級同步機制,可以保證共享變量的可見性和有序性(禁止指令重排),volatile的實現原理是基於處理器的Lock指令的,這個指令會使得對變量的修改立馬刷新回主內存,同時使得其他CPU中這個變量的副本失效;
  • volatile對於單個的共享變量的讀/寫(比如a=1;這種操作)具有原子性,但是像num++或者a=b;這種復合操作,volatile無法保證其原子性;
  • volatile的使用場景不是很多,使用時需要深入考慮下當前場景是否適用volatile(記住“對變量的寫操作不依賴於當前值”、“該變量沒有包含在具有其他變量的不變式中”這兩個使用條件)。常見的使用場景有多線程下的狀態標記量和雙重檢查等。

參考


免責聲明!

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



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