Java併發指南3:併發三大問題與volatile關鍵字,CAS操作

序言

先來看如下這個簡單的Java類,該類中並沒有使用任何的同步。

01final class SetCheck {
02private int  a = 0;
03private long b = 0;
04
05void set() {
06a =  1;
07b = -1;
08}
09
10boolean check() {
11return ((b ==  0) ||
12(b == -1 && a == 1));
13}
14}

如果是在一個串行執行的語言中,執行SetCheck類中的check方法永遠不會返回false,即使編譯器,運行時和計算機硬件並沒有按照你所期望的邏輯來處理這段程序,該方法依然不會返回false。在程序執行過程中,下面這些你所不能預料的行爲都是可能發生的:

  • 編譯器可能會進行指令重排序,所以b變量的賦值操作可能先於a變量。如果是一個內聯方法,編譯器可能更甚一步將該方法的指令與其他語句進行重排序。

  • 處理器可能會對語句所對應的機器指令進行重排序之後再執行,甚至併發地去執行。

  • 
內存系統(由高速緩存控制單元組成)可能會對變量所對應的內存單元的寫操作指令進行重排序。重排之後的寫操作可能會對其他的計算/內存操作造成覆蓋。

  • 編譯器,處理器以及內存系統可能會讓兩條語句的機器指令交錯。比如在32位機器上,b變量的高位字節先被寫入,然後是a變量,緊接着纔會是b變量的低位字節。

  • 編譯器,處理器以及內存系統可能會導致代表兩個變量的內存單元在(如果有的話)連續的check調用(如果有的話)之後的某個時刻才更新,而以這種方式保存相應的值(如在CPU寄存器中)仍會得到預期的結果(check永遠不會返回false)。


在串行執行的語言中,只要程序執行遵循類似串行的語義,如上幾種行爲就不會有任何的影響。在一段簡單的代碼塊中,串行執行程序不會依賴於代碼的內部執行細節,因此如上的幾種行爲可以隨意控制代碼。這樣就爲編譯器和計算機硬件提供了基本的靈活性。基於此,在過去的數十年內很多技術(CPU的流水線操作,多級緩存,讀寫平衡,寄存器分配等等)應運而生,爲計算機處理速度的大幅提升奠定了基礎。這些操作的類似串行執行的特性可以讓開發人員無須知道其內部發生了什麼。對於開發人員來說,如果不創建自己的線程,那麼這些行爲也不會對其產生任何的影響。

然而這些情況在併發編程中就完全不一樣了,上面的代碼在併發過程中,當一個線程調用check方法的時候完全有可能另一個線程正在執行set方法,這種情況下check方法就會將上面提到的優化操作過程暴露出來。如果上述任意一個操作發生,那麼check方法就有可能返回false。例如,check方法讀取long類型的變量b的時候可能得到的既不是0也不是-1.而是一個被寫入一半的值。另一種情況,set方法中的語句的亂序執行有可能導致check方法讀取變量b的值的時候是-1,然而讀取變量a時卻依然是0。

換句話說,不僅是併發執行會導致問題,而且在一些優化操作(比如指令重排序)進行之後也會導致代碼執行結果和源代碼中的邏輯有所出入。由於編譯器和運行時技術的日趨成熟以及多處理器的逐漸普及,這種現象就變得越來越普遍。對於那些一直從事串行編程背景的開發人員(其實,基本上所有的程序員)來說,這可能會導致令人詫異的結果,而這些結果可能從沒在串行編程中出現過。這可能就是那些微妙難解的併發編程錯誤的根本源頭吧。

在絕大部分的情況下,有一個很簡單易行的方法來避免那些在複雜的併發程序中因代碼執行優化導致的問題:使用同步。例如,如果SetCheck類中所有的方法都被聲明爲synchronized,那麼你就可以確保那麼內部處理細節都不會影響代碼預期的結果了。

但是在有些情況下你卻不能或者不想去使用同步,抑或着你需要推斷別人未使用同步的代碼。在這些情況下你只能依賴Java內存模型所闡述的結果語義所提供的最小保證。Java內存模型允許上面提到的所有操作,但是限制了它們在執行語義上潛在的結果,此外還提出了一些技術讓程序員可以用來控制這些語義的某些方面。

