深入解析volatile關鍵字

一隻修仙的猿發表於2020-12-10

前言

很高興遇見你~ 歡迎閱讀我的文章。

volatile關鍵字在Java多執行緒程式設計程式設計中起的作用是很大的,合理使用可以減少很多的執行緒安全問題。但其實可以發現使用這個關鍵字的開發者其實很少,包括我自己。遇到同步問題,首先想到的一定是加鎖,也就是synchronize關鍵字,暴力鎖解決一切多執行緒疑難雜症。但,鎖的代價是很高的。執行緒阻塞、系統執行緒排程這些問題,都會造成很嚴重的效能影響。如果在一些合適的場景,使用volatile,既保證了執行緒安全,又極大地提高了效能

那為啥放著好用的volatile不用,偏偏要加鎖呢?一個很重要的原因是:不瞭解什麼是volatile。加鎖簡單粗暴,幾乎每個開發者都會用(但不一定可以正確地用),而volatile,可能壓根就不知道有這個東西(包括之前的筆者=_=)。那volatile是什麼東西?他有什麼作用?在什麼場景下適用?他的底層原理是什麼?他真的可以保證執行緒安全嗎?這一系列問題,是面試常見相關題目,也正是這篇文章要啊解決的問題。

那麼,我們開始吧。

認識volatile

volatile關鍵字的作用有兩個:變數修改對其他執行緒立即可見、禁止指令重排。

第二個作用我們後面再講,先主要講一下第一個作用。通俗點來說,就是我在一個執行緒對一個變數進行了修改,那麼其他執行緒馬上就可以知道我修改了他。嗯?難道我修改了數值其他執行緒不知道?我們先從例項程式碼中來感受volatile關鍵字的第一個作用。

private  boolean stopSignal = false;

public void fun(){
    // 建立10個執行緒
    for (int i=0;i<=10;i--){
        new Thread(() -> {
            while(!stopSignal){
                // 迴圈等待
            }
            System.out.println(Thread.currentThread().toString()+"我停下來了");
        }).start();
    }
    new Thread(() -> {
        stopSignal = true;
        System.out.println("給我停下來");
    }).start();
}

這個程式碼很簡單,建立10個執行緒迴圈等待stopSignal,當stopSignal變為true之後,則跳出迴圈,列印日誌。然後我們再開另外一個執行緒,把stopSignal改成true。如果按照正常的情況下,應該是先列印“給我停下來”,然後再列印10個“我停下來了”,最後結束程式。我們看看具體情況如何。來,執行:

深入解析volatile關鍵字

嗯嗯?為什麼只列印兩個我停下來了?而且看左邊的停止符號,表示這個程式還沒結束。也就是說在剩下的執行緒中,他們拿到的stopSignal資料依舊是false,而不是最新的true。所以問題就是:執行緒中變數的修改,對於其他執行緒並不是立即可見。導致這個問題的原因我們後面講,現在是怎麼解決這個問題。加鎖是個好辦法,只要我們在迴圈判斷與修改數值的時候加個鎖,就可以拿到最新的資料了。但是前面講到,鎖是個重量級操作,然後再加上迴圈,這效能估計直接掉下水道里了。最好的解決方法就是:給變數加上volatile關鍵字,如下:

private volatile  boolean stopSignal = false;

我們再執行一下:

深入解析volatile關鍵字

誒,可以了,全部停下來了。使用了volatile關鍵修飾的變數,只要被修改,那麼其他的執行緒均會立即可見。這就是volatile關鍵字的第一個重要作用:變數修改對其他執行緒立即可見。關於指令重排我們後面再講。

那麼為什麼變數的修改是對其他執行緒不是立即可見呢?volatile為何能實現這個效果?那這樣我們可不可以每個變數都給他加上volatile關鍵字修飾?要解決這些問題,我們得先從Java記憶體模型說起。繫好安全帶,我們的車準備開進底層原理了。

Java記憶體模型

Java記憶體模型不是堆區、棧區、方法區那些,而是執行緒之間如何共享資料的模型,也可以說是執行緒對共享資料讀寫的規範。記憶體模型是理解Java併發問題的基礎。限於篇幅這裡不深入講述,只簡單介紹一下。不然又是萬字長文了。先看個圖:

深入解析volatile關鍵字

JVM把記憶體總體分為兩個部分:執行緒私有以及執行緒共享,執行緒共享區域也稱為主記憶體。執行緒私有部分不會發生併發問題,所以主要是關注執行緒共享區域。這個圖大致上可以這麼理解:

  1. 所有共享變數儲存在主記憶體
  2. 每條執行緒擁有自己的工作記憶體
  3. 工作記憶體保留了被該執行緒使用的變數的主記憶體副本
  4. 變數操作必須在工作記憶體進行
  5. 不同執行緒之間無法訪問對方的工作記憶體

