執行緒安全(上)--徹底搞懂volatile關鍵字

帥地發表於2018-08-20

對於volatile這個關鍵字,相信很多朋友都聽說過,甚至使用過,這個關鍵字雖然字面上理解起來比較簡單,但是要用好起來卻不是一件容易的事。
這篇文章將從多個方面來講解volatile,讓你對它更加理解。

計算機中為什麼會出現執行緒不安全的問題

volatile既然是與執行緒安全有關的問題,那我們先來了解一下計算機在處理資料的過程中為什麼會出現執行緒不安全的問題。
大家都知道,計算機在執行程式時,每條指令都是在CPU中執行的,而執行指令過程中會涉及到資料的讀取和寫入。由於程式執行過程中的臨時資料是存放在主存(實體記憶體)當中的,這時就存在一個問題,由於CPU執行速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。
為了處理這個問題,在CPU裡面就有了快取記憶體(Cache)的概念。當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。
我舉個簡單的例子,比如cpu在執行下面這段程式碼的時候,

t = t + 1;

會先從快取記憶體中檢視是否有t的值,如果有,則直接拿來使用,如果沒有,則會從主存中讀取,讀取之後會複製一份存放在快取記憶體中方便下次使用。之後cup進行對t加1操作,然後把資料寫入快取記憶體,最後會把快取記憶體中的資料重新整理到主存中。

這一過程在單執行緒執行是沒有問題的,但是在多執行緒中執行就會有問題了。在多核CPU中,每條執行緒可能執行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的,本次講解以多核cup為主)。這時就會出現同一個變數在兩個快取記憶體中的不一致問題了。
例如:
兩個執行緒分別讀取了t的值,假設此時t的值為0,並且把t的值存到了各自的快取記憶體中,然後執行緒1對t進行了加1操作,此時t的值為1,並且把t的值寫回到主存中。但是執行緒2中快取記憶體的值還是0,進行加1操作之後,t的值還是為1,然後再把t的值寫回主存。
此時,就出現了執行緒不安全問題了。

Java中的執行緒安全問題

上面那種執行緒安全問題,可能對於不同的作業系統會有不同的處理機制,例如Windows作業系統和Linux的作業系統的處理方法可能會不同。
我們都知道,Java是一種誇平臺的語言,因此Java這種語言在處理執行緒安全問題的時候,會有自己的處理機制,例如volatile關鍵字,synchronized關鍵字,並且這種機制適用於各種平臺。
Java記憶體模型規定所有的變數都是存在主存當中(類似於前面說的實體記憶體),每個執行緒都有自己的工作記憶體(類似於前面的快取記憶體)。執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接對主存進行操作。並且每個執行緒不能訪問其他執行緒的工作記憶體。
由於java中的每個執行緒有自己的工作空間,這種工作空間相當於上面所說的快取記憶體,因此多個執行緒在處理一個共享變數的時候,就會出現執行緒安全問題。

這裡簡單解釋下共享變數,上面我們所說的t就是一個共享變數,也就是說,能夠被多個執行緒訪問到的變數,我們稱之為共享變數。在java中共享變數包括例項變數,靜態變數,陣列元素。他們都被存放在堆記憶體中。

volatile關鍵字

上面扯了一大堆,都沒提到volatile關鍵字的作用,下面開始講解volatile關鍵字是如何保證執行緒安全問題的。

可見性

什麼是可見性?

意思就是說,在多執行緒環境下,某個共享變數如果被其中一個執行緒給修改了,其他執行緒能夠立即知道這個共享變數已經被修改了,當其他執行緒要讀取這個變數的時候,最終會去記憶體中讀取,而不是從自己的工作空間中讀取。
例如我們上面說的,當執行緒1對t進行了加1操作並把資料寫回到主存之後,執行緒2就會知道它自己工作空間內的t已經被修改了,當它要執行加1操作之後,就會去主存中讀取。這樣,兩邊的資料就能一致了。
假如一個變數被宣告為volatile,那麼這個變數就具有了可見性的性質了。這就是volatile關鍵的作用之一了。