Java內存模型是Java語言規範的一部分,主要在JLS的第17章節介紹。這裏,我們只是討論一些基本的動機,屬性以及模型的程序一致性。這裏對JLS第一版中所缺少的部分進行了澄清。

我們假設Java內存模型可以被看作在1.2.4中描述的那種標準的SMP機器的理想化模型。

(1.2.4)

在這個模型中,每一個線程都可以被看作爲運行在不同的CPU上,然而即使是在多處理器上,這種情況也是很罕見的。但是實際上,通過模型所具備的某些特性,這種CPU和線程單一映射能夠通過一些合理的方法去實現。例如,因爲CPU的寄存器不能被另一個CPU直接訪問,這種模型必須考慮到某個線程無法得知被另一個線程操作變量的值的情況。這種情況不僅僅存在於多處理器環境上,在單核CPU環境裏,因爲編譯器和處理器的不可預測的行爲也可能導致同樣的情況。

Java內存模型沒有具體講述前面討論的執行策略是由編譯器,CPU,緩存控制器還是其它機制促成的。甚至沒有用開發人員所熟悉的類,對象及方法來討論。取而代之,Java內存模型中僅僅定義了線程和內存之間那種抽象的關係。衆所周知,每個線程都擁有自己的工作存儲單元(緩存和寄存器的抽象)來存儲線程當前使用的變量的值。Java內存模型僅僅保證了代碼指令與變量操作的有序性,大多數規則都只是指出什麼時候變量值應該在內存和線程工作內存之間傳輸。這些規則主要是爲了解決如下三個相互牽連的問題:

  1. 原子性:哪些指令必須是不可分割的。在Java內存模型中,這些規則需聲明僅適用於-—實例變量和靜態變量,也包括數組元素,但不包括方法中的局部變量-—的內存單元的簡單讀寫操作。

  2. 可見性:在哪些情況下,一個線程執行的結果對另一個線程是可見的。這裏需要關心的結果有,寫入的字段以及讀取這個字段所看到的值。

  3. 有序性:在什麼情況下,某個線程的操作結果對其它線程來看是無序的。最主要的亂序執行問題主要表現在讀寫操作和賦值語句的相互執行順序上。

原子性

當正確的使用了同步,上面屬性都會具有一個簡單的特性:一個同步方法或者代碼塊中所做的修改對於使用了同一個鎖的同步方法或代碼塊都具有原子性和可見性。同步方法或代碼塊之間的執行過程都會和代碼指定的執行順序保持一致。即使代碼塊內部指令也許是亂序執行的,也不會對使用了同步的其它線程造成任何影響。

當沒有使用同步或者使用的不一致的時候,情況就會變得複雜。Java內存模型所提供的保障要比大多數開發人員所期望的弱,也遠不及目前業界所實現的任意一款Java虛擬機。這樣,開發人員就必須負起額外的義務去保證對象的一致性關係:對象間若有能被多個線程看到的某種恆定關係,所有依賴這種關係的線程就必須一直維持這種關係,而不僅僅由執行狀態修改的線程來維持。

 

 

除了long型字段和double型字段外,java內存模型確保訪問任意類型字段所對應的內存單元都是原子的。這包括引用其它對象的引用類型的字段。此外,volatile long 和volatile double也具有原子性 。(雖然java內存模型不保證non-volatile long 和 non-volatile double的原子性,當然它們在某些場合也具有原子性。)(譯註:non-volatile long在64位JVM,OS,CPU下具有原子性)

當在一個表達式中使用一個non-long或者non-double型字段時,原子性可以確保你將獲得這個字段的初始值或者某個線程對這個字段寫入之後的值;但不會是兩個或更多線程在同一時間對這個字段寫入之後產生混亂的結果值(即原子性可以確保,獲取到的結果值所對應的所有bit位,全部都是由單個線程寫入的)。但是,如下面(譯註:指可見性章節)將要看到的,原子性不能確保你獲得的是任意線程寫入之後的最新值。 因此,原子性保證通常對併發程序設計的影響很小。

可見性

 

 

 

只有在下列情況時,一個線程對字段的修改才能確保對另一個線程可見:

