在面試官面前我是這樣介紹CAS的

带你聊技术發表於2024-03-06

來源:碼農本農

如何回答什麼是CAS?

CAS是Compare And Swap的簡稱,單從字面理解是比較並替換,實際指的是Unsafe類中的三個方法compareAndSwapObject,compareAndSwapInt,compareAndSwapLong,三個方法分別是以比較並替換的方式對Object型別的資料,對int型別的資料,對long型別的資料保證其操作的原子性。

在CAS比較並替換的邏輯中有三個重要的概念:預估值,記憶體值,更新值,而比較替換的邏輯為:如果預估值等於記憶體值,則將記憶體值更新為更新值,否則就不更新。

比較和替換這兩個動作,無論是在java層面實現還是在jvm層面實現在不加鎖的情況下都是無法完全保證原子性的,因此不得不依賴硬體對於併發的支援,即作業系統底層提供了cmpxchg指令,這一個指令就可以完成比較和替換兩個動作。那麼即便是將兩個動作濃縮到一個彙編指令裡面,就能保證原子性嗎?

答案是肯定的,這是因為計算機硬體自身支援一個彙編指令執行過程是不允許被中斷的,這兩個動作已經濃縮到了cmpxchg這一個彙編指令裡面了,不能中斷意味著這兩個動作必須在同個cpu時間片段內完成,一氣呵成,所以cmpxchg指令本身就具備了原子性。

但是不同的平臺實現cas有不同的方式。這個彙編指令的名字可能也會不同,這個需要注意。

分析:

分析CAS前必須先明白原子性是什麼。

什麼是原子性

原子性:指事務的不可分割性,一個事務的所有操作要麼不間斷地全部被執行,要麼一個也沒有執行。

前置瞭解

我們都知道所有的程式都是執行在cpu上的,cpu執行的是機器碼,如00,01,0A。因為這些指令操作起來太麻煩且不好記憶,所以後來有人發明了更加方便操作和好懂的組合語言,彙編是一種低階語言,這裡可以理解為機器碼對外所呈現的樣子,也可以乾脆理解為cpu執行的就是組合語言。基本每種平臺都實現了自己的組合語言,所以不同平臺的組合語言是不能通用的。

彙編和機器碼的對應關係如下:

ADD reg8/mem8,reg8 對應 00

ADD reg16/mem16,reg16 對應 01

OR reg8,reg8/mem8 對應 0A

OR reg16,reg16/mem16 對應 0B

jvm是一個虛擬機器,它有一套自己的位元組碼指令,它有一套記憶體管理機制和垃圾回收機制,它有自己的執行機制,它有類似與暫存器的運算元棧,它自身就是一臺計算機,它專門執行java語言,java語言依靠jvm執行,需要先編譯成位元組碼指令,就像C++語言執行要先編譯成組合語言一樣。

jvm是C實現的,歸根結底要執行在cpu上,所以我們可以知道java語言的執行過程為:java編譯成c++,再編譯成彙編,再編譯成機器碼。

透過一個例子來看下java程式碼到機器碼的翻譯過程:

java程式碼:i++

java程式碼編譯成位元組碼反編譯的程式碼:getstatic i 獲取i的值 iconst_1 準備常量1 iadd 加1 putstatic 將修改後的值寫回變數i

iadd會被jvm翻譯成類似於下面的彙編程式碼:mov (%rsp),%edx add $0x8,%rsp add %edx,%eax

而彙編指令add對應的二進位制機器碼為00,01等。

現在來理解下原子性概念中的不可分割和不間斷執行

不可分割性就是原子性。而事物的不可分割則是事務不受外界影響。

不管是java還是c++程式碼,最終執行的時候都要被編譯成機器碼,而機器碼是cpu執行的基本單元,所以只有一個機器碼指令才是天然的不可分割,而為了方便開發和理解,每個平臺都有一套對應機器碼的組合語言,以linux為例,作業系統會保證每個彙編指令的執行都是不允許被中斷的,也就是一個彙編指令也是不可分割的。

多個指令就一定是分割的嗎?

不一定,只要多個指令能夠不間斷執行就可以認為是沒有被分割的。

那如何才算不間斷執行呢?

不間斷就是幾個指令能夠順序執行,且執行過程中或者執行緒不可中斷,或者執行緒中斷後不受其他執行緒影響。

那什麼是中斷呢?

中斷就是執行緒被掛起。

那什麼時候執行緒才會被掛起呢?

java程式設計師都知道,執行緒執行過程中遇到鎖的時候,如果沒有獲取到鎖就會阻塞掛起。當呼叫wait方法,join方法,park方法的時候都會進入阻塞掛起的狀態,但是這些狀態都是程式設計師人為的,是可以避免的。