簡單總結一下,所有資料都要放在主記憶體中,執行緒要操作這些資料必須要先拷貝到自己的工作記憶體,然後只能對自己工作記憶體中的資料進行修改;修改完成之後,再寫回主記憶體。執行緒無法訪問另一個執行緒的資料,這也就是為什麼執行緒私有的資料不存在併發問題。

那為什麼不直接從主記憶體修改資料,而要先在工作記憶體修改後再寫回主記憶體呢?這就涉及到了高速緩衝區的設計。簡單來說,處理器的速度非常快,但是執行的過程中需要頻繁在記憶體中讀寫資料,而記憶體訪問的速度遠遠跟不上cpu的速度,導致降低了cpu的效率。因而設計出高速緩衝區,處理器可以直接操作高速緩衝區的資料,等到空閒時間,再把資料寫回主記憶體,提高了效能。而JVM為了遮蔽不同的平臺對於高速緩衝區的設計,就設計出了Java記憶體模型,來讓開發者可以面向統一的記憶體模型進行程式設計。可以看到,這裡的工作記憶體就對應硬體層面的高速緩衝區。

指令重排

前面我們一直沒講指令重排,是因為這是屬於JVM在後端編譯階段進行的優化,而在程式碼中隱藏的問題很難去復現,也很難通過程式碼執行來看出差別。指令重排是即時編譯器對於位元組碼編譯過程中的一種優化,受到執行時環境的影響。限於能力,筆者只能通過理論分析來講這個問題。

我們知道JVM執行位元組碼一般有兩種方式:直譯器執行和即時編譯器執行。直譯器這個比較容易理解,就是一行行程式碼解釋執行,所以也不存在指令重排的問題。但是解釋執行存在很大的問題:解釋程式碼需要耗費一定的處理時間、無法對編譯結果進行優化,所以解釋執行一般在應用剛啟動時或者即時編譯遇到異常才使用解釋執行。而即時編譯則是在執行過程中,把熱點程式碼先編譯成機器碼,等到執行到該程式碼的時候就可以直接執行機器碼,不需要進行解釋,提高了效能。而在編譯過程中,我們可以對編譯後的機器碼進行優化,只要保證執行結果一致,我們可以按照計算機世界的特性對機器碼進行優化,如方法內聯、公共子表示式消除等等,指令重排就是其中一種。

計算機的思維跟我們人的思維是不一樣的,我們按照物件導向的思維來程式設計,但計算機卻必須按照“程式導向”的思維來執行。所以,在不影響執行結果的情況下,JVM會更改程式碼的執行順序。注意,這裡的不影響執行結果,是指在當前執行緒下。如我們可能會在一個執行緒中初始化一個元件,等到初始化完成則設定標誌位為true,然後其他執行緒只需要監聽該標誌位即可監聽初始化是否完成,如下:

// 初始化操作
isFinish = true;

在當前執行緒看起來,注意,是看起來,會先執行初始化操作,再執行賦值操作,因為結果是符合預期的。但是!!!在其他執行緒看來,這整個執行順序都是亂的。JVM可能先執行isFinish賦值操作,再執行初始化操作;而如果你在別的執行緒監聽isFinish變化,就可能出現還未初始化完成isFinish卻是true的問題。而volatile可以禁止指令重排,保證在isFinish被賦值之前,所有的初始化動作都已經完成

volatile的具體定義

上面講了兩個看起來跟我們的主角volatile關係不大的知識點,但其實是非常重要的知識點。

首先,通過Java記憶體模型的理解,現在知道為什麼會出現執行緒對變數的修改其他執行緒未立即可知的原因了吧?執行緒修改變數之後,可能並不會立即寫回主記憶體,而其他執行緒,在主記憶體資料更新後,也並不會立即去主記憶體獲取最新的資料。這也是問題所在。

被volatile關鍵字修飾的變數規定:每次使用資料都必須去主記憶體中獲取;每次修改完資料都必須馬上同步到主記憶體。這樣就實現了每個執行緒都可以立即收到該變數的修改資訊。不會出現讀取髒資料舊資料的情況。

第二個作用是禁止指令重排。這裡是使用到了JVM的一個規定:同步記憶體操作前的所有操作必須已經完成。而我們知道每次給volatile賦值的時候,他會同步到主記憶體中。所以,在同步之前,保證所有操作都必須完成了。所以當其他執行緒監測到變數的變化時,賦值前的操作就肯定都已經完成了。

既然volatile關鍵字這麼好,那可不可以每個地方都使用好了?當然不行!在前面講Java記憶體模型的時候有提到,為了提高cpu效率,才分出了高速緩衝區。如果一個變數並不需要保證執行緒安全,那麼頻繁地寫入和讀取記憶體,是很大的效能消耗。因而,只有必須使用volatile的地方,才使用他。

volatile修飾的變數一定是執行緒安全嗎

首先明確一下,怎麼樣才算是執行緒安全?

  1. 一個操作要符合原子性,要麼不執行,要麼一次性執行完成,在執行過程中不會受其他操作的影響。
  2. 對於變數的修改相對於其他執行緒必須立即可見。
  3. 程式碼的執行在其他執行緒看來要滿足有序性。