一個寫線程釋放一個鎖之後,另一個讀線程隨後獲取了同一個鎖。本質上,線程釋放鎖時會將強制刷新工作內存中的髒數據到主內存中,獲取一個鎖將強制線程裝載(或重新裝載)字段的值。鎖提供對一個同步方法或塊的互斥性執行,線程執行獲取鎖和釋放鎖時,所有對字段的訪問的內存效果都是已定義的。

注意同步的雙重含義:鎖提供高級同步協議,同時在線程執行同步方法或塊時,內存系統(有時通過內存屏障指令)保證值的一致性。這說明,與順序程序設計相比較,併發程序設計與分佈式程序設計更加類似。同步的第二個特性可以視爲一種機制:一個線程在運行已同步方法時,它將發送和/或接收其他線程在同步方法中對變量所做的修改。從這一點來說,使用鎖和發送消息僅僅是語法不同而已。


如果把一個字段聲明爲volatile型,線程對這個字段寫入後,在執行後續的內存訪問之前,線程必須刷新這個字段且讓這個字段對其他線程可見(即該字段立即刷新)。每次對volatile字段的讀訪問,都要重新裝載字段的值。

一個線程首次訪問一個對象的字段,它將讀到這個字段的初始值或被某個線程寫入後的值。
此外,把還未構造完成的對象的引用暴露給某個線程,這是一個錯誤的做法 (see ?.1.2)。在構造函數內部開始一個新線程也是危險的,特別是這個類可能被子類化時。Thread.start有如下的內存效果:調用start方法的線程釋放了鎖,隨後開始執行的新線程獲取了這個鎖。如果在子類構造函數執行之前,可運行的超類調用了new Thread(this).start(),當run方法執行時,對象很可能還沒有完全初始化。同樣,如果你創建且開始一個新線程T,這個線程使用了在執行start之後才創建的一個對象X。你不能確信X的字段值將能對線程T可見。除非你把所有用到X的引用的方法都同步。如果可行的話,你可以在開始T線程之前創建X。

線程終止時,所有寫過的變量值都要刷新到主內存中。比如,一個線程使用Thread.join來終止另一個線程,那麼第一個線程肯定能看到第二個線程對變量值得修改。

注意,在同一個線程的不同方法之間傳遞對象的引用,永遠也不會出現內存可見性問題。
內存模型確保上述操作最終會發生,一個線程對一個特定字段的特定更新,最終將會對其他線程可見,但這個“最終”可能是很長一段時間。線程之間沒有同步時,很難保證對字段的值能在多線程之間保持一致(指寫線程對字段的寫入立即能對讀線程可見)。特別是,如果字段不是volatile或沒有通過同步來訪問這個字段,在一個循環中等待其他線程對這個字段的寫入,這種情況總是錯誤的(see ?.2.6)。

在缺乏同步的情況下,模型還允許不一致的可見性。比如,得到一個對象的一個字段的最新值,同時得到這個對象的其他字段的過期的值。同樣,可能讀到一個引用變量的最新值,但讀取到這個引用變量引用的對象的字段的過期值。
不管怎樣,線程之間的可見性並不總是失效(指線程即使沒有使用同步,仍然有可能讀取到字段的最新值),內存模型僅僅是允許這種失效發生而已。因此,即使多個線程之間沒有使用同步,也不保證一定會發生內存可見性問題(指線程讀取到過期的值),java內存模型僅僅是允許內存可見性問題發生而已。在很多當前的JVM實現和java執行平臺中,甚至是在那些使用多處理器的JVM和平臺中,也很少出現內存可見性問題。共享同一個CPU的多個線程使用公共的緩存,缺少強大的編譯器優化,以及存在強緩存一致性的硬件,這些都會使線程更新後的值能夠立即在多線程之間傳遞。這使得測試基於內存可見性的錯誤是不切實際的,因爲這樣的錯誤極難發生。或者這種錯誤僅僅在某個你沒有使用過的平臺上發生,或僅在未來的某個平臺上發生。這些類似的解釋對於多線程之間的內存可見性問題來說非常普遍。沒有同步的併發程序會出現很多問題,包括內存一致性問題。

 

有序性 

 

有序性規則表現在以下兩種場景: 線程內和線程間

  •  從某個線程的角度看方法的執行,指令會按照一種叫“串行”(as-if-serial)的方式執行,此種方式已經應用於順序編程語言。

  •  這個線程“觀察”到其他線程併發地執行非同步的代碼時,任何代碼都有可能交叉執行。唯一起作用的約束是:對於同步方法,同步塊以及volatile字段的操作仍維持相對有序。

 
