JDK 原始碼解析 —— AtomicInteger
阿新 • • 發佈:2019-01-01
零. 前言
JDK 裡面提供的以 Atomic* 開頭的類基本原理都是一致的, 都是藉助了底層硬體級別的 Lock 來實現原子操作的。 本文以 AtomicInteger 為例進行講述, 其他的類似。閱讀本文前建議先閱讀基礎篇:Java
記憶體模型
一. 處理器原子操作: 3種加鎖方式
2. 使用匯流排鎖保證原子性(開銷大)
3. 用快取鎖保證原子性
頻繁使用的記憶體會快取在處理器的 L1,L2 和 L3 快取記憶體裡, 那麼原子操作就可以直接在處理器內部快取中進行, 並不需要宣告匯流排鎖, 在奔騰 6 和最近的處理器中可以使用“快取鎖定”的方式來實現複雜的原子性。所謂“快取鎖定”就是如果快取在處理器快取行中記憶體區域在 LOCK 操作期間被鎖定,當它執行鎖操作回寫記憶體時,處理器不在總線上聲言 LOCK# 訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時會起快取行無效,在上圖中,當
CPU1 修改快取行中的 i 時使用快取鎖定,那麼 CPU2 就不能同時修改快取了 i 的快取行, 在 CPU1 更新後, CPU2 去主存拿最新值, 從而保證了資料的一致性。
但是有兩種情況下處理器不會使用快取鎖定。第一種情況是:當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行(cache line),則處理器會呼叫匯流排鎖定。第二種情況是:有些處理器不支援快取鎖定。對於 Inter486 和奔騰處理器,就算鎖定的記憶體區域在處理器的快取行中也會呼叫匯流排鎖定。
以上兩個機制我們可以通過 Inter 處理器提供了很多 LOCK 字首的指令來實現。比如位測試和修改指令BTS,BTR,BTC,交換指令 XADD,CMPXCHG 和其他一些運算元和邏輯指令,比如 ADD(加),OR(或)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。
二. AtomicInteger 原始碼如何實現原子性 類宣告:
關於 CPU 的鎖有如下 3 種:
1. 處理器自動保證基本記憶體操作的原子性 首先處理器會自動保證基本的記憶體操作的原子性。 處理器保證從系統記憶體當中讀取或者寫入一個位元組是原子的, 意思是當一個處理器讀取一個位元組時, 其他處理器不能訪問這個位元組的記憶體地址。 奔騰 6 和最新的處理器能自動保證單處理器對同一個快取行裡進行 16/32/64 位的操作是原子的, 但是複雜的記憶體操作處理器不能自動保證其原子性, 比如跨匯流排寬度, 跨多個快取行, 跨頁表的訪問。 但是處理器提供匯流排鎖定和快取鎖定兩個機制來保證複雜記憶體操作的原子性。如果多個處理器同時對共享變數進行讀改寫(i++ 就是經典的讀改寫操作)操作, 那麼共享變數就會被多個處理器同時進行操作, 這樣讀改寫操作就不是原子的, 操作完之後共享變數的值會和期望的不一致, 舉個例子:如果 i=1,我們進行兩次 i++ 操作,我們期望的結果是 3,但是有可能結果是 2 。如下圖
原因是有可能多個處理器同時從各自的快取中讀取變數i, 分別進行加一操作, 然後分別寫入系統記憶體當中。 那麼想要保證讀改寫共享變數的操作是原子的, 就必須保證 CPU1 讀改寫共享變數的時候,CPU2 不能操作快取了該共享變數記憶體地址的快取。
處理器使用匯流排鎖就是來解決這個問題的。 所謂匯流排鎖就是使用處理器提供的一個 LOCK# 訊號,當一個處理器在總線上輸出此訊號時, 其他處理器的請求將被阻塞住, 那麼該處理器可以獨佔使用共享記憶體。二. AtomicInteger 原始碼如何實現原子性 類宣告:
public class AtomicInteger extends Number implements java.io.Serializable
繼承了 Number, 這主要是提供方法將數值轉化為 byte, double 等方便 Java 開發者使用;
實現了 Serializable, 為了網路傳輸等的序列化用, 編碼時最好手動生成序列化 ID, 讓 javac 編譯器生成開銷大, 而且可能造成意想不到的狀況。
變數宣告:
private volatile int value;
原始型別變數宣告為 pirvate 的, 這樣不會發生外部修改問題(逃逸), 如果是引用的話, 再把引用用 public 方法暴露出去那麼還是會造成逃逸現象, 不過這裡是原始型別, 不會出現這種情況;
volatile 關鍵字修飾, 使 value 變數的改變具有可見性, 底層實現是記憶體柵欄,保證每次取到的是最新值。
get 方法:
public final int get() {
return value;
}
final 型別方法, 不可繼承, 進一步保證執行緒安全。
自減操作:
/**
* Atomically decrements by one the current value.
*
* @return the previous value
*/
public final int getAndDecrement() {
for (;;) {
int current = get();
int next = current - 1;
if (compareAndSet(current, next))
return current;
}
}
可以看到 for 是一個死迴圈, 是採用忙等(也叫自旋)的方式不斷地嘗試(樂觀鎖)-1 操作, 直到成功才退出。
這裡的核心是呼叫了 compareAndSet() 方法, 傳入當前值和新值。
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
利用 JNI 呼叫底層其他語言實現的方法, 利用作業系統提供的 CAS(只要當前值和原來不一致就重新取值直到成功) 來保證原子性。
三. CAS 的缺點
基本上 Java 的 concurrent 包都是建立在 CAS 的基礎上的, 甚至還包括業界一個很出名的應用於高頻交易的框架 Disruptor 也是利用 CAS 來保證原子性。 但是 CAS 還是有它的缺點:
1. ABA問題。因為 CAS 需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。
從Java1.5開始JDK的atomic包裡提供了一個類 AtomicStampedReference 來解決 ABA 問題。這個類的compareAndSet 方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。
2. 迴圈時間長開銷大。自旋 CAS 如果長時間不成功,會給 CPU 帶來非常大的執行開銷。如果JVM能支援處理器提供的 pause 指令那麼效率會有一定的提升,pause 指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使 CPU 不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation:記憶體順序衝突一般是由偽/假共享引起,假共享是指多個
CPU 同時修改同一個快取行的不同部分而引起其中一個CPU的操作無效,當出現這個記憶體順序衝突時,CPU必須清空流水線)而引起 CPU 流水線被清空(CPU pipeline flush),從而提高 CPU 的執行效率。
3. 只能保證一個共享變數的原子操作。當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行CAS操作。
四. 參考資料
《Java 併發程式設計實戰》
JDK1.7 原始碼