讀書筆記: Java併發編程的藝術 併發編程的底層實現原理

引言

學習書籍: 《Java併發編程的藝術》
Java代碼在編譯後會變成Java字節碼,字節碼被類加載器加載到JVM內,JVM執行字節碼,最終需要轉換爲彙編指令在CPU上執行,Java中所使用的併發機制依賴於JVM的實現和CPU的指令。因此,接下來讓我們看看Java併發機制的底層到底是如何實現的。

一. volatile的應用

1.1 volatile的定義與實現原理

可見性: 當一個線程修改一個共享變量時,另外一個線程能夠讀取到這個修改的值。
volatile是一個輕量級的synchronized,它在多處理器開發中保證了共享變量的"可見性"。如果volatile使用恰當,它比synchronized的使用和執行成本更低,因爲它不會引起線程上下文的切換和調度。

官方定義: Java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。
如果一個字段被聲明成volatile,那麼Java線程內存模型會確保所有線程看到這個變量的值是一致的。

下面是與volatile實現原理相關的CPU術語:

術語 英文單詞 術語描述
內存屏障                                          memory barriers                                                        一組處理器指令,用於實現對內存操作的順序限制
緩存行 cache line CPU高速緩衝區中可以分配的最小存儲單位。處理器填寫緩存行時會加載整個緩存行,(針對一次緩存操作)現代CPU需要執行幾百次CPU指令
原子操作 atomic operations 不可中斷的一個或一系列操作
高速緩存行 多個緩存行組成的固定大小的數據塊
緩存行填充 cache line fill 當CPU識別到從內存中讀取的操作數是可緩存的時候,CPU會讀取整個高速緩存行,並把數據存放到適當的緩存區域中(L1,L2,L3或者所有緩衝區) 不是所有的數據都可以被CPU緩存
緩存命中 cache hit 如果進行高速緩存行填充操作的內存位置仍然是下一次處理器訪問的地址時,處理器將從緩存中讀取操作數,而不是從內存中讀取。
寫命中 write hit 當處理器將操作數寫回到一個內存緩存的區域時,它會首先檢查這個緩存的內存地址是否在CPU緩存行中存在,如果存在對應的緩存行,則處理器將這個操作數回寫到CPU緩存行,而不會寫回到內存緩存中。這樣做的原因是考慮到很多時候cache存入的是中間結果,沒有必要同步更新memory。
寫缺失 write misses the cache 一個有效的緩存行被寫入到不存在的內存區域

CPU不僅會緩存數據,還會緩存數據存儲在內存中的地址,通過內存地址,CPU緩存與內存產生了對應關係。

被volatile變量修飾的共享變量進行寫操作時,生成的彙編代碼中會多出 Lock 指令,實際上,Lock前綴的指令在多核處理器下會引發兩件事情:

  1. 將當前處理器緩存行的數據寫回到系統內存。
  2. 這個寫回內存的操作會使在其它CPU裏緩存了該內存地址的數據無效(至於怎麼做到的,請看下面一段話)。

爲了提高處理速度,處理器不直接和內存進行通信,而是先將系統內存的數據讀到CPU的內部緩存(L1,L2或其它)後再進行操作,但操作完畢後並不知道何時會寫入到內存。如果對聲明瞭volatile的變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在的緩存行的數據寫回到系統內存。但是,就算寫回到內存,如果其它處理器緩存的值仍是舊值,再執行計算操作就會有問題。所以,在多處理器下,爲了保證各處理器的緩存是一致的,就會實現緩存一致性協議每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了。當處理器發現自己緩存行對應的內存地址發生了修改,就會將當前處理器緩存行設置爲無效。當處理器對這個數據進行修改操作時,會重新從系統內存中把數據讀到處理器緩存中。 疑惑: 如果兩個變量A和B同時存放在一個緩存行中,此時A發生了修改,難道要把整個緩存行都設置無效嗎?那B豈不是很無辜?答: 確實如此,所以需要避免A和B加載到同一個緩存行,實現的方式有許多種,比如通過填充的方式,強行讓A或B變量佔據整個緩存行