再次提醒,這些僅是最小特性的規則。具體到任何一個程序或平臺上,可能存在更嚴格的有序性規則。所以你不能依賴它們,因爲即使你的代碼遵循了這些更嚴格的規則,仍可能在不同特性的JVM上運行失敗,而且測試非常困難。

需要注意的是,線程內部的觀察視角被JLS [1] 中其他的語義的討論所採用。例如,算術表達式的計算在線程內看來是從左到右地執行操作(JLS 15.6章節),而這種執行效果是沒有必要被其他線程觀察到的。

僅當某一時刻只有一個線程操作變量時,線程內的執行表現爲串行。出現上述情景,可能是因爲使用了同步,互斥體[2] 或者純屬巧合。當多線程同時運行在非同步的代碼裏進行公用字段的讀寫時,會形成一種執行模式。在這種模式下,代碼會任意交叉執行,原子性和可見性會失效,以及產生競態條件。這時線程執行不再表現爲串行。

儘管JLS列出了一些特定的合法和非法的重排序,如果碰到所列範圍之外的問題,會降低以下這條實踐保證 :運行結果反映了幾乎所有的重排序產生的代碼交叉執行的情況。所以,沒必要去探究這些代碼的有序性。

 

volatile關鍵字詳解:在JMM中volatile的內存語義是鎖

 

volatile的特性

當我們聲明共享變量爲volatile後,對這個變量的讀/寫將會很特別。理解volatile特性的一個好方法是:把對volatile變量的單個讀/寫,看成是使用同一個監視器鎖對這些單個讀/寫操作做了同步。下面我們通過具體的示例來說明,請看下面的示例代碼:

class VolatileFeaturesExample {volatile long vl = 0L;  //使用volatile聲明64位的long型變量public void set(long l) {vl = l;   //單個volatile變量的寫}public void getAndIncrement () {vl++;    //複合(多個)volatile變量的讀/寫}public long get() {return vl;   //單個volatile變量的讀}}

假設有多個線程分別調用上面程序的三個方法,這個程序在語意上和下面程序等價:

class VolatileFeaturesExample {long vl = 0L;               // 64位的long型普通變量public synchronized void set(long l) {     //對單個的普通 變量的寫用同一個監視器同步vl = l;}public void getAndIncrement () { //普通方法調用long temp = get();           //調用已同步的讀方法temp += 1L;                  //普通寫操作set(temp);                   //調用已同步的寫方法}public synchronized long get() {//對單個的普通變量的讀用同一個監視器同步return vl;}}

如上面示例程序所示,對一個volatile變量的單個讀/寫操作,與對一個普通變量的讀/寫操作使用同一個監視器鎖來同步,它們之間的執行效果相同。

監視器鎖的happens-before規則保證釋放監視器和獲取監視器的兩個線程之間的內存可見性,這意味着對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。


簡而言之,volatile變量自身具有下列特性:監視器鎖的語義決定了臨界區代碼的執行具有原子性。這意味着即使是64位的long型和double型變量,只要它是volatile變量,對該變量的讀寫就將具有原子性。如果是多個volatile操作或類似於volatile++這種複合操作,這些操作整體上不具有原子性。

  • 可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。

  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

volatile寫-讀建立的happens before關係

上面講的是volatile變量自身的特性,對程序員來說,volatile對線程的內存可見性的影響比volatile自身的特性更爲重要,也更需要我們去關注。

從JSR-133開始,volatile變量的寫-讀可以實現線程之間的通信。

從內存語義的角度來說,volatile與監視器鎖有相同的效果:volatile寫和監視器的釋放有相同的內存語義;volatile讀與監視器的獲取有相同的內存語義。

請看下面使用volatile變量的示例代碼:

class VolatileExample {int a = 0;volatile boolean flag = false;public void writer() {a = 1;                   //1flag = true;               //2}public void reader() {if (flag) {                //3int i =  a;           //4……}}}

假設線程A執行writer()方法之後,線程B執行reader()方法。根據happens before規則,這個過程建立的happens before 關係可以分爲兩類:

  1. 根據程序次序規則,1 happens before 2; 3 happens before 4。

  2. 根據volatile規則,2 happens before 3。

