歡迎光臨
每天分享高質量文章

徹底搞懂volatile關鍵字

來自:苦逼的碼農(微訊號:di201805)

作者:帥地

個人簡介:一個熱愛程式設計的在校生,我的世界不只有coding,還有writing。目前維護訂閱號「苦逼的碼農」,專註於寫「演演算法與資料結構」,「Java」,「計算機網路」。

全文共:4964字,預計閱讀時間:14分鐘

對於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關鍵字的就算講完了。如果有哪裡講的不對的地方,非常歡迎你的指點。下篇應該會講synchronized關鍵字。

推薦閱讀:
聊一聊讓我矇蔽一晚上的各種常量池
JVM(2)—一文讀懂垃圾回收

參考書籍:

  1. 深入理解Java虛擬機器(JVM高階特性與最佳實踐)。

  2. Java併發程式設計實戰


編號821,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

Web開發

更多推薦18個技術類微信公眾號

涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

贊(0)

分享創造快樂