但是有一種掛起是不可避免的,我們知道cpu是序列的執行模式,一個時間點上只能執行一個執行緒。為了讓所有的執行緒看起來都是在同時執行,作業系統的機制是將cpu的執行時間分成很多個細小的時間片段,由作業系統為每個執行緒分配時間片段,當輪到某個時間片段執行時,繫結此時間片段的執行緒才會被執行,當這個時間片段用完,當前的執行緒如果還沒有執行完的話就會被掛起,等待再次被排程執行。這個過程中執行緒就被中斷了。

中斷後不受其他執行緒影響怎麼理解?

例如synchronized程式碼塊,雖然執行緒在執行程式碼塊邏輯的時候會被cpu時間片段排程中斷,但是synchronized關鍵字透過加鎖的方式只允許一個執行緒進入程式碼塊邏輯,這就保證了當前執行緒在執行程式碼塊中邏輯的時候雖然會中斷,但是不受其他執行緒的影響。

不能看出上面的說不可中斷和中斷不受其他執行緒影響對應的正是cas的方式和加鎖的方式實現原子性。

本篇我們只看CAS方式

CAS實現原理

計算機做了什麼?

我們知道了cas最終是靠計算機底層原語cmpxchg支援,下面就是linux_x86底層的彙編實現。

linux_x86底層實現

\hotspot\src\os_cpu\linux_x86\vm\atomic_linux_x86.inline.hpp
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP(); // 行內函數,用來判斷當前系統是否為多處理器
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}

在這個方法中最關鍵的是一行程式碼是:LOCK_IF_MP(%4) "cmpxchgl %1,(%3),cmpxchgl關鍵字就是彙編指令原語,這個原語在不同的計算機的實現不一樣,在linux_x86就叫做cmpxchgl。

而cmpxchg方法可以理解為linux_x86平臺對外提供cas支援的API。

這段程式碼中我們看到LOCK_IF_MP關鍵字,看到lock一般我們會想到加鎖,這裡確實是加鎖的意思,或許你會說cas操作為什麼還要加鎖,如果這樣,那直接用加鎖的方式實現原子性不就可以了,這段程式碼的邏輯其實是先判斷是否為多核處理器,如果是多核就會加鎖,如果單核就不加鎖。而這個加鎖的實現根據系統平臺的不同會有不同的實現方式,大致分為鎖匯流排和鎖快取。

我們說了,一條機器指令(或者說彙編指令)一定能在一個cpu時間片段內完成,如果兩個執行緒佔據兩個時間片,這個兩個時間片段都是要執行同一個指令,當一個時間片段執行完,才能執行下一個時間片段,這樣看起來單個指令的執行是序列的,不會有問題,但是現在的處理器一般都會存在多核,甚至多cpu,每個核都會有自己的快取,所以,多核情況下僅僅靠cas是無法保證原子性的,作業系統內部透過鎖來規避。

這屬於作業系統為實現更高效而不得不付出的複雜性。這裡牽扯到快取一致性協議,介紹作業系統的時候再細說。

JVM做了什麼?

既然計算機提供了cas支援,那麼應用程式怎麼應用呢,java虛擬機器為java實現提供了支援。

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj); //查詢要指定的物件
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); //獲取要操作的是物件的欄位的記憶體地址
return (jint)(Atomic::cmpxchg(x, addr, e)) == e; //執行Atomic類中的cmpxchg
UNSAFE_END

以上原始碼中真正實現cas的程式碼為:

Atomic::cmpxchg(x, addr, e)

不難看出,x為新值,addr為地址,e為期望值

jvm底層呼叫了彙編程式碼中的方法cmpxchg,這個方法就是上面彙編程式碼中方法。

上面這個方法是jvm底層實現,也是jvm對於cas的支援,jvm對外也提供了API,只不過是以本地方法的形式提供給java使用,它的體現就是 Unsafe類下面提供的三個本地方法compareAndSwapObject,compareAndSwapInt,compareAndSwapLong。


在面試官面前我是這樣介紹CAS的


這個幾個方法的具體實現都在jvm內部。上面的jvm程式碼對應的就是compareAndSwapInt本地方法的具體實現,其他方法也都大同小異。

Unsafe類是java層面提供的用於記憶體管理的類,除記憶體操作方法外,同時還提供執行緒排程操作,,記憶體屏障相關操作等,但是它不是應用類載入器載入的,因此程式設計師是不能直接使用的。

我們來解釋下這幾個引數:

 public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

var1:要修改的物件起始地址 如:0x00000111

var2:需要修改的具體記憶體地址 如100 。0x0000011+100 = 0x0000111就是要修改的值的地址

注意沒有var3

var4:期望記憶體中的值,拿這個值和0x0000111記憶體中的中值比較,如果為true,則修改,返回ture,否則返回false,等待下次修改。