  3. 根據happens before 的傳遞性規則,1 happens before 4。

上述happens before 關係的圖形化表現形式如下:

在上圖中,每一個箭頭鏈接的兩個節點,代表了一個happens before 關係。黑色箭頭表示程序順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens before保證。

這裏A線程寫一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量之前所有可見的共享變量,在B線程讀同一個volatile變量後,將立即變得對B線程可見。

volatile寫-讀的內存語義

volatile寫的內存語義如下:

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存。

以上面示例程序VolatileExample爲例,假設線程A首先執行writer()方法,隨後線程B執行reader()方法,初始時兩個線程的本地內存中的flag和a都是初始狀態。下圖是線程A執行volatile寫後,共享變量的狀態示意圖:

如上圖所示,線程A在寫flag變量後,本地內存A中被線程A更新過的兩個共享變量的值被刷新到主內存中。此時,本地內存A和主內存中的共享變量的值是一致的。

volatile讀的內存語義如下:

  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

下面是線程B讀同一個volatile變量後,共享變量的狀態示意圖:

如上圖所示,在讀flag變量後,本地內存B已經被置爲無效。此時,線程B必須從主內存中讀取共享變量。線程B的讀取操作將導致本地內存B與主內存中的共享變量的值也變成一致的了。

如果我們把volatile寫和volatile讀這兩個步驟綜合起來看的話,在讀線程B讀一個volatile變量後,寫線程A在寫這個volatile變量之前所有可見的共享變量的值都將立即變得對讀線程B可見。

下面對volatile寫和volatile讀的內存語義做個總結:

  • 線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所在修改的)消息。

  • 線程B讀一個volatile變量,實質上是線程B接收了之前某個線程發出的(在寫這個volatile變量之前對共享變量所做修改的)消息。

  • 線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A通過主內存向線程B發送消息。

volatile內存語義的實現

下面,讓我們來看看JMM如何實現volatile寫/讀的內存語義。

前文我們提到過重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。下面是JMM針對編譯器制定的volatile重排序規則表:

是否能重排序第二個操作
第一個操作普通讀/寫volatile讀volatile寫
普通讀/寫

NO
volatile讀NONONO
volatile寫
NONO

舉例來說,第三行最後一個單元格的意思是:在程序順序中,當第一個操作爲普通變量的讀或寫時,如果第二個操作爲volatile寫,則編譯器不能重排序這兩個操作。

從上表我們可以看出:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,爲此,JMM採取保守策略。下面是基於保守策略的JMM內存屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。

  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。

  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。

