Java併發程式設計:volatile關鍵字解析

   volatile這個關鍵字可能很多朋友都聽說過,或許也都用過。在Java 5之前,它是一個備受爭議的關鍵字,因為在程式中使用它往往會導致出人意料的結果。在Java 5之後,volatile關鍵字才得以重獲生機。

  volatile關鍵字雖然從字面上理解起來比較簡單,但是要用好不是一件容易的事情。由於volatile關鍵字是與Java的記憶體模型有關的,因此在講述volatile關鍵之前,我們先來了解一下與記憶體模型相關的概念和知識,然後分析了volatile關鍵字的實現原理,最後給出了幾個使用volatile關鍵字的場景。

  以下是本文的目錄大綱:

  一.記憶體模型的相關概念

  二.併發程式設計中的三個概念

  三.Java記憶體模型

  四..深入剖析volatile關鍵字

  五.使用volatile關鍵字的場景

  若有不正之處請多多諒解,並歡迎批評指正。

  請尊重作者勞動成果,轉載請標明原文連結:

  http://www.cnblogs.com/dolphin0520/p/3920373.html

一.記憶體模型的相關概念

  大家都知道,計算機在執行程式時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到資料的讀取和寫入。由於程式執行過程中的臨時資料是存放在主存(實體記憶體)當中的,這時就存在一個問題,由於CPU執行速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。因此在CPU裡面就有了快取記憶體。

  也就是,當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。舉個簡單的例子,比如下面的這段程式碼:

1
i = i + 1;

   當執行緒執行這個語句時,會先從主存當中讀取i的值,然後複製一份到快取記憶體當中,然後CPU執行指令對i進行加1操作,然後將資料寫入快取記憶體,最後將快取記憶體中i最新的值重新整理到主存當中。

  這個程式碼在單執行緒中執行是沒有任何問題的,但是在多執行緒中執行就會有問題了。在多核CPU中,每條執行緒可能運行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的)。本文我們以多核CPU為例。

  比如同時有2個執行緒執行這段程式碼,假如初始時i的值為0,那麼我們希望兩個執行緒執行完之後i的值變為2。但是事實會是這樣嗎?

  可能存在下面一種情況:初始時,兩個執行緒分別讀取i的值存入各自所在的CPU的快取記憶體當中,然後執行緒1進行加1操作,然後把i的最新值1寫入到記憶體。此時執行緒2的快取記憶體當中i的值還是0,進行加1操作之後,i的值為1,然後執行緒2把i的值寫入記憶體。

  最終結果i的值是1,而不是2。這就是著名的快取一致性問題。通常稱這種被多個執行緒訪問的變數為共享變數。

  也就是說,如果一個變數在多個CPU中都存在快取(一般在多執行緒程式設計時才會出現),那麼就可能存在快取不一致的問題。

  為了解決快取不一致性問題,通常來說有以下2種解決方法:

  1)通過在匯流排加LOCK#鎖的方式

  2)通過快取一致性協議

  這2種方式都是硬體層面上提供的方式。

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。比如上面例子中 如果一個執行緒在執行 i = i +1,如果在執行這段程式碼的過程中,在總線上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

  但是上面的方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。

  所以就出現了快取一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

二.併發程式設計中的三個概念

  在併發程式設計中,我們通常會遇到以下三個問題:原子性問題,可見性問題,有序性問題。我們先看具體看一下這三個概念:

1.原子性

  原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

  一個很經典的例子就是銀行賬戶轉賬問題:

  比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

  試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬戶A減去1000元之後,操作突然中止。然後又從B取出了500元,取出500元之後,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。

  所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

  同樣地反映到併發程式設計中會出現什麼結果呢?

  舉個最簡單的例子,大家想一下假如為一個32位的變數賦值過程不具備原子性的話,會發生什麼後果?

1
i = 9;

   假若一個執行緒執行到這個語句時,我暫且假設為一個32位的變數賦值包括兩個過程:為低16位賦值,為高16位賦值。

  那麼就可能發生一種情況:當將低16位數值寫入之後,突然被中斷,而此時又有一個執行緒去讀取i的值,那麼讀取到的就是錯誤的資料。

2.可見性

  可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

  舉個簡單的例子,看下面這段程式碼:

1
2
3
4
5
6
//執行緒1執行的程式碼
int i = 0;
i = 10;
//執行緒2執行的程式碼
j = i;

   假若執行執行緒1的是CPU1,執行執行緒2的是CPU2。由上面的分析可知,當執行緒1執行 i =10這句時,會先把i的初始值載入到CPU1的快取記憶體中,然後賦值為10,那麼在CPU1的快取記憶體當中i的值變為10了,卻沒有立即寫入到主存當中。

  此時執行緒2執行 j = i,它會先去主存讀取i的值並載入到CPU2的快取當中,注意此時記憶體當中i的值還是0,那麼就會使得j的值為0,而不是10.

  這就是可見性問題,執行緒1對變數i修改了之後,執行緒2沒有立即看到執行緒1修改的值。

3.有序性

  有序性:即程式執行的順序按照程式碼的先後順序執行。舉個簡單的例子,看下面這段程式碼:

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;          //語句2

   上面程式碼定義了一個int型變數,定義了一個boolean型別變數,然後分別對兩個變數進行賦值操作。從程式碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段程式碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什麼呢?這裡可能會發生指令重排序(Instruction Reorder)。

  下面解釋一下什麼是指令重排序,一般來說,處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。

  比如上面的程式碼中,語句1和語句2誰先執行對最終的程式結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。

  但是要注意,雖然處理器會對指令進行重排序,但是它會保證程式最終結果會和程式碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:

0.001291036605835