Java 之 volatile 詳解

Fysddsw_lc發表於2018-04-21

 Volatile可以看做是輕量級的 Synchronized,它只保證了共享變數的可見性。線上程 A 修改被 volatile 修飾的共享變數之後,執行緒 B 能夠讀取到正確的值。 java 在多執行緒中操作共享變數的過程中,會存在指令重排序與共享變數工作記憶體快取的問題。

java 記憶體模型

Java 之 volatile 詳解

Java記憶體模型規定了所有的變數都儲存在主記憶體中。每條執行緒中還有自己的工作記憶體,執行緒的工作記憶體中儲存了被該執行緒所使用到的變數(這些變數是從主記憶體中拷貝而來)。執行緒對變數的所有操作(讀取,賦值)都必須在工作記憶體中進行。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。

併發程式設計的三大概念

可見性

可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。通常,我們無法確保執行讀操作的執行緒能適時看到其他執行緒寫入的值,有時甚至是根本不可能的事情。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

可見性,是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是執行緒修改的結果。

另一個執行緒馬上就能看到。比如:用volattitle修飾的變數,就會具有可見性。volatile修飾的變數不允許執行緒內部快取和重排序,即直接修改記憶體。所以對其他執行緒是可見的。但是這裡需要注意一個問題,volatile只能讓他修飾內容具有可見性,但不能保證他具有原子性。比如 volatile int a = 0;之後有一個操作 a++;這個變數a具有可見性,但是a++ 依然是一個非原子操作,也就是這個操作同樣存線上程安全問題。

而普通的共享變數不能保證可見性,因為普通共享變數被修改之後,什麼時候被寫入主存是不確定的,當其他執行緒去讀取時,此時記憶體中可能還是原來的舊值,因此無法保證可見性。

在 Java 中通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,並且在釋放鎖之前會將對變數的修改重新整理到主存當中。因此可以保證可見性。

原子性

原子是世界上的最小單位,具有不可分割性。原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。在Java中,對基本資料型別的變數的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。

比如 a=0;(a非long和double型別) 這個操作是不可分割的,那麼我們說這個操作時原子操作。再比如:a++; 這個操作實際是a = a + 1;是可分割的,所以他不是一個原子操作。非原子操作都會存線上程安全問題,需要我們使用同步技術(sychronized)來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

Java記憶體模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個執行緒執行該程式碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。

有序性

有序性就是程式執行的順序按照程式碼的先後順序執行。

什麼是指令重排序,一般來說,處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。指令重排序不會影響單個執行緒的執行,但是會影響到執行緒併發執行的正確性。也就是說,要想併發程式正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程式執行不正確。

在Java記憶體模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。

在Java裡面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼,自然就保證了有序性。

另外,Java記憶體模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

volatile 原理

java 語言提供了一種稍弱的同步機制,Volatile可以看做是輕量級的 Synchronized,即volatile變數,用來將變數的更新操作通知到其他執行緒。當把變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile型別的變數時總會返回最新寫入的值。

在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。

當對非 volatile 變數進行讀寫的時候,每個執行緒先從記憶體拷貝變數到CPU快取中。如果計算機有多個CPU,每個執行緒可能在不同的CPU上被處理,這意味著每個執行緒可以拷貝到不同的 CPU cache 中。

  而宣告變數是 volatile 的,JVM 保證了每次讀變數都從記憶體中讀,跳過 CPU cache 這一步。

volatile作用

1.volatile可見性

一旦一個共享變數(類的成員變數、類的靜態成員變數)被volatile修飾之後,那麼就具備了兩層語義:

1)保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。

2)禁止進行指令重排序。


//執行緒1boolean stop = false;while(!stop){    doSomething();}//執行緒2stop = true;複製程式碼

這段程式碼是很典型的一段程式碼,很多人在中斷執行緒時可能都會採用這種標記辦法。但是事實上,這段程式碼會完全執行正確麼?即一定會將執行緒中斷麼?不一定,也許在大多數時候,這個程式碼能夠把執行緒中斷,但是也有可能會導致無法中斷執行緒(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死迴圈了)。

下面解釋一下這段程式碼為何有可能導致無法中斷執行緒。在前面已經解釋過,每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。

那麼當執行緒2更改了stop變數的值之後,但是還沒來得及寫入主存當中,執行緒2轉去做其他事情了,那麼執行緒1由於不知道執行緒2對stop變數的更改,因此還會一直迴圈下去。

但是用volatile修飾之後就變得不一樣了:

第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

第二:使用volatile關鍵字的話,當執行緒2進行修改時,會導致執行緒1的工作記憶體中快取變數stop的快取行無效(反映到硬體層的話,就是CPU的L1或者L2快取中對應的快取行無效);

第三:由於執行緒1的工作記憶體中快取變數stop的快取行無效,所以執行緒1再次讀取變數stop的值時會去主存讀取。

那麼線上程2修改stop值時(當然這裡包括2個操作,修改執行緒2工作記憶體中的值,然後將修改後的值寫入記憶體),會使得執行緒1的工作記憶體中快取變數stop的快取行無效,然後執行緒1讀取時,發現自己的快取行無效,它會等待快取行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。

那麼執行緒1讀取到的就是最新的正確的值。

2.原子性

