程式設計師深夜慘遭老婆鄙視,原因竟是CAS原理太簡單?| 每一張圖都力求精美
悟空
種樹比較好的時間是十年前,其次是現在。
自主開發了Java學習平台、PMP刷題小程式。目前主修Java
、多執行緒
、SpringBoot
、SpringCloud
、k8s
。
本公眾號不限於分享技術,也會分享工具的使用、人生感悟、讀書總結。
夜黑風高的晚上,一名苦逼程式設計師正在瘋狂敲著鍵盤,突然他老婆帶著一副睡眼朦朧的眼神瞟了下電腦桌面。於是有了如下對話:
老婆:這畫的圖是啥意思,怎麼還有三角形,四邊形?
我:我在畫CAS的原理,要不我跟你講一遍?
老婆:好呀!
案例:甲看見一個三角形積木,覺得不好看,想替換成五邊形,但是乙想把積木替換成四邊形。(前提條件,只能被替換一次)
甲比較雞賊,想到了一個辦法:「我把積木帶到另外一個房間裡面去替換,並上鎖,就不會被別人打擾了。」(這裡用到了排他鎖synchronized
)
乙覺得甲太不厚道:「房間上了鎖,我進不去,我也看不見積木長啥樣。(因上了鎖,所以不能訪問)」
於是甲、乙想到了另外一個辦法:誰先搶到積木,誰先替換,如果積木形狀變了,則不允許其他人再次替換。(比較並替換CAS
)
於是他們就開始搶三角形積木:
-
場景1:
甲搶到,替換成五邊形,乙不能替換
- 假如甲先搶到了,積木還是三角形的,就把三角形替換成五邊形了。
-
乙後搶到,積木已經變為五邊形了,乙就沒機會替換了(因為甲、乙共一次替換機會)。
-
場景2:
乙搶到未替換,甲替換成功
-
假如乙先搶到了,但是突然覺得三角形也挺好看的,沒有替換,放下積木就走開了。
-
然後甲搶到了積木,積木還是三角形的,想到乙沒有替換,就把三角形替換成五邊形了。
-
-
場景3:
乙搶到,替換成三角形,甲替換成五邊形,ABA問題
- 假如乙先搶到了,但是覺得這個三角形是舊的,就換了另外一個一摸一樣的三角形,只是積木比較新。
- 然後甲搶到了積木,積木還是三角形的,想到乙沒有替換,就把三角形替換成五邊形了。
老婆聽完後,覺得這三種場景都太簡單了,原來電腦這麼簡單,早知道我也去學電腦。。。
被無情鄙視了,好在老婆居然聽懂了,不知道大家聽懂沒?
回歸正傳,我們用電腦術語來講下Java CAS的原理
一、Java CAS簡介
CAS的全稱:Compare-And-Swap(比較並交換)。比較變數的現在值與之前的值是否一致,若一致則替換,否則不替換。
CAS的作用:原子性更新變數值,保證執行緒安全。
CAS指令:需要有三個操作數,變數的當前值(V),舊的預期值(A),準備設置的新值(B)。
CAS指令執行條件:當且僅當V=A時,處理器才會設置V=B,否則不執行更新。
CAS的返回指:V的之前值。
CAS處理過程:原子操作,執行期間不會被其他執行緒中斷,執行緒安全。
CAS並發原語:體現在Java語言中sun.misc.Unsafe類的各個方法。調用UnSafe類中的CAS方法,JVM會幫我們實現出CAS彙編指令,這是一種完全依賴於硬體的功能,通過它實現了原子操作。由於CAS是一種系統原語,原語屬於作業系統用於範疇,是由若干條指令
組成,用於完成某個功能的一個過程,並且原語的執行必須是連續的,在執行過程中不允許被中斷,所以CAS是一條CPU的原子指令,不會造成所謂的數據不一致的問題,所以CAS是執行緒安全的。
二、能寫幾行程式碼說明下嗎?
在上篇講volatile時,講到了如何使用原子整型類AtomicInteger來解決volatile的非原子性問題,保證多個執行緒執行num++的操作,最終執行的結果與單執行緒一致,輸出結果為20000。
這次我們還是用AtomicInteger。
首先定義atomicInteger變數的初始值等於10,主記憶體中的值設置為10
AtomicInteger atomicInteger = new AtomicInteger(10);
然後調用atomicInteger的CAS方法,先比較當前變數atomicInteger的值是否是10,如果是,則將變數的值設置為20
atomicInteger.compareAndSet(10, 20);
設置成功,atomicInteger更新為20
當我們再次調用atomicInteger的CAS方法,先比較當前變數atomicInteger的值是否是10,如果是,則將變數的值設置為30
atomicInteger.compareAndSet(10, 30);
設置失敗,因atomicInteger的當前值為20,而比較值是10,所以比較後,不相等,故不能進行更新。
完整程式碼如下:
package com.jackson0714.passjava.threads;
import java.util.concurrent.atomic.AtomicInteger;
/**
演示CAS compareAndSet 比較並交換
* @author: 悟空聊架構
* @create: 2020-08-17
*/
public class CASDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(10);
Boolean result1 = atomicInteger.compareAndSet(10,20);
System.out.printf("當前atomicInteger變數的值:%d 比較結果%s\r\n", atomicInteger.get(), result1);
Boolean result2 = atomicInteger.compareAndSet(10,30);
System.out.printf("當前atomicInteger變數的值:%d, 比較結果%s\n" , atomicInteger.get(), result2);
}
}
執行結果如下:
當前atomicInteger變數的值:20 比較結果true
當前atomicInteger變數的值:20, 比較結果false
我們來對比看下原理圖理解下上面程式碼的過程
- 第一步:執行緒1和執行緒2都有主記憶體中變數的拷貝,值都等於10
- 第二步:執行緒1想要將值更新為20,先要將工作記憶體中的變數值與主記憶體中的變數進行比較,值都等於10,所以可以將主記憶體中的值替換成20
- 第三步:執行緒1將主記憶體中的值替換成20,並將執行緒1中的工作記憶體中的副本更新為20
- 第四步:執行緒2想要將變數更新為30,先要將執行緒2的工作記憶體中的值與主記憶體進行比較10不等於20,所以不能更新
- 第五步:執行緒2將工作記憶體的副本更新為與主記憶體一致:20
圖畫得非常棒!
上述的場景和我們用Git程式碼管理工具是一樣的,如果有人先提交了程式碼到develop分支,另外一個人想要改這個地方的程式碼,就得先pull develop分支,以免提交時提示衝突。
三、能講下CAS底層原理嗎?
源碼調試
這裡我們用atomicInteger的getAndIncrement()方法來講解,這個方法裡面涉及到了比較並替換的原理。
示例如下:
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(10);
Thread.sleep(100);
new Thread(() -> {
atomicInteger.getAndIncrement();
}, "aaa").start();
atomicInteger.getAndIncrement();
}
-
(1)首先需要開啟IDEA的多執行緒調試模式
-
(2)我們先打斷點到17行,main執行緒執行到此行,子執行緒
aaa
還未執行自增操作。
getAndIncrement方法會調用unsafe的getAndAddInt
方法,
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
-
(3)在源碼
getAndAddInt
方法的361行打上斷點,main執行緒先執行到361行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:當前自增多少,默認為1,且不可設為其他值
- var5:當前變數的值
this.getIntVolatile(var1, var2)
:根據當前對象var1和對象的記憶體偏移量var2得到主記憶體中變數的值,賦值給var5,並在main執行緒的工作記憶體中存放一份var5的副本
-
(4)在362行打上斷點,main執行緒繼續執行一步
- var5獲取到主記憶體中的值為10
-
(5)切換到子執行緒aaa,還是在361行斷點處,還未獲取主記憶體的值
-
(6)子執行緒aaa繼續執行一步,獲取到var5的值等於10
(7)切換到main執行緒,進行比較並替換
this.compareAndSwapInt(var1, var2, var5, var5 + var4)
var5=10,通過var1和var2獲取到的值也是10,因為沒有其他執行緒修改變數。compareAndSwapInt的源碼我們後面再說。
所以比較後,發現變數沒被其他執行緒修改,可以進行替換,替換值為var5+var4=11,變數值替換後為 11,也就是自增1。這行程式碼執行結果返回true(自增成功),退出do while循環。return值為變數更新前的值10。
(8)切換到子執行緒aaa,進行比較並自增
因為此時aaa執行緒的var5=10,而主記憶體中的值已經更新為11了,所以比較後發現被其他執行緒修改了,不能進行替換,返回false,繼續執行do while循環。
- (9)子執行緒aaa繼續執行,重新獲取到的var=11
-
(10)子執行緒aaa繼續執行,進行比較和替換,結果為true
因var5=11,主記憶體中的變數值也等於11,所以比較後相等,可以進行替換,替換值為var5+var4,結果為12,也就是自增1。退出循環,返回變數更新前的值var5=11。
至此,getAndIncrement方法的整個原子自增的邏輯就debug完了。所以可以得出結論:
先比較執行緒中的副本是否與主記憶體相等,相等則可以進行自增,並返回副本的值,若其他執行緒修改了主記憶體中的值,當前執行緒不能進行自增,需要重新獲取主記憶體的值,然後再次判斷是否與主記憶體中的值是否相等,以此往複。
四、CAS有什麼問題?
不知道大家發現沒,aaa執行緒可能會出現循環多次的問題,因為其他執行緒可能將主記憶體的值又改了,但是aaa執行緒拿到的還是老的數據,就會出現再循環一次,就會給CPU帶來性能開銷。這個就是自旋
。
頻繁出現自旋,循環時間長,開銷大
(因為執行的是do while,如果比較不成功一直在循環,最差的情況,就是某個執行緒一直取到的值和預期值都不一樣,這樣就會無限循環)- 只能保證
一個
共享變數的原子操作- 當對
一個
共享變數執行操作時,我們可以通過循環CAS的方式來保證原子操作 - 但是對於
多個
共享變數操作時,循環CAS就無法保證操作的原子性,這個時候只能用鎖來保證原子性
- 當對
- 引出來ABA問題(有彩蛋)
五、小結
本篇從和老婆的對話開始,以通俗的語言給老婆講了CAS問題,其中還涉及到了並發鎖。然後從底層程式碼一步一步debug,深入理解了CAS的原理。
每一張圖都力求精美!分享+在看啊,大佬們!
彩蛋:還有一個ABA問題沒有給大家講,另外這裡怎麼不是AAB(拖拉機),AAA(金花)?
這周前三天寫技術文章花了大量時間,少熬夜,睡覺啦 ~ 我們下期再來講ABA問題,小夥伴們分享轉發下好嗎?您的支援是我寫作最大的動力~
悟空,一隻努力變強的碼農!我要變身超級賽亞人啦!