  • 在每個volatile讀操作的後面插入一個LoadStore屏障。

上述內存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內存語義。

下面是保守策略下,volatile寫插入內存屏障後生成的指令序列示意圖:

上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因爲StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內存。

這裏比較有意思的是volatile寫後面的StoreLoad屏障。這個屏障的作用是避免volatile寫與後面可能有的volatile讀/寫操作重排序。因爲編譯器常常無法準確判斷在一個volatile寫的後面,是否需要插入一個StoreLoad屏障(比如,一個volatile寫之後方法立即return)。爲了保證能正確實現volatile的內存語義,JMM在這裏採取了保守策略:在每個volatile寫的後面或在每個volatile讀的前面插入一個StoreLoad屏障。從整體執行效率的角度考慮,JMM選擇了在每個volatile寫的後面插入一個StoreLoad屏障。因爲volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫之後插入StoreLoad屏障將帶來可觀的執行效率的提升。從這裏我們可以看到JMM在實現上的一個特點:首先確保正確性,然後再去追求執行效率。

下面是在保守策略下,volatile讀插入內存屏障後生成的指令序列示意圖:

上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

上述volatile寫和volatile讀的內存屏障插入策略非常保守。在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。下面我們通過具體的示例代碼來說明:

class VolatileBarrierExample {int a;volatile int v1 = 1;volatile int v2 = 2;void readAndWrite() {int i = v1;           //第一個volatile讀int j = v2;           // 第二個volatile讀a = i + j;            //普通寫v1 = i + 1;          // 第一個volatile寫v2 = j * 2;          //第二個 volatile寫}…                    //其他方法}

針對readAndWrite()方法,編譯器在生成字節碼時可以做如下的優化:

注意,最後的StoreLoad屏障不能省略。因爲第二個volatile寫之後,方法立即return。此時編譯器可能無法準確斷定後面是否會有volatile讀或寫,爲了安全起見,編譯器常常會在這裏插入一個StoreLoad屏障。

上面的優化是針對任意處理器平臺,由於不同的處理器有不同“鬆緊度”的處理器內存模型,內存屏障的插入還可以根據具體的處理器內存模型繼續優化。以x86處理器爲例,上圖中除最後的StoreLoad屏障外,其它的屏障都會被省略。

前面保守策略下的volatile讀和寫,在 x86處理器平臺可以優化成:

前文提到過,x86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀,讀-寫和寫-寫操作做重排序,因此在x86處理器中會省略掉這三種操作類型對應的內存屏障。在x86中,JMM僅需在volatile寫後面插入一個StoreLoad屏障即可正確實現volatile寫-讀的內存語義。這意味着在x86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因爲執行StoreLoad屏障開銷會比較大)。

JSR-133爲什麼要增強volatile的內存語義

在JSR-133之前的舊Java內存模型中,雖然不允許volatile變量之間重排序,但舊的Java內存模型允許volatile變量與普通變量之間重排序。在舊的內存模型中,VolatileExample示例程序可能被重排序成下列時序來執行:

在舊的內存模型中,當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4類似)。其結果就是:讀線程B執行4時,不一定能看到寫線程A在執行1時對共享變量的修改。

因此在舊的內存模型中 ,volatile的寫-讀沒有監視器的釋放-獲所具有的內存語義。爲了提供一種比監視器鎖更輕量級的線程之間通信的機制,JSR-133專家組決定增強volatile的內存語義:嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和監視器的釋放-獲取一樣,具有相同的內存語義。從編譯器重排序規則和處理器內存屏障插入策略來看,只要volatile變量與普通變量之間的重排序可能會破壞volatile的內存語意,這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。

由於volatile僅僅保證對單個volatile變量的讀/寫具有原子性,而監視器鎖的互斥執行的特性可以確保對整個臨界區代碼的執行具有原子性。在功能上,監視器鎖比volatile更強大;在可伸縮性和執行性能上,volatile更有優勢。如果讀者想在程序中用volatile代替監視器鎖,請一定謹慎。

 

CAS操作詳解

 

本文屬於作者原創,原文發表於InfoQ:http://www.infoq.com/cn/articles/atomic-operation

1    引言

原子(atom)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意爲”不可被中斷的一個或一系列操作” 。在多處理器上實現原子操作就變得有點複雜。本文讓我們一起來聊一聊在Inter處理器和Java裏是如何實現原子操作的。

 

2    術語定義

術語名稱英文解釋
緩存行Cache line緩存的最小操作單位
比較並交換Compare and SwapCAS操作需要輸入兩個數值,一箇舊值(期望操作前的值)和一個新值,在操作期間先比較下在舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。
CPU流水線CPU pipelineCPU流水線的工作方式就象工業生產上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然後將一條X86指令分成5~6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘週期完成一條指令,因此提高CPU的運算速度。
內存順序衝突Memory order violation內存順序衝突一般是由假共享引起,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個內存順序衝突時,CPU必須清空流水線。

3    處理器如何實現原子操作

32位IA-32處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。

3.1   處理器自動保證基本內存操作的原子性

首先處理器會自動保證基本的內存操作的原子性。處理器保證從系統內存當中讀取或者寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其他處理器不能訪問這個字節的內存地址。奔騰6和最新的處理器能自動保證單處理器對同一個緩存行裏進行16/32/64位的操作是原子的,但是複雜的內存操作處理器不能自動保證其原子性,比如跨總線寬度,跨多個緩存行,跨頁表的訪問。但是處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性。

3.2   使用總線鎖保證原子性

第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫(i++就是經典的讀改寫操作)操作,那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。如下圖
1

(例1)

原因是有可能多個處理器同時從各自的緩存中讀取變量i,分別進行加一操作,然後分別寫入系統內存當中。那麼想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內存地址的緩存。

處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享內存。

3.3 使用緩存鎖保證原子性

第二個機制是通過緩存鎖定保證原子性。在同一時刻我們只需保證對某個內存地址的操作是原子性即可,但總線鎖定把CPU和內存之間通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,最近的處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

頻繁使用的內存會緩存在處理器的L1,L2和L3高速緩存裏,那麼原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖,在奔騰6和最近的處理器中可以使用“緩存鎖定”的方式來實現複雜的原子性。所謂“緩存鎖定”就是如果緩存在處理器緩存行中內存區域在LOCK操作期間被鎖定,當它執行鎖操作回寫內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時會起緩存行無效,在例1中,當CPU1修改緩存行中的i時使用緩存鎖定,那麼CPU2就不能同時緩存了i的緩存行。

但是有兩種情況下處理器不會使用緩存鎖定。第一種情況是:當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行(cache line),則處理器會調用總線鎖定。第二種情況是:有些處理器不支持緩存鎖定。對於Inter486和奔騰處理器,就算鎖定的內存區域在處理器的緩存行中也會調用總線鎖定。

以上兩個機制我們可以通過Inter處理器提供了很多LOCK前綴的指令來實現。比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些操作數和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的內存區域就會加鎖,導致其他處理器不能同時訪問它。

4    JAVA如何實現原子操作

在java中可以通過循環CAS的方式來實現原子操作。

4.1 使用循環CAS實現原子操作

JVM中的CAS操作正是利用了上一節中提到的處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是循環進行CAS操作直到成功爲止,以下代碼實現了一個基於CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。

001       private AtomicInteger atomicI = newAtomicInteger(0);
002
003       private int i = 0;
004
005       public static void main(String[] args) {
006
007              final Counter cas = new Counter();
008
009              List<Thread> ts = new ArrayList<Thread>(600);
010
011              long start = System.currentTimeMillis();
012
013              for (int j = 0; j < 100; j++) {
014
015                     Thread t = new Thread(new Runnable() {
016
017                            @Override
018
019                            public void run() {
020
021                                   for (int i = 0; i < 10000; i++) {
022
023                                          cas.count();
024
025                                          cas.safeCount();
026
027                                   }
028
029                            }
030
031                     });
032
033                     ts.add(t);
034
035              }
036
037              for (Thread t : ts) {
038
039                     t.start();
040
041              }
042
043       // 等待所有線程執行完成
044
045              for (Thread t : ts) {
046
047                     try {
048
049                            t.join();
050
051                     catch (InterruptedException e) {
052
053                            e.printStackTrace();
054
055                     }
056
057              }
058
059              System.out.println(cas.i);
060
061              System.out.println(cas.atomicI.get());
062
063              System.out.println(System.currentTimeMillis() - start);
064
065       }
066
067       /**
068
069        * 使用CAS實現線程安全計數器
070
071        */
072
073       private void safeCount() {
074
075              for (;;) {
076
077                     int i = atomicI.get();
078
079                     boolean suc = atomicI.compareAndSet(i, ++i);
080
081                     if (suc) {
082
083                            break;
084
085                     }
086
087              }
088
089       }
090
091       /**
092
093        * 非線程安全計數器
094
095        */
096
097       private void count() {
098
099              i++;
100
101       }
102
103}

從Java1.5開始JDK的併發包裏提供了一些類來支持原子操作,如AtomicBoolean(用原子方式更新的 boolean 值),AtomicInteger(用原子方式更新的 int 值),AtomicLong(用原子方式更新的 long 值),這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。

在Java併發包中有一些併發框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大和只能保證一個共享變量的原子操作。

  1. ABA問題。因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

1public boolean compareAndSet(
2               V      expectedReference,//預期引用
3
4               V      newReference,//更新後的引用
5
6              int    expectedStamp, //預期標誌
7
8              int    newStamp //更新後的標誌
9)
  1. 循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支持處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

  2. 只能保證一個共享變量的原子操作。當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行CAS操作。

4.2 使用鎖機制實現原子操作

鎖機制保證了只有獲得鎖的線程能夠操作鎖定的內存區域。JVM內部實現了很多種鎖機制,有偏向鎖,輕量級鎖和互斥鎖,有意思的是除了偏向鎖,JVM實現鎖的方式都用到的循環CAS,當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。詳細說明可以參見文章Java SE1.6中的Synchronized

5      參考資料

  1. Java SE1.6中的Synchronized

  2. Intel 64和IA-32架構軟件開發人員手冊

  3. 深入分析Volatile的實現原理





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