volatile保證變數可見性的原理

當一個變數被宣告為volatile時,在編譯成會變指令的時候,會多出下面一行:

0x00bbacde: lock add1 $0x0,(%esp);

這句指令的意思就是在暫存器執行一個加0的空操作。不過這條指令的前面有一個lock(鎖)字首。
當處理器在處理擁有lock字首的指令時:
在之前的處理中,lock會導致傳輸資料的匯流排被鎖定,其他處理器都不能訪問匯流排,從而保證處理lock指令的處理器能夠獨享運算元據所在的記憶體區域,而不會被其他處理所干擾。
但由於匯流排被鎖住,其他處理器都會被堵住,從而影響了多處理器的執行效率。為了解決這個問題,在後來的處理器中,處理器遇到lock指令時不會再鎖住匯流排,而是會檢查資料所在的記憶體區域,如果該資料是在處理器的內部快取中,則會鎖定此快取區域,處理完後把快取寫回到主存中,並且會利用快取一致性協議來保證其他處理器中的快取資料的一致性。

快取一致性協議

剛才我在說可見性的時候,說“如果一個共享變數被一個執行緒修改了之後,當其他執行緒要讀取這個變數的時候,最終會去記憶體中讀取,而不是從自己的工作空間中讀取”,實際上是這樣的:
執行緒中的處理器會一直在匯流排上嗅探其內部快取中的記憶體地址在其他處理器的操作情況,一旦嗅探到某處處理器打算修改其記憶體地址中的值,而該記憶體地址剛好也在自己的內部快取中,那麼處理器就會強制讓自己對該快取地址的無效。所以當該處理器要訪問該資料的時候,由於發現自己快取的資料無效了,就會去主存中訪問。

有序性

實際上,當我們把程式碼寫好之後,虛擬機器不一定會按照我們寫的程式碼的順序來執行。例如對於下面的兩句程式碼:

int a = 1;
int b = 2;

對於這兩句程式碼,你會發現無論是先執行a = 1還是執行b = 2,都不會對a,b最終的值造成影響。所以虛擬機器在編譯的時候,是有可能把他們進行重排序的。
為什麼要進行重排序呢?
你想啊,假如執行 int a = 1這句程式碼需要100ms的時間,但執行int b = 2這句程式碼需要1ms的時間,並且先執行哪句程式碼並不會對a,b最終的值造成影響。那當然是先執行int b = 2這句程式碼了。
所以,虛擬機器在進行程式碼編譯優化的時候,對於那些改變順序之後不會對最終變數的值造成影響的程式碼,是有可能將他們進行重排序的。
更多程式碼編譯優化可以看我寫的另一篇文章:
虛擬機器在執行期對程式碼的優化策略


那麼重排序之後真的不會對程式碼造成影響嗎?
實際上,對於有些程式碼進行重排序之後,雖然對變數的值沒有造成影響,但有可能會出現執行緒安全問題的。具體請看下面的程式碼

public class NoVisibility{
    private static boolean ready;
    private static int number;

    private static class Reader extends Thread{
        public void run(){
        while(!ready){
            Thread.yield();
        }
        System.out.println(number);
    }
}
    public static void main(String[] args){
        new Reader().start();
        number = 42;
        ready = true;
    }
}

這段程式碼最終列印的一定是42嗎?如果沒有重排序的話,列印的確實會是42,但如果number = 42和ready = true被進行了重排序,顛倒了順序,那麼就有可能列印出0了,而不是42。(因為number的初始值會是0).
因此,重排序是有可能導致執行緒安全問題的。


如果一個變數被宣告volatile的話,那麼這個變數不會被進行重排序,也就是說,虛擬機器會保證這個變數之前的程式碼一定會比它先執行,而之後的程式碼一定會比它慢執行。
例如把上面中的number宣告為volatile,那麼number = 42一定會比ready = true先執行。

