1. 程式人生 > >Java併發:volatile關鍵字詳解

Java併發:volatile關鍵字詳解

概述 volatile關鍵字可以說是Java虛擬機器提供的最輕量級的同步機制,但是它並不容易完全被正確、完整地理解,以至於許多程式設計師都習慣不去使用它,遇到需要處理多執行緒資料競爭問題的時候一律使用synchronized來進行同步。瞭解volatile變數的語義對了解多執行緒操作的其他特性很有意義,在本文中我們將介紹volatile的語義到底是什麼。由於volatile關鍵字與Java記憶體模型(Java Memory Model,JMM)有較多的關聯,因此在介紹volatile關鍵字前我們會先介紹下Java記憶體模型。

1.記憶體模型的相關概念 “讓計算機併發執行若干個運算任務”與“更充分地利用計算機處理器的效能”之間的因果關係,看起來順理成章,實際上它們之間的關係並沒有想象中的那麼簡單,其中一個重要的複雜性來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個I/O操作是很難消除的(無法僅靠暫存器來完成所有運算任務)。由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),如下圖所示。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及 Dragon Protocol等。在本文中將會多次提到的“記憶體模型”一詞,可以理解為在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的記憶體模型,而Java虛擬機器也有自己的記憶體模型,並且這裡介紹的記憶體訪問操作與硬體的快取訪問操作具有很高的可比性。

   處理器、快取記憶體、主記憶體間的互動關係

除了增加快取記憶體之外,為了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致,因此,如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠程式碼的先後順序來保證。與處理器的亂序執行優化類似,Java虛擬機器的即時編譯器中也有類似的指令重排序(Instruction Reorder)優化。

2.Java記憶體模型

Java虛擬機器規範中試圖定義一種Java記憶體模型來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。在此之前,主流程式語言(如C/C++等)直接使用物理硬體和作業系統的記憶體模型,因此,會由於不同平臺上記憶體模型的差異,有可能導致程式在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程式。

主記憶體與工作記憶體 Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數(Variables)與Java程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。

為了獲得較好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中。每條執行緒還有自己的工作記憶體(Working Memory,可與前面講的處理器快取記憶體類比),執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成,執行緒、主記憶體、工作記憶體三者的互動關係如圖所示。

執行緒、主記憶體、工作記憶體三者的互動關係

主記憶體直接對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器(甚至是硬體系統本身的優化措施)可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。

記憶體間互動操作 注:該部分內容可以作為了解簡單的看一下,下面會介紹該部分內容的等效判斷原則——先行發生規則(happens-before),先行發生規則更常問,並且更容易理解。

關於主記憶體與工作記憶體之間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,Java記憶體模型中定義了以下8種操作來完成,虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對於double和long型別的變數來說,load、store、read和write操作在某些平臺上允許有例外,可以)。

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  • unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行store和write操作。注意,Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read與load之間、store與write之間是可插入其他指令的,如對主記憶體中的變數a、b進行訪問時,一種可能出現順序是read a、read b、load b、load a。除此之外,Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  • 不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  •  一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一個時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
  • 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。
  • 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)。

這8種記憶體訪問操作以及上述規則限定,再加上稍後介紹的對volatile的一些特殊規定,就已經完全確定了Java程式中哪些記憶體訪問操作在併發下是安全的。由於這種定義相當嚴謹但又十分煩瑣,實踐起來很麻煩,所以在下文將介紹這種定義的一個等效判斷原則——先行發生原則,用來確定一個訪問在併發環境下是否安全。

原子性、可見性與有序性 原子性(Atomicity):一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

我們先來看看哪些是原子操作,哪些不是原子操作,先有一個直觀的印象:

int k = 5;  //程式碼1
k++;        //程式碼2
int j = k;  //程式碼3
k = k + 1;  //程式碼4

上面這4個程式碼中只有程式碼1是原子操作。

程式碼2:包含了三個操作。1.讀取變數k的值;2.將變數k的值加1;3.將計算後的值再賦值給變數k。

程式碼3:包含了兩個操作。1.讀取變數k的值;2.將變數k的值賦值給變數j。

