在日常開發中,難免會使用到併發,如對一個計數器自增。考慮如下場景:
public class DoAndRecord {
private int cot = 0;
public void doFunc () {
// Do something...
++cot;
}
}
如果是併發的場景,很容易造成兩個執行緒操作結束後,最後值只自增了1,出現了執行緒安全問題
。因此,在實際開發中,對於這樣的簡單的操作,我們可能會用到如下的類,如AtomicInteger
。
public class DoAndRecord {
private AtomicInteger cot = new AtomicInteger(0);
public void doFunc () {
// Do something...
cot.getAndIncrement();
}
}
這是一種無鎖的程式設計方式,這個類的底層就透過Unsafe類用到了CAS,即Compare And Swap
。實現的原理其實很簡單:如需要自增一個變數v,在更新前有一箇舊值e,期望更新成n。這個過程會經歷讀和寫,在寫之前,驗證現在變數的值還是不是e,如果還是e則更新為n,不是則失敗。
這裡需要注意的是,無鎖並不代表真的無鎖,只是軟體層面沒有加任何的鎖。只是,我們在程式設計的過程中沒有使用到鎖,但是考慮紅色部分的描述,可能會考慮一個問題,用一個變數保護另一個變數,誰來保護保護變數的變數呢?在這裡,比較與寫之間,會不會有其他執行緒在這個空擋更新這個值?答案是不會,硬體工程師將CAS設計為一條原子的指令,只是,實現這種方式必須保證讀和寫之間是原子的,鎖被加在了底層硬體的位置,至於如何加鎖,鎖住匯流排或是緩衝行,則依賴於具體CPU的實現,不同的處理器體系結構可能不同,軟體工程師不需要考慮這一點。
誠然程式設計變得簡單,在上面的demo中,自增操作已經無需使用synchronized去鎖住臨界區的程式碼,但是簡單的雖然是優雅的,不過未必是完美的,CAS依然會帶來問題。
ABA問題
場景
兩個執行緒都希望將100變為50(或者考慮為兩個使用者有共同的願望),那麼考慮正常結束的情況下,應該有一個執行緒是修改失敗結束:當一個執行緒修改成功了以後,已經是50了,而不是100,不應該修改。這是正常的情況,但是過程中,第三個執行緒僅希望將這個值加上50(而不追究原來是多少)。這個時候,有了第三個執行緒的參與,最終的值應該是100才對。但是,應該失敗的執行緒阻塞住了,偏等一個“將100變成50”的執行緒,和“把值加50”的執行緒全部執行完畢後,才繼續執行,於是,讀到100,變成50,三個執行緒都成功退出了。
這顯然與預期結果是不符的,我們期望有一個執行緒失敗,最終結果為100,但是實際情況三個執行緒都成功結束,值卻是50。如果涉及到對併發場景的資料一致性要求非常高的情況,這種資料的丟失會引發嚴重的線上問題!這個就是老生常談的ABA問題,透過場景來理解這個問題帶來的代價,則是我作這篇部落格的重點。
解決的方式
依然是上面這個場景,造成這個問題的原因,是那個本應失敗的執行緒根本不知道100與100還有區別,它比較的時候所看見的100,與一開始的100根本就不是同一個100,所以,解決的方式就是為100打上版本號,讓這個執行緒可以區分兩個100,問題就可以解決了。可以參考AtomicStampedReference
的實現,原理與上述相同。
自旋等待問題
這個要深入到原始碼中去發現問題,我們進入到Java的JUC包的基石——Unsafe類中去檢視:
與普通的CAS不同,CAS如果在比較的階段發現讀到的值與預期不同,指令就會執行失敗,這裡所做的事是:如果失敗了,就把新的預期值拿出來,再去比較,直到成功。可以預見,當併發量特別高的時候,這個方法會經常失敗,代價是空轉CPU。試想在高併發的情況下還如此浪費資源,情況是非常糟糕。解決方法可以有減少自旋的次數,如失敗次數達到一定的閾值就放棄或阻塞,待喚醒之後再繼續,提高成功率。CAS的目的是減少執行緒阻塞、喚醒的過程以加快執行速度,當情況非常糟糕,樂觀鎖的效率並非很高的時候,可以考慮將二者達到一個平衡。
AtomicReference
這個是JUC包提供的另一個類,CAS能做到的只是更新一個值,但是如果是一個結構(例項),可能會需要同時更新多個值。這裡的實現方法也是CAS,只不過更新的是地址,使用的Unsafe
類中的compareAndSwapObject
方法。這是一個native方法,底層C++原始碼實現的時候,將原來的值的地址更新成新的值的地址,以實現更新多個值的目的。
class N {
Integer a;
Integer b;
Double d;
public N (Integer a, Integer b, Double d) {
this.a = a;
this.b = b;
this.d = d;
}
public N (Integer a, Double d) {
this.a = a;
this.d = d;
}
@Override
public String toString () {
return "N{" +
"a=" + a +
", b=" + b +
", d=" + d +
'}';
}
}
然後使用AtomicReference更新:
AtomicReference<N> reference = new AtomicReference<>(new N(1, 2, 2.5));
System.out.println(reference.get());
reference.getAndSet(new N(1, 1.5));
System.out.println(reference.get());
/*
N{a=1, b=2, d=2.5}
N{a=1, b=null, d=1.5}
*/
可以看見,更新時是整體的替換,可以印證剛剛的說法,透過將原來的引用指向新的地址以完成透過一次CAS更新全部的屬性的方法。具體可以參考openjdk的C++實現compareAndSwapObject
的程式碼,博主目前無暇去搜尋具體的程式碼,下面的程式碼摘抄自其他博主——原始碼解析 Java 的 compareAndSwapObject 到底比較的是什麼?。
// Unsafe.h
virtual jboolean compareAndSwapObject(::java::lang::Object *, jlong, ::java::lang::Object *, ::java::lang::Object *);
// natUnsafe.cc
static inline bool compareAndSwap (volatile jobject *addr, jobject old, jobject new_val)
{
jboolean result = false;
spinlock lock;
// 如果欄位的地址與期望的地址相等則將欄位的地址更新
if ((result = (*addr == old)))
*addr = new_val;
return result;
}
// natUnsafe.cc
jboolean sun::misc::Unsafe::compareAndSwapObject (jobject obj, jlong offset,jobject expect, jobject update) {
// 獲取欄位地址並轉換為字串
jobject *addr = (jobject*)((char *) obj + offset);
// 呼叫 compareAndSwap 方法進行比較
return compareAndSwap (addr, expect, update);
}