在 Java 中,我們可以使用 synchronized
關鍵字和 CAS
來實現加鎖效果。
悲觀鎖:
-
對於悲觀鎖來說,它總是認為每次訪問共享資源時會發生衝突,所以必須對每次資料操作加上鎖,以保證臨界區的程式同一時間只能有一個執行緒在執行。
-
synchronized
是悲觀鎖,儘管隨著 JDK 版本的升級,synchronized 關鍵字已經“輕量級”了很多,但依然是悲觀鎖,執行緒開始執行第一步就要獲取鎖,一旦獲得鎖,其他的執行緒進入後就會阻塞並等待鎖。 -
悲觀鎖多用於
寫多讀少
的環境,避免頻繁失敗和重試影響效能。
樂觀鎖:
- 樂觀鎖總是假設對共享資源的訪問沒有衝突,執行緒可以不停地執行,無需加鎖也無需等待。一旦多個執行緒發生衝突,樂觀鎖通常使用一種稱為 CAS 的技術來保證執行緒執行的安全性。
CAS
是樂觀鎖,執行緒執行的時候不會加鎖,它會假設此時沒有衝突,然後完成某項操作;如果因為衝突失敗了就重試,直到成功為止。- 由於樂觀鎖假想操作中沒有鎖的存在,因此不太可能出現死鎖的情況,換句話說,樂觀鎖天生免疫死鎖。
- 樂觀鎖多用於
讀多寫少
的環境,避免頻繁加鎖影響效能。
什麼是 CAS
在 CAS 中,有這樣三個值:
- V:要更新的變數(var)
- E:預期值(expected),本質上指的是“舊值”
- N:新值(new)
比較並交換的過程如下:
判斷 V 是否等於 E,如果等於,將 V 的值設定為 N;如果不等,說明已經有其它執行緒更新了 V,於是當前執行緒放棄更新,什麼都不做。
CAS 是一種原子操作,它是一種系統原語,是一條 CPU 的原子指令,從 CPU 層面已經保證它的原子性。
當多個執行緒同時使用 CAS 操作一個變數時,只有一個會勝出,併成功更新,其餘均會失敗,但失敗的執行緒並不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的執行緒放棄操作。
CAS 的原理
在 Java 中,有一個Unsafe
類,它在sun.misc
包中。它裡面都是一些native
方法,其中就有幾個是關於 CAS 的:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
Unsafe 對 CAS 的實現是透過 C++ 實現的,它的具體實現和作業系統、CPU 都有關係。
Linux 的 X86 下主要是透過cmpxchgl
這個指令在 CPU 上完成 CAS 操作的,但在多處理器情況下,必須使用lock
指令加鎖來完成。當然,不同的作業系統和處理器在實現方式上肯定會有所不同。
CMPXCHG是“Compare and Exchange”的縮寫,它是一種原子指令,用於在多核/多執行緒環境中安全地修改共享資料。CMPXCHG在很多現代微處理器體系結構中都有,例如Intel x86/x64體系。對於32位運算元,這個指令通常寫作CMPXCHG,而在64位運算元中,它被稱為CMPXCHG8B或CMPXCHG16B。
除了上面提到的方法,Unsafe 裡面還有其它的方法。比如支援執行緒掛起和恢復的park
和unpark
方法,LockSupport底層就呼叫了這兩個方法。還有支援反射操作的allocateInstance()
方法。
CAS 如何實現原子操作
JDK 提供了一些用於原子操作的類,在java.util.concurrent.atomic
包下面。
以AtomicInteger
類的getAndAdd(int delta)
方法為例:
private static final Unsafe unsafe = Unsafe.getUnsafe();
public final int getAndAdd(int delta) {
// 呼叫的 Unsafe 類的方法
return unsafe.getAndAddInt(this, valueOffset, delta);
}
// var1:想要進行操作的物件。
// var2:要操作的 var1 物件中的某個欄位的偏移量。這個偏移量可以透過 Unsafe 類的 objectFieldOffset 方法獲得。
// var4:要增加的值。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 獲取當前物件指定欄位的值
// getIntVolatile 方法能保證讀操作的可見性,即讀取的結果是最新的寫入結果,不會因為 JVM 的最佳化策略(如指令重排序)或者 CPU 的快取導致讀取到過期的資料
var5 = this.getIntVolatile(var1, var2);
// 如果物件 var1 在記憶體地址 var2 處的值等於預期值 var5,則將該位置的值更新為 var5 + var4,並返回 true;否則,不做任何操作並返回 false。
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
- Object var1:想要進行操作的物件。
- long var2:要操作的 var1 物件中的某個欄位的偏移量。這個偏移量可以透過 Unsafe 類的 objectFieldOffset 方法獲得。
- int var4:要增加的值。
JDK 9 及其以後版本中,getAndAddInt 方法和 JDK 8 中的實現有所不同,我們就拿 JDK 11 的原始碼來做一個對比吧:
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
這個方法上面增加了 @HotSpotIntrinsicCandidate
註解。這個註解允許 HotSpot VM 自己來寫彙編或 IR 編譯器來實現該方法以提供更加的效能。
IR(Intermediate Representation)是一種用於幫助最佳化編譯器的中間程式碼表示方法。編譯器通常將原始碼首先轉化為 IR,然後對 IR 進行各種最佳化,最後將最佳化後的 IR 轉化為目的碼。在 JVM(Java Virtual Machine)中,JIT(Just-In-Time)編譯器將 Java 位元組碼(即.class 檔案的內容)轉化為 IR,然後對 IR 進行最佳化,最後將 IR 編譯為機器碼。這個過程在 Java 程式執行時進行,因此被稱為“即時編譯”。JVM 中的 C1 和 C2 編譯器就是 IR 編譯器。C1 編譯器在編譯時進行一些簡單的最佳化,然後快速地將 IR 編譯為機器碼。C2 編譯器在編譯時進行更深入的最佳化,以獲得更高的執行效率,但編譯的時間也相對更長。
也就是說,雖然表面上看到的是 weakCompareAndSet 和 compareAndSet,但是不排除 HotSpot VM 會手動來實現 weakCompareAndSet 真正功能的可能性。
簡單來說,weakCompareAndSet
操作僅保留了volatile
自身變數的特性,而除去了 happens-before 規則帶來的記憶體語義。換句話說,weakCompareAndSet
無法保證處理操作目標的 volatile 變數外的其他變數的執行順序(編譯器和處理器為了最佳化程式效能而對指令序列進行重新排序),同時也無法保證這些變數的可見性。 但這在一定程度上可以提高效能。
CAS 的三大問題
ABA 問題
所謂的 ABA 問題,就是一個值原來是 A,變成了 B,又變回了 A。這個時候使用 CAS 是檢查不出變化的,但實際上卻被更新了兩次。
ABA 問題的解決思路是在變數前面追加上版本號或者時間戳。從 JDK 1.5 開始,JDK 的 atomic 包裡提供了一個類AtomicStampedReference
類來解決 ABA 問題。
// 這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果二者都相等,才使用 CAS 設定為新的值和標誌。
public boolean compareAndSet(V expectedReference,// 預期引用,也就是認為原本應該在那個位置的引用
V newReference,// 新引用,如果預期引用正確,將被設定到該位置的新引用。
int expectedStamp,// 預期標記
int newStamp) {// 新標記
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
執行流程:
①、Pair<V> current = pair;
這行程式碼獲取當前的 pair 物件,其中包含了引用和標記。
②、接下來的 return 語句做了幾個檢查:
expectedReference == current.reference && expectedStamp == current.stamp
:首先檢查當前的引用和標記是否和預期的引用和標記相同。如果二者中有任何一個不同,這個方法就會返回 false。- 如果上述檢查透過,也就是說當前的引用和標記與預期的相同,那麼接下來就會檢查新的引用和標記是否也與當前的相同。如果相同,那麼實際上沒有必要做任何改變,這個方法就會返回 true。
- 如果新的引用或者標記與當前的不同,那麼就會呼叫 casPair 方法來嘗試更新 pair 物件。casPair 方法會嘗試用 newReference 和 newStamp 建立的新的 Pair 物件替換當前的 pair 物件。如果替換成功,casPair 方法會返回 true;如果替換失敗(也就是說在嘗試替換的過程中,pair 物件已經被其他執行緒改變了),casPair 方法會返回 false。
長時間自旋
CAS 多與自旋結合。如果自旋 CAS 長時間不成功,會佔用大量的 CPU 資源。
解決思路是讓 JVM 支援處理器提供的 pause
指令。
pause 指令能讓自旋失敗時 cpu 睡眠一小段時間再繼續自旋,從而使得讀操作的頻率降低很多,為解決記憶體順序衝突而導致的 CPU 流水線重排的代價也會小很多。
多個共享變數的原子操作
當對一個共享變數執行操作時,CAS 能夠保證該變數的原子性。但是對於多個共享變數,CAS 就無法保證操作的原子性,這時通常有兩種做法:
- 使用
AtomicReference
類保證物件之間的原子性,把多個變數放到一個物件裡面進行 CAS 操作; - 使用鎖。鎖內的臨界區程式碼可以保證只有當前執行緒能操作。