線程安全(上)--完全搞懂volatile關鍵字

對於volatile這個關鍵字,相信不少朋友都據說過,甚至使用過,這個關鍵字雖然字面上理解起來比較簡單,可是要用好起來卻不是一件容易的事。java

這篇文章將從多個方面來說解volatile,讓你對它更加理解。編程

計算機中爲何會出現線程不安全的問題

volatile既然是與線程安全有關的問題,那咱們先來了解一下計算機在處理數據的過程當中爲何會出現線程不安全的問題。數組

你們都知道,計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程當中會涉及到數據的讀取和寫入。因爲程序運行過程當中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,因爲CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,所以若是任什麼時候候對數據的操做都要經過和內存的交互來進行,會大大下降指令執行的速度。緩存

爲了處理這個問題,在CPU裏面就有了高速緩存(Cache)的概念。當程序在運行過程當中,會將運算須要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就能夠直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束以後,再將高速緩存中的數據刷新到主存當中。安全

我舉個簡單的例子,好比cpu在執行下面這段代碼的時候,多線程

t = t + 1;

會先從高速緩存中查看是否有t的值,若是有,則直接拿來使用,若是沒有,則會從主存中讀取,讀取以後會複製一份存放在高速緩存中方便下次使用。以後cup進行對t加1操做,而後把數據寫入高速緩存,最後會把高速緩存中的數據刷新到主存中。ide

這一過程在單線程運行是沒有問題的,可是在多線程中運行就會有問題了。在多核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並不是編程實戰
關注公衆號: 苦逼的碼農,獲取更多原創文章,後臺回覆"禮包"送你一份資源大禮包。
相關文章
相關標籤/搜索