不過這裡需要注意的是,虛擬機器只是保證這個變數之前的程式碼一定比它先執行,但並沒有保證這個變數之前的程式碼不可以重排序。之後的也一樣。

volatile關鍵字能夠保證程式碼的有序性,這個也是volatile關鍵字的作用。
總結一下,一個被volatile宣告的變數主要有以下兩種特性保證保證執行緒安全。

  1. 可見性。
  2. 有序性。

volatile真的能完全保證一個變數的執行緒安全嗎?

我們通過上面的講解,發現volatile關鍵字還是挺有用的,不但能夠保證變數的可見性,還能保證程式碼的有序性。
那麼,它真的能夠保證一個變數在多執行緒環境下都能被正確的使用嗎?
答案是否定的。原因是因為Java裡面的運算並非是原子操作

原子操作

原子操作:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
也就是說,處理器要嘛把這組操作全部執行完,中間不允許被其他操作所打斷,要嘛這組操作不要執行。
剛才說Java裡面的執行並非是原子操作。我舉個例子,例如這句程式碼

int a = b + 1;

處理器在處理程式碼的時候,需要處理以下三個操作:

  1. 從記憶體中讀取b的值。
  2. 進行a = b + 1這個運算
  3. 把a的值寫回到記憶體中

而這三個操作處理器是不一定就會連續執行的,有可能執行了第一個操作之後,處理器就跑去執行別的操作的。

證明volatile無法保證執行緒安全的例子

由於Java中的運算並非是原子操作,所以導致volatile宣告的變數無法保證執行緒安全。
對於這句話,我給大家舉個例子。程式碼如下:

public class Test{
    public static volatile int t = 0;

    public static void main(String[] args){

        Thread[] threads = new Thread[10];
        for(int i = 0; i < 10; i++){
            //每個執行緒對t進行1000次加1的操作
            threads[i] new Thread(new Runnable(){
                @Override
                public void run(){
                    for(int j = 0; j < 1000; j++){
                        t = t + 1;
                    }
                }
            });
            threads[i].start();
        }

        //等待所有累加執行緒都結束
        while(Thread.activeCount() > 1){
            Thread.yield();
        }

        //列印t的值
        System.out.println(t);
    }
}

最終的列印結果會是1000 * 10 = 10000嗎?答案是否定的。
問題就出現在t = t + 1這句程式碼中。我們來分析一下
例如:
執行緒1讀取了t的值,假如t = 0。之後執行緒2讀取了t的值,此時t = 0。
然後執行緒1執行了加1的操作,此時t = 1。但是這個時候,處理器還沒有把t = 1的值寫回主存中。這個時候處理器跑去執行執行緒2,注意,剛才執行緒2已經讀取了t的值,所以這個時候並不會再去讀取t的值了,所以此時t的值還是0,然後執行緒2執行了對t的加1操作,此時t =1 。
這個時候,就出現了執行緒安全問題了,兩個執行緒都對t執行了加1操作,但t的值卻是1。所以說,volatile關鍵字並不一定能夠保證變數的安全性。

什麼情況下volatile能夠保證執行緒安全

剛才雖然說,volatile關鍵字不一定能夠保證執行緒安全的問題,其實,在大多數情況下volatile還是可以保證變數的執行緒安全問題的。所以,在滿足以下兩個條件的情況下,volatile就能保證變數的執行緒安全問題:

  1. 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  2. 變數不需要與其他狀態變數共同參與不變約束。

講到這裡,關於volatile關鍵字的就算講完了。如果有哪裡講的不對的地方,非常歡迎你的指點。下篇應該會講synchronize關鍵字。

參考書籍:

  1. 深入理解Java虛擬機器(JVM高階特性與最佳實踐)。
  2. Java並非程式設計實戰

    如果你習慣在微信看文章,歡迎關注公眾號:苦逼的碼農,獲取更多原創文章,後臺回覆”禮包”送你一份資源大禮包。

相關文章