概述
CAS(Compare-and-Swap),即比較並替換,是一種實現併發算法時常用到的技術,Java併發包中的很多類都使用了CAS技術。CAS也是現在面試經常問的問題,本文將深入的介紹CAS的原理。
案例
介紹CAS之前,我們先來看一個例子。
/**
* @author joonwhee
* @date 2019/7/6
*/
public class VolatileTest {
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
public static void increase() {
race++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
這個例子有些網友反饋會進入死循環,我後面也發現了,在IDEA的RUN模式下確實會陷入死循環,通過 Thread.currentThread().getThreadGroup().list(); 代碼可以打印出當前的線程情況如下:
java.lang.ThreadGroup[name=main,maxpri=10]
Thread[main,5,main]
Thread[Monitor Ctrl-Break,5,main]
可以看到,除了Main方法線程後,還有一個Monitor Ctrl-Break線程,這個線程是IDEA用來監控Ctrl-Break中斷信號的線程。
解決死循環的辦法:如果是IDEA,可以使用DEBUG模式運行就可以,或者使用下面這段代碼。
import java.util.concurrent.CountDownLatch;
/**
* @author joonwhee
* @date 2019/7/6
*/
public class VolatileTest {
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
private static CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);
public static void increase() {
race++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
countDownLatch.countDown();
}
});
threads[i].start();
}
countDownLatch.await();
System.out.println(race);
}
}
上面這個例子在volatile關鍵字詳解文中用過,我們知道,運行完這段代碼之後,並不會獲得期望的結果,而且會發現每次運行程序,輸出的結果都不一樣,都是一個小於200000的數字。
通過分析字節碼我們知道,這是因爲volatile只能保證可見性,無法保證原子性,而自增操作並不是一個原子操作(如下圖所示),在併發的情況下,putstatic指令可能把較小的race值同步回主內存之中,導致我們每次都無法獲得想要的結果。那麼,應該怎麼解決這個問題了?
解決方法:
首先我們想到的是用synchronized來修飾increase方法。
使用synchronized修飾後,increase方法變成了一個原子操作,因此是肯定能得到正確的結果。但是,我們知道,每次自增都進行加鎖,性能可能會稍微差了點,有更好的方案嗎?
答案當然是有的,這個時候我們可以使用Java併發包原子操作類(Atomic開頭),例如以下代碼。
我們將例子中的代碼稍做修改:race改成使用AtomicInteger定義,“race++”改成使用“race.getAndIncrement()”,AtomicInteger.getAndIncrement()是原子操作,因此我們可以確保每次都可以獲得正確的結果,並且在性能上有不錯的提升(針對本例子,在JDK1.8.0_151下運行)。
通過方法調用,我們可以發現,getAndIncrement方法調用getAndAddInt方法,最後調用的是compareAndSwapInt方法,即本文的主角CAS,接下來我們開始介紹CAS。
getAndAddInt方法解析:拿到內存位置的最新值v,使用CAS嘗試修將內存位置的值修改爲目標值v+delta,如果修改失敗,則獲取該內存位置的新值v,然後繼續嘗試,直至修改成功。
CAS是什麼?
CAS是英文單詞CompareAndSwap的縮寫,中文意思是:比較並替換。CAS需要有3個操作數:內存地址V,舊的預期值A,即將要更新的目標值B。
CAS指令執行時,當且僅當內存地址V的值與預期值A相等時,將內存地址V的值修改爲B,否則就什麼都不做。整個比較並替換的操作是一個原子操作。
源碼分析
上面源碼分析時,提到最後調用了compareAndSwapInt方法,接着繼續深入探討該方法,該方法在Unsafe中對應的源碼如下。
可以看到調用了“Atomic::cmpxchg”方法,“Atomic::cmpxchg”方法在linux_x86和windows_x86的實現如下。
linux_x86的實現:
windows_x86的實現:
Atomic::cmpxchg方法解析:
mp是“os::is_MP()”的返回結果,“os::is_MP()”是一個內聯函數,用來判斷當前系統是否爲多處理器。
- 如果當前系統是多處理器,該函數返回1。
- 否則,返回0。
LOCK_IF_MP(mp)會根據mp的值來決定是否爲cmpxchg指令添加lock前綴。
- 如果通過mp判斷當前系統是多處理器(即mp值爲1),則爲cmpxchg指令添加lock前綴。
- 否則,不加lock前綴。
這是一種優化手段,認爲單處理器的環境沒有必要添加lock前綴,只有在多核情況下才會添加lock前綴,因爲lock會導致性能下降。cmpxchg是彙編指令,作用是比較並交換操作數。
intel手冊對lock前綴的說明如下:
- 確保對內存的讀-改-寫操作原子執行。在Pentium及Pentium之前的處理器中,帶有lock前綴的指令在執行期間會鎖住總線,使得其他處理器暫時無法通過總線訪問內存。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有總線鎖的基礎上做了一個很有意義的優化:如果要訪問的內存區域(area of memory)在lock前綴指令執行期間已經在處理器內部的緩存中被鎖定(即包含該內存區域的緩存行當前處於獨佔或以修改狀態),並且該內存區域被完全包含在單個緩存行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該緩存行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的內存區域,因此能保證指令執行的原子性。這個操作過程叫做緩存鎖定(cache locking),緩存鎖定將大大降低lock前綴指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的內存地址未對齊時,仍然會鎖住總線。
- 禁止該指令與之前和之後的讀和寫指令重排序。
- 把寫緩衝區中的所有數據刷新到內存中。
上面的第1點保證了CAS操作是一個原子操作,第2點和第3點所具有的內存屏障效果,保證了CAS同時具有volatile讀和volatile寫的內存語義。
CAS的缺點:
CAS雖然很高效的解決了原子操作問題,但是CAS仍然存在三大問題。
- 循環時間長開銷很大。
- 只能保證一個共享變量的原子操作。
- ABA問題。
循環時間長開銷很大:我們可以看到getAndAddInt方法執行時,如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。
只能保證一個共享變量的原子操作:當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。
什麼是ABA問題?ABA問題怎麼解決?
如果內存地址V初次讀取的值是A,並且在準備賦值的時候檢查到它的值仍然爲A,那我們就能說它的值沒有被其他線程改變過了嗎?
如果在這段期間它的值曾經被改成了B,後來又被改回爲A,那CAS操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲CAS操作的“ABA”問題。Java併發包爲了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變量值的版本來保證CAS的正確性。因此,在使用CAS前要考慮清楚“ABA”問題是否會影響程序併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。