程式碼4:包含了三個操作。1.讀取變數k的值;2.將變數k的值加1;3.將計算後的值再賦值給變數k。

注:實際編譯成位元組碼後,這些程式碼的位元組碼條數跟我上面的運算元可能有出入,但為了更容易理解,並且這些操作已經總體上能說明問題,因此使用這些操作來分析。

上面這個例子只是簡單的分析了幾種常見的情況。具體到底層的指令(上文記憶體間操作提到的8個指令),由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store和write,我們大致可以認為基本資料型別的訪問讀寫是具備原子性的。如果應用場景需要一個更大範圍的原子性保證,Java記憶體模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機器未把lock和unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個位元組碼指令反映到Java程式碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

可見性(Visibility):可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。 我們先看下以下的例子,對可見性有一個直觀的印象:

// 執行緒A執行的程式碼
int k = 0; //1
k = 5;     //2
// 執行緒B執行的程式碼
int j = k; //3

上面這個例子,如果執行緒A先執行,然後執行緒B再執行,j的值是多少了?

答案是無法確定。因為即使執行緒A已經把k的值更新為5,但是這個操作是線上程A的工作記憶體中完成的,工作記憶體所更新的變數並不會立即同步回主記憶體,因此執行緒B從主記憶體中得到的變數k的值是不確定的。這就是可見性問題,執行緒A對變數k修改了之後,執行緒B沒有立即看到執行緒A修改的值。

Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的,無論是普通變數還是volatile變數都是如此,普通變數與volatile變數的區別是,volatile的特殊規則保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。因此,可以說volatile保證了多執行緒操作時變數的可見性,而普通變數則不能保證這一點。

除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final。同步塊的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)”這條規則獲得的,而final關鍵字的可見性是指:被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他執行緒有可能通過這個引用訪問到“初始化了一半”的物件),那在其他執行緒中就能看見final欄位的值。

有序性(Ordering):一個執行緒中的所有操作必須按照程式的順序來執行。 我們先看下以下的例子,對有序性有一個直觀的印象:

例子1:

int k = 0; 
int j = 1  
k = 5; //程式碼1
j = 6; //程式碼2

按照有序性的規定,該例子中的程式碼1應該在程式碼2之前執行,但是實際上真的是這樣嗎?

答案是否定的,JVM並不保證上面這個程式碼1和程式碼2的執行順序,因為這兩行程式碼並沒有資料依賴性,先執行哪一行程式碼,最終的執行結果都不會改變,因此,JVM可能會進行指令重排序。

例子2:

int k = 1; // 程式碼1
int j = k; // 程式碼2

在單執行緒中,程式碼1的執行順序會在程式碼2之前嗎?

答案是肯定的,因為程式碼2依賴於程式碼1的執行結果,因此JVM不會對這兩行程式碼進行指令重排序。

Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一個時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

介紹完併發中3種重要的特性後,我們發現synchronized關鍵字在需要這3種特性的時候都可以作為其中一種的解決方案,看起來很“萬能”。的確,大部分的併發控制操作都能使用synchronized來完成。synchronized的“萬能”也間接造就了它被程式設計師濫用的局面,越“萬能”的併發控制,通常會伴隨著越大的效能影響。

重排序 上面的有序性提到了重排序,這裡稍微介紹下重排序的基本內容。

1.什麼是重排序? 重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段。

2.重排序有哪些? 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。3.為什麼要重排序? 為了提高效能。

4.重排序會導致不正確的結果嗎? 重排序保證在單執行緒下不會改變執行結果,但在多執行緒下可能會改變執行結果。

例子1:

上圖的3種情況,在單執行緒下,只要重排序了兩個操作的執行順序就會改變執行結果,因此這3種情況的程式碼是不會被重排序的。

例子2:

1int a = 1;
2int b = 2;

上面這段程式碼的兩個操作並沒有資料依賴性,改變兩個操作的執行順序也不會改變執行結果,因此有可能被重排序。

