CAS 相關基礎知識
CAS的全稱是Compare And Swap ,即比較交換。CAS 中一般會設計到3個引數:
- 記憶體值 V
- 舊的預期值A
- 要修改的新值B
當且僅當預期值 A 和記憶體值 V 相同時,將記憶體值V修改為 B,否則什麼都不做。
這裡關於 CPU 指令對於 CAS 的支援不深入研究,有興趣的可以自行了解。
CAS 幾個問題
很多書籍和文章中都有提出它存在的幾個問題:
- 1、迴圈時間長開銷很大
- 2、只能保證一個共享變數的原子操作
- 3、ABA 問題
下面就這三個問題展開來聊一下。
1、關於“迴圈時間長開銷很大”的疑惑與驗證
自旋 CAS 如果長時間不成功,會給 CPU 帶來非常大的開銷。但是真的是這樣嗎?到底多大的併發量才造成 CAS 的自旋次數會增加呢?另外,對於當前的機器及JDK,在無鎖,無CAS 的情況下,是否對於結果的影響是真的那麼明顯呢?對於這個問題,下面做了一個簡單的測試,但是測試結果也只是針對在我本地環境下,各位看官可以拉一下程式碼,在自己電腦上 run 一下,把機器資訊、JDK版本以及測試結果留言到評論區。
本文案例可以這裡獲取:glmapper-blog-sample-cas
這裡我是用了一個很簡單的案例,就是整數自增。使用了兩種方式去測試的,一種是無鎖,也不用 CAS 操作,另外一種是基於 CAS 的方式。(關於加鎖的方式沒有驗證,有時間再補充吧~)
計數器類
計數器裡面有兩個方法,一種是CAS 自旋方式,一種是直接自增。程式碼如下:
public class Counter {
public AtomicInteger safeCount = new AtomicInteger(0);
public int unsafe = 0;
// 使用自旋的方式
public void safeCount(){
for (;;){
int i = safeCount.get();
boolean success = safeCount.compareAndSet(i,++i);
if (success){
break;
}
}
}
// 普通方式自增
public void unsafeCount(){
unsafe++;
}
}
複製程式碼
模擬併發
這裡我們模擬使用 1000 個執行緒,執行 30 次來看下結果,包括總耗時和結果的正確性。
- CAS 方式
public static int testSafe() throws InterruptedException {
// 記錄開始時間
long start = System.currentTimeMillis();
// 例項化一個 Counter 計數器物件
Counter counter = new Counter();
CountDownLatch countDownLatch = new CountDownLatch(testCounts);
for (int i =0 ;i < testCounts;i++){
new Thread(()->{
// 呼叫 safeCount 方法
counter. safeCount();
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
// 結束時間
long end = System.currentTimeMillis();
safeTotalCostTime += (end-start);
return counter.safeCount.get();
}
複製程式碼
- 普通方式
public static int testUnSafe() throws InterruptedException {
// 記錄開始時間
long start = System.currentTimeMillis();
// 例項化一個 Counter 計數器物件
Counter counter = new Counter();
CountDownLatch countDownLatch = new CountDownLatch(testCounts);
for (int i =0 ;i< testCounts;i++){
new Thread(()->{
// 呼叫 unsafeCount 方法
counter.unsafeCount();
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
// 結束時間
long end = System.currentTimeMillis();
unsafeTotalCostTime += (end-start);
return counter.unsafe;
}
複製程式碼
- main 方法
public static void main(String[] args) throws InterruptedException {
// 執行 300 次
for (int i =0 ;i< 300;i++){
// 普通方式
int unSafeResult = testUnSafe();
// cas 方式
int safeResult = testSafe();
// 結果驗證,若果正確就將成功次數增加
if (unSafeResult == testCounts){
totalUnSafeCount++;
}
// 同上
if (safeResult == testCounts){
totalSafeCount++;
}
}
System.out.println("test count = " + testCounts);
System.out.println("非安全計數器正確個數 = " + totalUnSafeCount);
System.out.println("非安全計數器耗時 = " + unsafeTotalCostTime);
System.out.println("安全計數器正確個數 = " + totalSafeCount);
System.out.println("安全計數器耗時 = " + safeTotalCostTime);
}
複製程式碼
我的機器資訊如下:
- MacBook Pro (Retina, 15-inch, Mid 2015)
- 處理器:2.2 GHz Intel Core i7
- 記憶體:16 GB 1600 MHz DDR3
下面是一些測試資料。
1000(執行緒數) * 300(次數)
測試結果如下:
test count = 1000
非安全計數器正確個數 = 300
非安全計數器耗時 = 27193
安全計數器正確個數 = 300
安全計數器耗時 = 26337
複製程式碼
居然發現不使用 CAS 的方式居然比使用自旋 CAS 的耗時要高出將近 1s。另外一個意外的點,我嘗試了好幾次,不使用 CAS 的情況得到的結果正確率基本也是 4 個 9 以上的比率,極少數會出現計算結果錯誤的情況。
3000(執行緒數) * 30(次數)
測試結果如下:
test count = 3000
非安全計數器正確個數 = 30
非安全計數器耗時 = 7816
安全計數器正確個數 = 30
安全計數器耗時 = 8073
複製程式碼
這裡看到在耗時上已經很接近了。這裡需要考慮另外一個可能影響的點是,因為 testUnSafe 是 testSafe 之前執行的,“JVM 和 機器本身熱身” 影響耗時雖然很小,但是也存在一定的影響。
5000(執行緒數) * 30(次數)
測試結果如下:
test count = 5000
非安全計數器正確個數 = 30
非安全計數器耗時 = 23213
安全計數器正確個數 = 30
安全計數器耗時 = 14161
複製程式碼
隨著併發量的增加,這裡奇怪的是,普通自增方式所消耗的時間要高於CAS方式消耗的時間將近 8-9s 。
當嘗試 10000 次時,是的你沒猜錯,丟擲了 OOM 。但是從執行的結果來看,並沒有說隨著併發量的增大,普通方式錯誤的概率會增加,也沒有出現預想的 CAS 方式的耗時要比 普通模式耗時多。
由於測試樣本資料比較單一,對於測試結果沒法做結論,歡迎大家將各自機器的結果提供出來,以供參考。另外就是,最近看到很多面試的同學,如果有被問道這個問題,還是需要謹慎考慮下。關於是否“打臉”還是“被打臉”還需要更多的測試結果。
CAS 到底是怎麼操作的
- CPU 指令
- Unsafe 類
2、ABA 問題的簡單復現
網上關於 CAS 討論另外一個點就是 CAS 中的 ABA 問題,相信大多數同學在面試時如果被問到 CAS ,那麼 ABA 問題也會被問到,然後接著就是怎麼避免這個問題,是的套路就是這麼一環扣一環的。
我相信 90% 以上的開發人員在實際的工程中是沒有遇到過這個問題的,即使遇到過,在特定的情況下也是不會影響到計算結果。但是既然這個問題會被反覆提到,那就一定有它導致 bug 的場景,找了一個案例供大家參考:CAS下ABA問題及優化方案 。
這裡先不去考慮怎麼去規避這個問題,我們想怎麼去通過簡單的模擬先來複現這個 ABA 問題。其實這個也很簡單,如果你對執行緒交叉、順序執行了解的話。
如何實現多執行緒的交叉執行
這個點實際上也是一個在面試過程中很常見的一個基礎問題,我在提供的程式碼中給了三種實現方式,有興趣的同學可以拉程式碼看下。
下面以 lock 的方式來模擬下這個場景,程式碼如下:
public class ConditionAlternateTest{
private static int count = 0;
// 計數器
public AtomicInteger safeCount = new AtomicInteger(0);
// lock
private Lock lock = new ReentrantLock();
// condition 1/2/3 用於三個執行緒觸發執行的條件
Condition c1 = lock.newCondition();
Condition c2 = lock.newCondition();
Condition c3 = lock.newCondition();
// 模擬併發執行
CountDownLatch countDownLatch = new CountDownLatch(1);
// 執行緒1 ,A
Thread t1 = new Thread(()-> {
try {
lock.lock();
while (count % 3 != 0){
c1.await();
}
safeCount.compareAndSet(0, 1);
System.out.println("thread1:"+safeCount.get());
count++;
// 喚醒條件2
c2.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
// 執行緒2 ,B
Thread t2 = new Thread(()-> {
try {
lock.lock();
while (count % 3 != 1){
c2.await();
}
safeCount.compareAndSet(1, 0);
System.out.println("thread2:"+safeCount.get());
count++;
// 喚醒條件3
c3.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
// 執行緒2 ,A
Thread t3 = new Thread(()-> {
try {
lock.lock();
while (count % 3 != 2){
c3.await();
}
safeCount.compareAndSet(0, 1);
System.out.println("thread3:"+safeCount.get());
count++;
// 喚醒條件1
c1.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
// 啟動啟動執行緒
public void threadStart() {
t3.start();
t1.start();
t2.start();
countDownLatch.countDown();
}
public static void main(String[] args) throws InterruptedException {
ConditionAlternateTest test = new ConditionAlternateTest();
test.threadStart();
test.countDownLatch.await();
}
}
複製程式碼
執行結果:
thread1:1
thread2:0
thread3:1
複製程式碼
上面執行緒交叉的案例實際上並不是嚴格意義上的 ABA 問題的復現,這裡僅是模擬下產生的一個最簡單的過程。如果大家有好的案例,也可以分享一下。
ABA 問題解決
常見實踐:“版本號”的比對,一個資料一個版本,版本變化,即使值相同,也不應該修改成功。
java 中提供了 AtomicStampedReference 這個類來解決這個 ABA 問題。 AtomicStampedReference 原子類是一個帶有時間戳的物件引用,在每次修改後,AtomicStampedReference 不僅會設定新值而且還會記錄更改的時間。當 AtomicStampedReference 設定物件值時,物件值以及時間戳都必須滿足期望值才能寫入成功,這也就解決了反覆讀寫時,無法預知值是否已被修改的窘境。
實現程式碼這裡就不貼了,基於前面的程式碼改造,下面貼一下執行結果:
thread1,第一次修改;值為=1
thread2,已經改回為原始值;值為=0
thread3,第二次修改;值為=1
複製程式碼
3、只能保證一個共享變數的原子操作
當對一個共享變數執行操作時,我們可以使用 CAS 的方式來保證原子操作,但是對於對多個變數操作時,迴圈 CAS 就無法保證操作的原子性了,那麼這種場景下,我們就需要使用加鎖的方式來解決。