從上面分析我們講了:程式碼的有序性、修改的可見性,但是,缺少了原子性。在JVM中,在主記憶體進行讀寫都是滿足原子性的,這是JVM保證的原子性,那麼volatile豈不是執行緒安全的?並不是。

JVM的計算過程並不是原子性的。我們舉個例子,看一下程式碼:

private volatile int num = 0;

public void fun(){
    for (int k=0;k<=10;k++){
        new Thread(() -> {
            int a = 10000;
            while (a > 0) {
                a--;
                num++;
            }
            System.out.println(num);
        }).start();
    }
}

按照正常的情況,最後的輸出應該是100000才對,我們看看執行結果:

深入解析volatile關鍵字

怎麼才五萬多,不應該是10萬嗎?這是因為,volatile僅僅只是保證了修改後的資料對其他執行緒立即可見,但是並不保證運算的過程的原子性。

這裡的num++在編譯之後是分為三步:1.在工作區中取出變數資料到處理器 2.對處理器中的資料進行加一操作 3.把資料寫回工作記憶體。如果,在變數資料取到處理器運算的過程中,變數已經被修改了,所以這時候進行自增操作得到的結果就是錯誤的。舉個例子:

變數a=5,當前執行緒取5到處理器,這時a被其他執行緒改成了8,但是處理器繼續工作,把5自增得到6,然後把6寫回主記憶體,覆蓋資料8,從而導致了錯誤。因此,由於Java運算的非原子性,volatile並不是絕對執行緒安全

那什麼時候他是安全的:

  1. 計算的結果不依賴原來的狀態。
  2. 不需要與其他的狀態變數共同參與不變約束。

通俗點來講,就是運算不需要依賴於任何狀態的運算。因為依賴的狀態,可能在運算的過程中就已經發生了變化了,而處理器並不知道。如果涉及到需要依賴狀態的運算,則必須使用其他的執行緒安全方案,如加鎖,來保證操作的原子性。

適用場景

狀態標誌/多執行緒通知

狀態標誌是很適合使用volatile關鍵字,正如我們在第一部分舉的例子,通過設定標誌來通知其他所有的執行緒執行邏輯。或者是如上一部分的例子,當初始化完成之後設定一個標誌通知其他執行緒。

而更加常用的一個類似場景是執行緒通知。我們可以設定一個變數,然後多個執行緒觀察他。這樣只需要在一個執行緒更改這個數值,那麼其他的觀察這個變數的執行緒均可以收到通知。非常輕量級、邏輯也更加簡單。

保證初始化完整:雙重鎖檢查問題

單例模式是我們經常使用的,其中一種比較常見的寫法就是雙重鎖檢查,如下:

public class JavaClass {
	// 靜態內部變數
   private static JavaClass javaClass;
    // 構造器私有
   private JavaClass(){}
    
   public static JavaClass getInstance(){
       // 第一重判空
       if (javaClass==null){
           // 加鎖,第二重判空
           synchronized(JavaClass.class){
               if (javaClass==null){
                   javaClass = new JavaClass();
               }
           }
       }
       return javaClass;
   }
}

這種程式碼很熟悉,對吧,具體的設計邏輯我就不展開了。那麼這樣的程式碼是否絕對是執行緒安全的呢?並不是的,在某些極端情況下,仍然會出現問題。而問題就出在javaClass = new JavaClass();這句程式碼上。

新建物件並不是一個原子操作,他主要有三個子操作:

  1. 分配記憶體空間
  2. 初始化Singleton例項
  3. 賦值 instance 例項引用

正常情況下,也是按照這個順序執行。但是JVM是會進行指令重排優化的。就可能變成:

  1. 分配記憶體空間
  2. 賦值 instance 例項引用
  3. 初始化Singleton例項

賦值引用在初始化之前,那麼外部拿到的引用,可能就是一個未完全初始化的物件,這樣就造成問題了。所以這裡可以給單例物件進行volatile修飾,限制指令重排,就不會出現這種情況了。當然,關於單例模式,更加建議的寫法還是利用類載入來保證全域性單例以及執行緒安全,當然,前提是你要保證只有一個類載入器。限於篇幅這裡就不展開了。

其他型別的初始化標誌,也是可以利用volatile關鍵字來達到限制指令重排的作用。

總結

關於Java併發程式設計的知識很多,而volatile僅僅只是冰山一角。併發程式設計的難點在於,他的bug隱藏很深,可能經過幾輪測試都不能找到問題,但是一上線就崩潰了,且極難復現和查詢原因。因而學習併發原理與併發程式設計思想非常重要。同時,更要注重原理。掌握了原理和本質,那麼其他的相關知識也是手到擒來。

希望文章對你有幫助。

全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
筆者才疏學淺,有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信交流。

另外歡迎光臨筆者的個人部落格:傳送門

相關文章