var5:如果上一步比較為ture,則把var5更新到0x0000111其實的記憶體中。

原子操作,直接操作記憶體。

JAVA做了什麼?

java提供了java.util.concurrent.atomic包,包下面提供了各種原子類,如下圖。


在面試官面前我是這樣介紹CAS的


這些原子類底層基本都是依賴Unsafe類的三個方法實現。

接下來以AtomicInteger來介紹

先來看例子:

    static int i=0;
public static void main(String[] args) throws IOException, InterruptedException {

Thread T1=new Thread(new Runnable(){
@Override
public void run(){
for(int n=0;n<10000;n++){
i++;
}
}
});

Thread T2=new Thread(new Runnable(){
@Override
public void run(){
for(int n=0;n<10000;n++){
i++;
}

}
});

T1.start();
T2.start();
T1.join();
T2.join();

System.out.println(i);

大家都清楚這段程式碼最終列印的結果不一定是20000,因為i++這個操作不是原子性的。

上述程式碼的邏輯我們替換為AtomicInteger來實現

    static AtomicInteger atomic=new AtomicInteger();
public static void main(String[] args) throws IOException, InterruptedException {
Thread T1=new Thread(new Runnable(){
@Override
public void run(){
for(int n=0;n<10000;n++){
atomic.getAndAdd(1);
}

}
});

Thread T2=new Thread(new Runnable(){
@Override
public void run(){
for(int n=0;n<10000;n++){
atomic.getAndAdd(1);
}
}
});

T1.start();
T2.start();
T1.join();
T2.join();
System.out.println(atomic);

這段程式碼實現得到的結果是正確的20000。

先來看看AtomicInteger類的原始碼(只貼出主要程式碼)

public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;

private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex);
}
}

public AtomicInteger(int initialValue) {
value = initialValue;
}

public AtomicInteger() {
}

private volatile int value;

public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}

}

先來解釋下getAndAddInt方法中的這幾個引數

value是AtomicInteger物件的成員變數,它就是實際要被操作的變數;

this是指當前AtomicInteger物件;

valueOffse是指value這個屬性所代表的具體值位於AtomicInteger物件所分配的記憶體空間的偏移量。

從上面的程式碼中可以看出valueOffse的值是透過靜態方法確定的,也就是說在建立AtomicInteger物件前這個值就已經確定了且是固定的。之所以能固定下來,是因為java中的類在類載入的時候就已經確定了類中的成員變數所處整個記憶體空間的地址。所以同個類下的所有物件的這個偏移量值都是一樣的。

Unsafe類中的getAndAddInt方法的原始碼如下

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

這個方法中var1就是AtomicInteger物件本身,var2是偏移量,var4是要加加的值。var5是查詢出的當前AtomicInteger物件記憶體空間在valueOffse偏移量處的值是多少。

getAndAddInt方法的整體邏輯就是先獲取當前記憶體值作為預估值,利用compareAndSwapInt方法進行cas,即比較預估值和計算底層的記憶體值,如果相等就用更新值替換記憶體值,如果不等就返回false,然後重複上面的步驟,直到替換成功。

藉助這個思想,也可以解決一些資料庫層面的問題

int c=0;
while(c=0){
1 select ov from t where id=1;

2 int nv=ov*x;

3 c = update t set ov=nv where id=1 and ov=ov;
}

CAS的問題

不支援高併發

CAS只能用於併發不是很高的場景,java中的原始碼我們看到了,一般為了保證一定能替換成功需要在cas外套一個迴圈,如果併發很高,處於迴圈中的執行緒很多,就會導致cpu飆升,一般併發很高的場景需要用鎖解決。

ABA問題

使用CAS需要注意ABA問題,所謂ABA問題其實很簡單,先羅列下步驟

獲取:獲取記憶體值作為預期值

比較:預期值和記憶體值;

替換:記憶體值=更新值;

如果在獲取和比較之間記憶體值由1被改為2,又由2被改回了1,這種情況下是不會影響後面的比較和替換的。這便是ABA問題。可能結果上沒有問題,但是在邏輯上是有問題的。不能確保所有的場景都有問題。

ABA問題的解決

jdk提供 AtomicStampedReference 原子類解決ABA問題。

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)));
}

private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}

pair是一個靜態內部類,也是在類載入的時候計算固定的偏移量,只不過這個內部類中有兩個屬性,一個是具體的值,一個是時間戳。

透過compareAndSwapObject進行cas操作。

不難看出,原來是透過比較value的值容易出現ABA問題,現在是透過compareAndSwapObject方法比較value和時間戳來判斷是否要替換,替換的時候,會把value和時間戳都替換。



來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024922/viewspace-3008189/,如需轉載,請註明出處,否則將追究法律責任。

相關文章