5.怎麼禁止重排序? 可以通過插入記憶體屏障指令來禁止特定型別的處理器重排序。例如本文將提到的volatile關鍵字就有這種功能。

先行發生原則 Java語言中有一個“先行發生”(happens-before)的原則。這個原則非常重要,它是判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們可以通過幾條規則解決併發環境下兩個操作之間是否可能存在衝突的所有問題。

現在就來看看“先行發生”原則指的是什麼。先行發生是Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。這句話不難理解,但它意味著什麼呢?我們可以舉個例子來說明一下,如程式碼中所示的這3句虛擬碼。

//以下操作線上程A中執行
k=1;
//以下操作線上程B中執行
j=k;
//以下操作線上程C中執行
k=2;

假設執行緒A中的操作“k=1”先行發生於執行緒B的操作“j=k”,那麼可以確定線上程B的操作執行後,變數j的值一定等於1,得出這個結論的依據有兩個:一是根據先行發生原則,“k=1”的結果可以被觀察到;二是執行緒C還沒“登場”,執行緒A操作結束之後沒有其他執行緒會修改變數k的值。現在再來考慮執行緒C,我們依然保持執行緒A和執行緒B之間的先行發生關係,而執行緒C出現線上程A和執行緒B的操作之間,但是執行緒C與執行緒B沒有先行發生關係,那j的值會是多少呢?答案是不確定!1和2都有可能,因為執行緒C對變數k的影響可能會被執行緒B觀察到,也可能不會,這時候執行緒B就存在讀取到過期資料的風險,不具備多執行緒安全性。

下面是Java記憶體模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序。

  • 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說,應該是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而“後面”是指時間上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後順序。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

Java語言無須任何同步手段保障就能成立的先行發生規則就只有上面這些了,下面演示一下如何使用這些規則去判定操作間是否具備順序性,對於讀寫共享變數的操作來說,就是執行緒是否安全,讀者還可以從下面這個例子中感受一下“時間上的先後順序”與“先行發生”之間有什麼不同。

private int value=0;
pubilc void setValue(int value){ 
    this.value=value;
}
public int getValue(){ 
    return value;
}

上面的程式碼是一組再普通不過的getter/setter方法,假設存線上程A和B,執行緒A先(時間上的先後)呼叫了“setValue(1)”,然後執行緒B呼叫了同一個物件的“getValue()”,那麼執行緒B收到的返回值是什麼?

我們依次分析一下先行發生原則中的各項規則,由於兩個方法分別由執行緒A和執行緒B呼叫,不在一個執行緒中,所以程式次序規則在這裡不適用;由於沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用;由於value變數沒有被volatile關鍵字修飾,所以volatile變數規則不適用;後面的執行緒啟動、終止、中斷規則和物件終結規則也和這裡完全沒有關係。因為沒有一個適用的先行發生規則,所以最後一條傳遞性也無從談起,因此我們可以判定儘管執行緒A在操作時間上先於執行緒B,但是無法確定執行緒B中“getValue()”方法的返回結果,換句話說,這裡面的操作不是執行緒安全的。

那怎麼修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇:要麼把getter/setter方法都定義為synchronized方法,這樣就可以套用管程鎖定規則;要麼把value定義為volatile變數,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變數規則來實現先行發生關係。

通過上面的例子,我們可以得出結論:一個操作“時間上的先發生”不代表這個操作會是“先行發生”,那如果一個操作“先行發生”是否就能推匯出這個操作必定是“時間上的先發生”呢?很遺憾,這個推論也是不成立的,一個典型的例子就是多次提到的“指令重排序”,演示例子如下程式碼所示。

//以下操作在同一個執行緒中執行 
int i=1;
int j=2;

程式碼清單的兩條賦值語句在同一個執行緒之中,根據程式次序規則,“int i=1”的操作先行發生於“int j=2”,但是“int j=2”的程式碼完全可能先被處理器執行,這並不影響先行發生原則的正確性,因為我們在這條執行緒之中沒有辦法感知到這點。

上面兩個例子綜合起來證明了一個結論:時間先後順序與先行發生原則之間基本沒有太大的關係,所以我們衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。

