Java並發:樂觀鎖
作者:湯圓
個人部落格:javalover.cc
簡介
悲觀鎖和樂觀鎖都屬於比較抽象的概念;
我們可以用擬人的手法來想像一下:
- 悲觀鎖:像有些人,凡事都往壞的想,做最壞的打算;在java中就表現為,總是認為其他執行緒會去修改共享數據,所以每次操作共享數據時,都要加鎖(比如我們前面介紹過的內置鎖和顯式鎖)
- 樂觀鎖:像樂天派,凡事都往好的想,做最好的打算;在Java中就表現為,總是認為其他執行緒都不會去修改共享數據,所以每次操作共享數據時,都不加鎖,而是通過判斷當前狀態和上一次的狀態,來進行下一步的操作;(比如這節要介紹的無鎖,其中最常見的實現就是CAS演算法)
目錄
- 樂觀鎖的簡單實現:CAS
- 樂觀鎖的優點&缺點
- 樂觀鎖的適用場景
正文
1. 樂觀鎖的簡單實現:CAS
CAS的實現原理是比較並交換,簡單點來說就是,更新數據之前,會先檢查數據是否有被修改過:
- 如果沒有修改,則直接更新;
- 如果有被修改過,則重試;
下面我們通過一個程式碼來看下CAS的應用,這裡舉的例子是原子類AtomicInteger
public class AtomicDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(1);
ExecutorService service = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
service.submit(()->{
// 這裡會先檢查AtomicInteger中的值是否被修改,如果沒被修改,才會更新,否則會自旋等待
atomicInteger.getAndIncrement();
});
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicInteger.get());
}
}
可以看到,輸出的永遠都是101,說明結果符合預期;
這裡我們看下getAndIncrement的源碼,如下所示:
// AtomicInteger.java
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// UnSafe.java
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
// 這裡就是上面的CAS演算法核心
do {
// 1. 先取出期望值 var5(var1為值所在的對象,var2為欄位在對象中的位移量)
var5 = this.getIntVolatile(var1, var2);
// 2. 然後賦值時,獲取當前值,跟剛才取出的期望值 var5作比較
// 2.1 如果比較後發現值被修改了,則循環do while,直到當前值符合預期,才會進行更新操作(默認10次,超過10次還不符合預期,就會掛起執行緒,不再浪費CPU資源)
// 2.2 如果比較後發現值沒被修改,則直接更新
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
// 3. 返回舊值,即期望值
return var5;
}
這裡假設我們不是用的原子變數,而是普通的int來執行自增,那麼就有可能出現結果<預期的情況(因為自增不是原子操作),比如下面的程式碼
// 不要用這種方式來修改int值,不安全
public class AtomicDemo {
static int m = 1;
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
final int j = i;
service.submit(()->{
m++;
});
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(m);
}
}
多運行幾次,你會發現結果可能會小於預期,所以這就是原子類的好處:不用加鎖就可以實現自增等原子操作
2. 樂觀鎖的優點&缺點
它的優點很多,比如:
- 沒有鎖競爭,也就不會產生死鎖問題
- 不需要來回切換執行緒,降低了開銷(悲觀鎖需掛起和恢復執行緒,如果任務執行時間又很短,那麼這個操作就會很頻繁)
優點看起來還可以,那它有沒有缺點呢?也是有的:
- ABA問題:比如執行緒1將共享數據A改為B,然後過一會又改為A,那麼此時執行緒2訪問數據時,會認為該數據沒被修改過(當前值符合預期值),這樣我們就無法得知數據中間是否真的被修改過,以及修改的次數
- 開銷問題:如果自旋一直不符合預期值,那麼就會一直自旋,從而導致開銷很大(JDK6之前)
- 原子操作的局限性問題:雖然CAS可以保證原子操作,但是只是針對單個數據而言的;如果有多個數據需要同
步,CAS還是無能為力
下面我們就針對這幾個缺點來提出對於的解決方案
ABA問題
出現ABA問題,主要是因為我們沒有對修改過程進行記錄(就好比程式中的日誌記錄功能)
那麼我們可以通過版本號的方式來記錄每次修改,比如每修改一次,給對象的版本號屬性加1
不過現在有了AtomicStampedReference
這個類,它幫我們封裝了所需的狀態值,拿來即用,如下所示:
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
// 這裡的stamp就是狀態值,每次CAS都會同時比較當前值T和狀態值stamp
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
// 下面就是同時比較當前值和狀態值
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)));
}
}
開銷問題
利用CAS進行自旋操作時,如果發現當前值一直都不等於期望值,就會一直循環(JDK6之前)
所以這裡就引出了一個適應性自旋鎖的概念:當嘗試過N次後,發現還是不成功,則退出循環,掛起執行緒(JDK6之後,有了適應性自旋鎖)
這裡的N是不固定的,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在運行中,那麼虛擬機就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費處理器資源
—- 參考自《不可不說的Java「鎖」事》
大致意思就是,如果一個執行緒之前自旋成功過,獲取過鎖,那麼後面就會讓這個執行緒多自旋一會,比如20次(信用高)
但是如果如果一個執行緒之前自旋沒成功過或者很少成功,那麼後面就會讓這個執行緒少自旋一會,比如5次(信用低)
這裡需要糾正一個觀點:自旋鎖的次數設置問題,從JDK6開始,-XX:PreBlockSpin這個VM參數已經沒有意義了,在JDK7中已經被移除了;JDK6版本之後,默認都是用適應性自旋鎖來動態設置自旋的次數
如下圖所示:
在IDEA中添加-XX:PreBlockSpin=1
參數,運行會報錯如下:
原子操作的局限性問題
CAS的原子操作只是針對單個共享變數而言的(就像前面介紹的同步容器一樣,雖然每個方法都有鎖,但是複合操作卻無法保證原子性)
不過AtomicReference
這個類會有所幫助,它內部有一個V屬性,我們可以將多個共享變數封裝到這個V屬性中,然後再對V進行CAS操作
源碼如下:
public class AtomicReference<V> implements java.io.Serializable {
private static final long serialVersionUID = -1848883965231344442L;
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicReference.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 這裡的V我們可以自己定義一個類,然後將多個共享變數都封裝進去
private volatile V value;
}
3. 樂觀鎖的適用場景
分析樂觀鎖的適用場景之前,我們可以先看下悲觀鎖的適用場景
悲觀鎖是一來就上鎖,所以比較適合寫多讀少的場景,因為上了鎖,可以保證數據的一致性
那麼樂觀鎖對應的,就是從來都不上鎖,所以比較適合讀多寫少的場景,因為讀不會修改數據,所以CAS時成功的概率很大,也就不會有額外的開銷
總結
- 樂觀鎖的簡單實現:CAS,比較並交換
- 樂觀鎖的優點&缺點:
優點 | 缺點 |
---|---|
沒有鎖競爭,也就不會產生死鎖問題 | ABA問題(加狀態值解決) |
不需要來回切換執行緒,降低了開銷 | 自旋時間過長導致的開銷問題(舊版本JDK6之前才有的問題,JDK6之後默認用適應性自旋來動態設置自旋次數) |
多個共享變數不能保證原子操作(用AtomicReference封裝多個共享變數) |
- 樂觀鎖的適用場景:讀多寫少
參考
- 《實戰Java高並發》
- 不得不說的Java瑣事
- 自旋次數的設置問題:-XX:PreBlockSpin