volatile大補

對於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關鍵字。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章