3.volatile詳解volatile的特性 Java記憶體模型對volatile專門定義了一些特殊的訪問規則,當一個變數定義為volatile之後,它將具備兩種特性。

  • 保證此變數對所有執行緒的可見性,即當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。而普通變數不能做到這一點,普通變數的值線上程間傳遞均需要通過主記憶體來完成,例如,執行緒A修改一個普通變數的值,然後向主記憶體進行回寫,另外一條執行緒B線上程A回寫完成了之後再從主記憶體進行讀取操作,新變數值才會對執行緒B可見。
  • 禁止指令重排序優化。普通的變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。因為在一個執行緒的方法執行過程中無法感知到這點,這也就是Java記憶體模型中描述的所謂的“執行緒內表現為序列的語義”(Within-Thread As-If-Serial Semantics)。

volatile能保證原子性嗎? 關於volatile變數的可見性,經常會被開發人員誤解,認為以下描述成立:“volatile變數對所有執行緒是立即可見的,對volatile變數所有的寫操作都能立刻反應到其他執行緒之中,換句話說,volatile變數在各個執行緒中是一致的,所以基於volatile變數的運算在併發下是安全的”。這句話的論據部分並沒有錯,但是其論據並不能得出“基於volatile變數的運算在併發下是安全的”這個結論。volatile變數在各個執行緒的工作記憶體中不存在一致性問題,但是Java裡面的運算並非原子操作,並且volatile並不能保證原子性,導致volatile變數的運算在併發下一樣是不安全的,我們可以通過一段簡單的演示來說明原因,請看下面的例子。

例子:多執行緒下的自增運算

public class VolatileDemo {
	public static volatile int n = 0;
	
	public static final int THREAD_COUNT=20;
	
	public static void main(String[] args) {
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i = 0; i < THREAD_COUNT; i++)
		{
			threads[i] = new Thread(new Runnable() {
				
				@Override
				public void run() {
					for(int i = 0; i < 10000; i++) {
						n++;
					}
				}
			});
			threads[i].start();
		}
		//等待除了主執行緒外其他所有執行緒都結束
		while(Thread.activeCount() > 1) {
			Thread.yield();
		}
		System.out.println(n);
	}
}

例子分析:

這段程式碼發起了20個執行緒,每個執行緒對race變數進行10000次自增操作,如果這段程式碼能夠正確併發的話,最後輸出的結果應該是200000。執行完這段程式碼之後,並不會獲得期望的結果,而且會發現每次執行程式,輸出的結果都不一樣,都是一個小於200000的數字,這是為什麼呢?

問題就出現在自增運算“race++”之中,我們用Javap反編譯這段程式碼後會發現只有一行程式碼的increase()方法在Class檔案中是由4條位元組碼指令構成的,從位元組碼層面上很容易就分析出併發失敗的原因了:當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他執行緒可能已經把race的值加大了,而在操作棧頂的值就變成了過期的資料,所以putstatic指令執行後就可能把較小的race值同步回主記憶體之中。 

getstatic // 獲取靜態變數race,並將值壓入棧頂
iconst_1  // 將int值1推送至棧頂
iadd      // 將棧頂兩個int型數值相加並將結果壓入棧頂
putstatic // 為靜態變數race賦值

從這個例子我們可以確定volatile是不能保證原子性的,要保證運算的原子性可以使用java.util.concurrent.atomic包下的一些原子操作類。例如最常見的: AtomicInteger。

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

//雙重檢測單例模式
public class Singleton {
	// 私有化建構函式
	private Singleton() {}
	// 沒有加入volatile關鍵字
	private static Singleton instence = null;
	// 對外提供的工廠方法
	public static Singleton getInstence() {
		// 第一次檢測
		if(instence == null) {
			// 同步鎖
			synchronized (Singleton.class) {
				// 第二次檢測
				if(instence == null) {
					instence = new Singleton();
				}
			}
		}
		return instence;
	}
	
}