下面來具體講解volatile的兩條實現原則。

  1. Lock前綴指令會引起處理器緩存回寫到內存。Lock前綴指令會在指令在執行期間,使得CPU處理器產生LOCK#信號,理論上LOCK#信號會鎖住整條總線,CPU總線(又叫前端總線)是所有CPU與芯片組連接的主幹道,負責CPU與外界所有部件的通信,包括緩存、內存以及北橋,現在總線被鎖定,相當於切斷了CPU與內存的通信橋樑,因此其他的CPU都不能訪問系統內存了,顯然這個代價是極其昂貴的。
    對於Intel486和奔騰處理器,在鎖操作時,總是在總線上發出LOCK#信號。但在P6或近幾年創造的處理器中,LOCK#信號一般不鎖總線,而是鎖緩存了。如果訪問的內存地址和數據已經緩存在了CPU內部,則不會在總線上發出LOCK#信號,相反,處理器會直接鎖定對應的緩存行並把修改後的數據回寫到對應的內存,最後使用緩存一致性機制來確保修改的原子性,這一系列操作被稱爲"緩存鎖定"。緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據。
  2. 一個處理器的緩存數據回寫到內存會導致其他處理器的緩存數據無效。在多核處理器中進行操作時,處理器能嗅探其他處理器訪問系統內存或它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統內存以及其它處理器緩存中的數據在總線上保持一致。舉個例子,如果一個處理器嗅探到其它處理器打算寫入數據至內存地址,而這個地址處於共享狀態(代表着當前處理器可能緩存了這個地址),那麼當前處理器會使它自己對應該內存地址的緩存行無效,在下次訪問相同內存地址時,強制從系統內存中查詢數據,並填入(覆蓋或者刷新)緩存行。

1.2 volatile的使用優化

Doug lea在JDK7中新增了一個隊列集合類LinkedTransferQueue,它在使用volatile變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。
在這裏插入圖片描述
爲什麼追加字節能優化性能? 讓我們來看看LinkedTransferQueue這個類,它使用了一個內部類類型來定義隊列的頭節點(head)和尾節點(tail),而這個內部類PaddedAtomicReference相對於父類AtomicReference只做了一件事,就是將共享變量追加到64個字節。我們可以來計算一下,一個對象的引用佔4個字節,PaddedAtomicReference追加了15個變量(共佔60個字節),再加上父類的value變量(4個字節),一共是64個字節。

爲什麼追加64個字節能夠提高併發編程的效率呢?這是因爲對於英特爾酷睿i7、酷睿、Atom等處理器的L1,L2,L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味如果隊列的頭節點和尾節點都不足64個字節,處理器會將把它們讀取到同一個高速緩存行中,在多處理器下,每個處理器都會緩存同樣的頭節點和尾節點,當一個處理器試圖修改頭節點時,會將整個緩存行鎖定,那麼在緩存一致性機制的作用下,會導致其它處理器不能訪問自己高速緩存行中的尾節點,而隊列的入隊和出隊操作需要不斷的修改頭節點和尾節點,所以多處理器下將嚴重影響入隊和出隊的效率。LinkedTransferQueue使用追加到64個字節的方式來填滿高速緩衝區中的緩存行,避免頭節點和尾節點存儲在同一個緩存行中,使頭、尾節點在修改時不會相互鎖定。

那麼是不是在任何場景下,只要使用volatile變量都應該追加到64個字節呢? 不是的。在以下兩種場景下不應該使用這種方式:

  1. 緩存行非64字節寬的處理器。比如P6系列和奔騰處理器,它們的L1和L2緩存行是32個字節寬。
  2. 共享變量不會被頻繁地寫。因爲追加字節的方式需要處理器讀取更多的字節到高速緩衝區,這本身就會帶來一定的性能開銷。如果共享變量不被頻繁寫,那麼被鎖的概率也非常小,就沒必要通過追加字節的方式來避免相互鎖定了。

不過這種追加字節的方式在Java 7下可能不生效,因爲Java 7更加智能,它會淘汰或重新排列無用的字段,需要使用其它追加字節的方式。

二. synchronized的實現原理與應用

很多人都把synchronized稱呼爲"重量級鎖",但是,隨着Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不是那麼中了。下面介紹Java SE 1.6中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。

先來看看利用synchronized實現同步的基礎: Java中的每一個對象都可以作爲鎖。具體表現爲以下三種形式:

  1. 對於普通同步方法,鎖是當前實例對象。
  2. 對於靜態同步方法,鎖是當前類的Class對象。
  3. 對於同步方法塊,鎖是Synchronized括號內配置的對象。

後文中出現的對象頭指的都是這些用於同步的對象的對象頭。