public class Test {    public volatile int inc = 0;     public void increase() {        inc++;    }     public static void main(String[] args) {        final Test test = new Test();        for(int i=0;i<10;i++){            new Thread(){                public void run() {                    for(int j=0;j<1000;j++)                        test.increase();                };            }.start();        }         while(Thread.activeCount()>1)  //保證前面的執行緒都執行完            Thread.yield();        System.out.println(test.inc);    }}複製程式碼

這段程式的輸出結果是多少?也許有些朋友認為是10000。但是事實上執行它會發現每次執行結果都不一致,都是一個小於10000的數字。

可能有的朋友就會有疑問,不對啊,上面是對變數inc進行自增操作,由於volatile保證了可見性,那麼在每個執行緒中對inc自增完之後,在其他執行緒中都能看到修改後的值啊,所以有10個執行緒分別進行了1000次操作,那麼最終inc的值應該是1000*10=10000。

這裡面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程式錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變數的操作的原子性。

在前面已經提到過,自增操作是不具備原子性的,它包括讀取變數的原始值、進行加1操作、寫入工作記憶體。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

假如某個時刻變數inc的值為10,

執行緒1對變數進行自增操作,執行緒1先讀取了變數inc的原始值,然後執行緒1被阻塞了;

然後執行緒2對變數進行自增操作,執行緒2也去讀取變數inc的原始值,由於執行緒1只是對變數inc進行讀取操作,而沒有對變數進行修改操作,所以不會導致執行緒2的工作記憶體中快取變數inc的快取行無效,也不會導致主存中的值重新整理,所以執行緒2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作記憶體,最後寫入主存。

然後執行緒1接著進行加1操作,由於已經讀取了inc的值,注意此時線上程1的工作記憶體中inc的值仍然為10,所以執行緒1對inc進行加1操作後inc的值為11,然後將11寫入工作記憶體,最後寫入主存。

那麼兩個執行緒分別進行了一次自增操作後,inc只增加了1。

根源就在這裡,自增操作不是原子性操作,而且volatile也無法保證對變數的任何操作都是原子性的。

解決方案:可以通過synchronized或lock,進行加鎖,來保證操作的原子性。也可以通過AtomicInteger。

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作類,即對基本資料型別的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。atomic是利用CAS來實現原子性操作的(Compare And Swap),CAS實際上是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。

3.volatile保證有序性

在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。

volatile關鍵字禁止指令重排序有兩層意思:

1)當程式執行到volatile變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

2)在進行指令優化時,不能將在對volatile變數的讀操作或者寫操作的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。

//x、y為非volatile變數
//flag為volatile變數
x = 2; //語句1
y = 0; //語句2
flag = true; //語句3
x = 4; //語句4
y = -1; //語句5

由於flag變數為volatile變數,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

//執行緒1:context = loadContext();   //語句1inited = true;             //語句2 //執行緒2:while(!inited ){  sleep()}doSomethingwithconfig(context);複製程式碼

volatile的實現原理

處理器為了提高處理速度,不直接和記憶體進行通訊,而是將系統內部的資料讀到內部快取後在進行操作,但操作完之後不知道什麼時候會寫入記憶體。

如果對宣告瞭volatile變數進行寫操作時,JVM會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫會到系統記憶體。 這一步確保瞭如果有其他執行緒對宣告瞭volatile變數進行修改,則立即更新主記憶體中資料。

但這時候其他處理器的快取還是舊的,所以在多處理器環境下,為了保證各個處理器快取一致,每個處理會通過嗅探在匯流排上傳播的資料來檢查 自己的快取是否過期,當處理器發現自己快取行對應的記憶體地址被修改了,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作時,會強制重新從系統記憶體把資料讀到處理器快取裡。 這一步確保了其他執行緒獲得的宣告瞭volatile變數都是從主記憶體中獲取最新的。

Lock字首指令實際上相當於一個記憶體屏障(也成記憶體柵欄),它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成。

volatile的應用場景

synchronized關鍵字是防止多個執行緒同時執行一段程式碼,那麼就會很影響程式執行效率,而volatile關鍵字在某些情況下效能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

1)對變數的寫操作不依賴於當前值

2)該變數沒有包含在具有其他變數的不變式中

狀態標誌

也許實現 volatile 變數的規範使用僅僅是使用一個布林狀態標誌,用於指示發生了一個重要的一次性事件,例如完成初始化或請求停機。

volatile boolean shutdownRequested;

...

public void shutdown() { 
    shutdownRequested = true; 
}

public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
複製程式碼

執行緒1執行doWork()的過程中,可能有另外的執行緒2呼叫了shutdown,所以boolean變數必須是volatile。

而如果使用 synchronized 塊編寫迴圈要比使用 volatile 狀態標誌編寫麻煩很多。由於 volatile 簡化了編碼,並且狀態標誌並不依賴於程式內任何其他狀態,因此此處非常適合使用 volatile。

這種型別的狀態標記的一個公共特性是:通常只有一種狀態轉換shutdownRequested 標誌從false 轉換為true,然後程式停止。這種模式可以擴充套件到來回轉換的狀態標誌,但是隻有在轉換週期不被察覺的情況下才能擴充套件(從falsetrue,再轉換到false)。此外,還需要某些原子狀態轉換機制,例如原子變數。

一次性安全釋出

在缺乏同步的情況下,可能會遇到某個物件引用的更新值(由另一個執行緒寫入)和該物件狀態的舊值同時存在。

這就是造成著名的雙重檢查鎖定(double-checked-locking)問題的根源,其中物件引用在沒有同步的情況下進行讀操作,產生的問題是您可能會看到一個更新的引用,但是仍然會通過該引用看到不完全構造的物件

未完待續


相關文章