這段程式碼是單例的雙重檢測機制實現,相信很多人都用過,並且覺得這個程式碼是沒問題的。在大多數情況,這段程式碼確實沒問題,但在極端的情況下,有個隱藏的問題。

例子分析:

假設有兩個執行緒同時訪問這段程式碼,此時執行緒A走到15行開始初始化物件,執行緒B則剛走到12行進行第一次檢測。這時要介紹下15行初始化這行程式碼,這行程式碼雖然只有一句話,但是被編譯後會變成以下3條指令:

memory = allocate();    // 1.分配物件的記憶體空間
ctorInstance(memory);    // 2.初始化物件
instance = memory;    // 3.設定instance指向剛才分配的記憶體地址

正常情況下,這3條執行時按順序執行,雙重檢測機制就沒有問題。但是CPU內部會在保證不影響最終結果的前提下對指令進行重新排序(不影響最終結果只是針對單執行緒,切記),指令重排的主要目的是為了提高效率。在本例中,如果這3條指令被重排成以下順序:

memory = allocate();    // 1.分配物件的記憶體空間
instance = memory;    // 3.設定instance指向剛才分配的記憶體地址
ctorInstance(memory);    // 2.初始化物件

如果執行緒A執行完1和3,instance物件還未完成初始化,但是已經不再指向null。此時執行緒B搶佔到CPU資源,執行第12行的檢測結果為false,則執行第19行,從而返回一個還未初始化完成的instance物件,從而出導致問題出現。要解決這個問題,只需要使用volatile關鍵字修飾instance物件即可。

volatile的使用限制 由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性。

  • 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  • 變數不需要與其他的狀態變數共同參與不變約束。

volatile的使用場景 1.狀態標記量 使用volatile來修飾狀態標記量,使得狀態標記量對所有執行緒是實時可見的,從而保證所有執行緒都能實時獲取到最新的狀態標記量,進一步決定是否進行操作。例如常見的促銷活動“秒殺”,可以用volatile來修飾“是否售罄”欄位,從而保證在併發下,能正確的處理商品是否售罄。

volatile boolean flag = false;
while(!flag){
    doSomething();
}
public void setFlag() {
    flag = true;
}

2.雙重檢測機制實現單例 普通的雙重檢測機制在極端情況,由於指令重排序會出現問題,通過使用volatile來修飾instance,禁止指令重排序,從而可以正確的實現單例。

//雙重檢測單例模式
public class Singleton {
	// 私有化建構函式
	private Singleton() {}
	// volatile修飾單例物件,volatile在這裡的作用是禁止指令重拍帶來的問題
	private static volatile Singleton instence = null;
	// 對外提供的工廠方法
	public static Singleton getInstence() {
		// 第一次檢測
		if(instence == null) {
			// 同步鎖
			synchronized (Singleton.class) {
				// 第二次檢測
				if(instence == null) {
					instence = new Singleton();
				}
			}
		}
		return instence;
	}
	
}

總結: 1、每個執行緒有自己的工作記憶體,工作記憶體中的資料並不會實時重新整理回主記憶體,因此在併發情況下,有可能執行緒A已經修改了成員變數k的值,但是執行緒B並不能讀取到執行緒A修改後的值,這是因為執行緒A的工作記憶體還沒有被重新整理回主記憶體,導致執行緒B無法讀取到最新的值。 2、在工作記憶體中,每次使用volatile修飾的變數前都必須先從主記憶體重新整理最新的值,這保證了當前執行緒能看見其他執行緒對volatile修飾的變數所做的修改後的值。 3、在工作記憶體中,每次修改volatile修飾的變數後都必須立刻同步回主記憶體中,這保證了其他執行緒可以看到自己對volatile修飾的變數所做的修改。 4、volatile修飾的變數不會被指令重排序優化,保證程式碼的執行順序與程式的順序相同。 5、volatile保證可見性,不保證原子性,部分保證有序性(僅保證被volatile修飾的變數)。 6、指令重排序的目的是為了提高效能,指令重排序僅保證在單執行緒下不會改變最終的執行結果,但無法保證在多執行緒下的執行結果。 7、為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止重排序。