當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。那麼鎖到底存放在哪裏呢?鎖裏面會存儲什麼信息呢?

從JVM規範中可以看到Synchronized在JVM裏面的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節不同。代碼塊同步是使用monitorenter和moniorexit指令實現的,而方法的同步的實現細節雖然沒有詳細說明,但同樣可以使用這兩個指令來實現。

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexist是插入到方法的結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexist與之匹配。任何對象都有一個monitor與之關聯,當monitor被持有後,對象將會處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

2.1 Java對象頭

synchronized用的所是存儲在Java對象頭裏的。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2個字寬存儲對象頭。在32位虛擬機中,1字寬=4字節,即32bit,如下表所示:

長度 內容 說明
32/64bit Mark Word 存儲對象的hashCode或鎖信息
32/64bit Class Metadata Address 存儲到對象類型數據的指針
32/64bit Array length 數組的長度(如果當前對象是數組)

Java對象頭裏的Mark Word中默認存儲對象的HashCode、分代年齡和鎖標記位。32位JVM的Mark Word的默認存儲結構如下:

鎖狀態 25bit 4bit 1bit是否是偏向鎖 2bit 鎖的標誌位
無鎖狀態 對象的hashCode 對象的分代年齡 0 01

在運行期間,Mark Word裏存儲的數據不是一成不變,它會隨着鎖標誌位的變化而變化。
在這裏插入圖片描述

2.2 鎖的升級與對比

Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了"偏向鎖"和"輕量級鎖",在Java SE 1.6 中,鎖一共有4種狀態,級別從低到高依次是: 無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,這意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率。

2.2.1 偏向鎖

Hotspot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需要簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖): 如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

  1. 偏向鎖的撤銷
    偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖(正常使用並退出同步塊,不會釋放偏向鎖)。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活着,則立刻遍歷該線程的操作棧,如果發現這個線程仍然需要持有偏向鎖,則偏向鎖升級成爲輕量級鎖;如果線程不再需要偏向鎖了,則將對象恢復成無鎖狀態,最後重新偏向。
    在這裏插入圖片描述
  2. 關閉偏向鎖
    偏向鎖在Java 6和 Java 7裏是默認啓用,但是它在應用程序啓動幾秒鐘之後才激活,如有必要,可以使用JVM參數來關閉延遲:

-XX:BiasedLockingStartupDelay=0

如果你確定應用程序中所有的鎖通常都處於競爭狀態,可以通過JVM參數關閉偏向鎖(畢竟競爭狀態下,偏向鎖會頻繁變更鎖的狀態):

-XX:-UseBiasedLocking=false

那麼程序默認會進入輕量級鎖狀態。

2.2.2 輕量級鎖

  1. 輕量級鎖加鎖
    線程在執行同步塊之前,JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向當前線程鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其它線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
  2. 輕量級鎖解鎖
    輕量級鎖解鎖時,會使用具有原子性的CAS操作將Displaced Mark Word替換回原來的對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭(存在其他線程曾嘗試獲得鎖,並且因獲取失敗導致進入阻塞狀態),鎖就會膨脹成重量級鎖,並且當前線程會釋放鎖,將對象頭中Mark Word恢復成無鎖狀態,最後恢復阻塞的線程。
    在這裏插入圖片描述

問: 線程2在嘗試通過CAS操作修改對象頭中的Mark Word失敗後,經過幾次自旋,纔會進入線程阻塞狀態呢?
答:輕量級鎖認爲競爭存在,但競爭的成都很低,一般兩個線程對於同一個鎖的操作都會錯開,或者說稍微等待一下(自旋),持有鎖的線程就會釋放鎖。
但是當自旋超過一定的次數,或者一個線程在持有鎖,一個線程在自旋,又有第三個線程競爭鎖時,輕量級鎖會膨脹成重量級鎖。

