CAS的全稱是Compare And Swap ,即比較交換。CAS 中通常會設計到3個參數:java
當且僅當預期值 A 和內存值 V 相同時,將內存值V修改成 B,不然什麼都不作。git
這裏關於 CPU 指令對於 CAS 的支持不深刻研究,有興趣的能夠自行了解。github
不少書籍和文章中都有提出它存在的幾個問題:面試
下面就這三個問題展開來聊一下。安全
自旋 CAS 若是長時間不成功,會給 CPU 帶來很是大的開銷。可是真的是這樣嗎?到底多大的併發量才形成 CAS 的自旋次數會增長呢?另外,對於當前的機器及JDK,在無鎖,無CAS 的狀況下,是否對於結果的影響是真的那麼明顯呢?對於這個問題,下面作了一個簡單的測試,可是測試結果也只是針對在我本地環境下,各位看官能夠拉一下代碼,在本身電腦上 run 一下,把機器信息、JDK版本以及測試結果留言到評論區。bash
本文案例能夠這裏獲取:glmapper-blog-sample-cas多線程
這裏我是用了一個很簡單的案例,就是整數自增。使用了兩種方式去測試的,一種是無鎖,也不用 CAS 操做,另一種是基於 CAS 的方式。(關於加鎖的方式沒有驗證,有時間再補充吧~)併發
計數器裏面有兩個方法,一種是CAS 自旋方式,一種是直接自增。代碼以下:app
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 次來看下結果,包括總耗時和結果的正確性。測試
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;
}
複製代碼
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);
}
複製代碼
個人機器信息以下:
下面是一些測試數據。
測試結果以下:
test count = 1000
非安全計數器正確個數 = 300
非安全計數器耗時 = 27193
安全計數器正確個數 = 300
安全計數器耗時 = 26337
複製代碼
竟然發現不使用 CAS 的方式竟然比使用自旋 CAS 的耗時要高出將近 1s。另一個意外的點,我嘗試了好幾回,不使用 CAS 的狀況獲得的結果正確率基本也是 4 個 9 以上的比率,極少數會出現計算結果錯誤的狀況。
測試結果以下:
test count = 3000
非安全計數器正確個數 = 30
非安全計數器耗時 = 7816
安全計數器正確個數 = 30
安全計數器耗時 = 8073
複製代碼
這裏看到在耗時上已經很接近了。這裏須要考慮另一個可能影響的點是,由於 testUnSafe 是 testSafe 以前執行的,「JVM 和 機器自己熱身」 影響耗時雖然很小,可是也存在必定的影響。
測試結果以下:
test count = 5000
非安全計數器正確個數 = 30
非安全計數器耗時 = 23213
安全計數器正確個數 = 30
安全計數器耗時 = 14161
複製代碼
隨着併發量的增長,這裏奇怪的是,普通自增方式所消耗的時間要高於CAS方式消耗的時間將近 8-9s 。
當嘗試 10000 次時,是的你沒猜錯,拋出了 OOM 。可是從執行的結果來看,並無說隨着併發量的增大,普通方式錯誤的機率會增長,也沒有出現預想的 CAS 方式的耗時要比 普通模式耗時多。
因爲測試樣本數據比較單一,對於測試結果無法作結論,歡迎你們將各自機器的結果提供出來,以供參考。另外就是,最近看到不少面試的同窗,若是有被問道這個問題,仍是須要謹慎考慮下。關因而否「打臉」仍是「被打臉」還須要更多的測試結果。
網上關於 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 問題的復現,這裏僅是模擬下產生的一個最簡單的過程。若是你們有好的案例,也能夠分享一下。
常見實踐:「版本號」的比對,一個數據一個版本,版本變化,即便值相同,也不該該修改爲功。
java 中提供了 AtomicStampedReference 這個類來解決這個 ABA 問題。 AtomicStampedReference 原子類是一個帶有時間戳的對象引用,在每次修改後,AtomicStampedReference 不只會設置新值並且還會記錄更改的時間。當 AtomicStampedReference 設置對象值時,對象值以及時間戳都必須知足指望值才能寫入成功,這也就解決了反覆讀寫時,沒法預知值是否已被修改的窘境。
實現代碼這裏就不貼了,基於前面的代碼改造,下面貼一下運行結果:
thread1,第一次修改;值爲=1
thread2,已經改回爲原始值;值爲=0
thread3,第二次修改;值爲=1
複製代碼
當對一個共享變量執行操做時,咱們可使用 CAS 的方式來保證原子操做,可是對於對多個變量操做時,循環 CAS 就沒法保證操做的原子性了,那麼這種場景下,咱們就須要使用加鎖的方式來解決。