因爲自旋會消耗CPU,爲了避免無用的自旋(比如獲得鎖的線程被阻塞了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖的狀態了。當鎖處於重量級鎖的狀態下,其它線程試圖獲取鎖時,都會直接被阻塞住(不需要自旋嘗試獲得鎖)。直到有鎖的線程釋放鎖之後,喚醒這些阻塞的線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

  1. 鎖的優缺點對比
優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級別的差距 如果線程之間存在鎖競爭,會帶來額外的鎖撤銷的開銷 適用於只有一個線程訪問同步塊的場景
輕量級鎖 競爭的線程不會阻塞,能在其他線程釋放鎖的第一時間獲得鎖,提高了線程的響應速度 如果持有鎖的線程始終不釋放鎖,會導致當前線程陷入自旋,消耗CPU 追求響應時間 同步塊執行速度非常快
重量級鎖 線程競爭不使用自旋,不會消耗CPU 只要發現競爭,則線程進入阻塞,響應速度慢 競爭壓力大 併發線程數量多 同步塊執行時間長

對象頭中的Mark Work中的指針指向某一個線程內棧幀中的鎖記錄空間,這就是加鎖。

問題: 爲什麼說輕量級鎖能夠提高線程的響應速度,比重量級鎖下線程的響應速度快?
答: 因爲輕量級鎖下,線程處於競爭狀態時,進入的是自旋狀態(仍然處於運行狀態),當鎖被釋放後,線程能立刻獲得鎖並進入同步塊。而重量級鎖下,處於競爭狀態的線程會進入阻塞態,當持有鎖的線程釋放鎖後,處於阻塞態的線程只會被喚醒成就緒態。衆所周知,處於就緒態的線程想要被調用,還得看CPU和線程調度器的心情, 所以線程恢復正常運行的速度比輕量級鎖要慢得多。

三. 原子操作的實現原理

原子(atomic): 不能被進一步分割的最小粒子
原子操作(atomic operation): 不可被中斷的一個或一系列操作

3.1 術語定義

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

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

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

  1. 使用總線鎖保證原子性
    如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一樣。
    在這裏插入圖片描述
    比如上方圖片中,CPU1和CPU2都在進行i++操作,我們期望的結果是3,可是最終的結果可能爲2。原因可能是多個處理器同時從各自的緩存中讀取變量i,分別進行i加1操作,然後分別寫入內存系統中。CPU1修改i的值並寫入內存後,沒有及時通知CPU2,導致CPU2仍然使用過期的緩存進行運算,最後把i=2再次寫回內存。那麼,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1在操作變量i時,CPU2不能操作緩存了該共享變量對應的內存地址。
    處理器使用總線鎖就是用來解決這個問題的。所謂總線鎖,就是CPU提供的傳播在總線上的LOCK#信號。當一個處理器在總線上輸出這個信號時,其它處理器的請求將被阻塞住,那麼該處理器可以獨佔共享內存。(前面說過了,總線鎖定會導致CPU斷掉了與外部所有部件溝通的橋樑,當然也就包括了共享內存)
  2. 使用緩存鎖保證原子性
    在同一時刻,我們只需要保證對某個內存地址的操作是原子性的即可,但總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其它處理器不能和操作其它內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。
    頻繁使用的內存回緩存在處理器的L1、L2以及L3高速緩存區中,那麼原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖。所謂"緩存鎖定"是指內存區域如果被緩存在處理器的緩存行中,並且在Lock期間被鎖定,那麼當它執行鎖操作回寫到內存時,處理器不在總線上傳播LOCK#信號,而是根據緩存行中存儲的對應的內存地址,直接修改系統內存中的數據,並通過緩存一致性機制來保證操作的原子性,因爲緩存一致性機制回組織同時修改由兩個以上處理器緩存的內存區域數據,當其它處理器回寫已被鎖定的緩存行數據時,回使緩存行無效。比如上例中,當CPU1修改緩存行中的i時使用了緩存鎖定,那麼即便CPU2已經緩存了i,這個緩存行也會變得無效,後續 進行i+1時,強制性的從系統內存中獲取最新的數據進行運算。緩存鎖定不使用LOCK#信號,而依賴於緩存一致性機制來保證同一時刻不能有多個處理器同時修改同一個內存地址中的數據。

但是有兩種情況下,處理器不會使用緩存鎖定:

  1. 當操作的數據不能被緩存在處理器的內部,或者操作的數據跨多個緩存行(cache line)時,則處理器會調用總線鎖定。
  2. 有些處理器不支持緩存鎖定。對於Intel486和Pentium處理器,就算鎖定的內存區域在處理器的緩存中,也將會調用總線鎖定。

針對以上兩個機制,我們通過Intel處理器提供了很多Lock前綴的指令來實現。例如:

  1. 位測試和修改指令: BTS、BTR、BTC
  2. 交換指令 XADD、CMPXCHG
  3. 邏輯指令,如ADD,OR
    被這些指令操作的內存區域就會加鎖,導致其他處理器不能同時訪問它。

3.3 Java如何實現原子性操作

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

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

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

/**
  * 線程安全的計數器
  */
private AtomicInteger atomicCounter = new AtomicInteger(0);

/**
  * 非線程安全的計數器
  */
private Integer counter = 0;
private void safeCount() {
    for(;;) {
        int oldCount = atomicCounter.get();
        boolean suc = atomicCounter.compareAndSet(oldCount, ++oldCount);
        if(suc) {
            break;
        }
    }
}

private void count() {
    counter++;
}
public static void main(String[] args) {
    final Counter cas = new Counter();
    List<Thread> ts = new ArrayList<>(512);
    long start = System.currentTimeMillis();
    for (int j = 0; j<100; j++) {
        Thread t = new Thread(() -> {
            for (int i = 0; i<10000; i++) {
                cas.count();
                cas.safeCount();
            }
        });
        ts.add(t);
    }

    for (Thread t : ts) {
        t.start();
    }

    // 等待所有線程執行完畢
    for (Thread t : ts) {
        try {
            // 阻塞調用此方法的線程(主線程),直到目標線程執行完畢
            t.join();
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }
    System.out.println(cas.counter);
    System.out.println(cas.atomicCounter);
    System.out.println(System.currentTimeMillis() - start);
}

執行結果:

932170
1000000
149

3.3.2 CAS實現原子操作的三大問題

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

  1. ABA問題
    CAS需要在操作值的時候,檢查舊值是否發生變化。如果沒有發生變化,則更新。但是如果一個值原來是A,變成了B,最後又變成了A,那麼使用CAS進行檢查時會發生值沒有發生變化,但實際上卻發生了變化。ABA問題解決的思路就是使用版本號。在變量前面追加版本號,每次更新後把版本號加1,那麼A->B->A就變成了A1->B2->A3。從Java 1.5開始,JDK的Atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法會首先檢查當前的引用是否等於預期的引用,並且檢查當前的標誌是否等於預期的標誌,如果全部相等,則將該引用和標誌的值設置爲給定的更新值。
/**
  * 使用原子的方式設置引用和標記的值
  * 當前引用 == 預期引用 && 當前標誌 == 預期標誌  滿足條件才能更新
  *
  * @param expectedReference 預期引用
  * @param newReference 更新後的引用
  * @param expectedStamp 預期的標誌
  * @param newStamp 更新後的標誌
  * @return 是否執行成功
  */
public boolean compareAndSet(V   expectedReference,
                              V   newReference,
                              int expectedStamp,
                              int newStamp) {
}
  1. 循環時間長開銷大
    自旋CAS如果長時間不成功,會導致CPU空轉,帶來非常大的執行開銷。如果JVM能夠提供pause命令,那麼效率會有一定的提升。pause命令有兩個作用: 第一,它可以延遲指令流水線執行指令,使CPU不會消耗過多的執行資源。第二,它可以避免多個CPU同時操作同一個緩存行(只要發現有其它CPU在操作緩存行,會停止當前CPU的指令流水線操作),從而避免了前文提到的假共享問題,也就避免了退出循環時因內存順序衝突而引起CPU流水線被清空,從而提高了CPU的工作效率。

  2. 只能保證一個共享變量的原子性操作
    對於多個共享變量操作時,循環CAS就無法保證操作的原子性了,這時候可以通過加鎖、合併共享變量或者使用Java 1.5開始提供的AtomicReference類來保證引用對象的原子性,可以把多個變量放進一個對象V裏來進行CAS操作。

class ShareP{
    private Integer number;
    private String text;
    private LocalDateTime soldDate;
	... 省略構造函數和get/set方法
}
public class Main {
    public static void main(String[] args) {
        final ShareP shareP = new ShareP(5,"地球", LocalDateTime.now());
        AtomicReference<ShareP> atomic = new AtomicReference<>(shareP);

        ShareP shareP2 = atomic.get();
        shareP.setNumber(10);
        boolean suc = atomic.compareAndSet(shareP2, shareP);
        System.out.println("執行情況: " + suc); // true
        System.out.println(atomic.get().getNumber()); // 10
    }
}

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

鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域。JVM中實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。除了偏向鎖外,JVM實現鎖的方式都採用了循環CAS,即當一個線程進入同步塊時使用循環CAS的方式獲得鎖,退出同步塊時使用循環CAS的方式釋放鎖。

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