Java併發必知必會第三彈:用積木講解ABA原理
阿新 • • 發佈:2020-08-26
# Java併發必知必會第三彈:用積木講解ABA原理
![封面圖](http://cdn.jayh.club/blog/20200825/f0IhlK4RmutQ.png?imageslim)
可落地的 Spring Cloud專案:[PassJava](https://github.com/Jackson0714/PassJava-Platform)
## 本篇主要內容如下
![本篇主要內容](http://cdn.jayh.club/blog/20200825/MlRROUaMYKIV.png?imageslim)
## 一、背景
![4個A](http://cdn.jayh.club/blog/20200821/b5JUIpeSuH7E.png?imageslim)
上一節我們講了[程式設計師深夜慘遭老婆鄙視,原因竟是CAS原理太簡單?](https://juejin.im/post/6863799243182702599),留了一個彩蛋給大家,ABA問題是怎麼出現的,為什麼不是AAB拖拉機,AAA金花,4個A炸彈 ?這一篇我們再來揭開ABA的神祕面紗。
## 二、面試連環炮
面試的時候我們也經常遭遇面試官的連環追問:
- CAS概念?
- Unsafe類是幹啥用的?
- CAS底層實現是怎麼樣的
- ABA問題什麼場景下會出現?
- ABA有什麼危害?
- 原子引用更新是啥?
- 如何避免ABA問題?
![面試連環炮](http://cdn.jayh.club/blog/20200825/94FgHlKLDUno.png?imageslim)
## 三、用積木講解ABA問題
**案例:甲看見一個三角形積木,覺得不好看,想替換成五邊形,但是乙想把積木替換成四邊形。(前提條件,只能被替換一次)**
![用積木講解ABA過程](http://cdn.jayh.club/blog/20200825/ue3sNCylBMHT.png?imageslim)
可能出現的過程如上圖所示:
- 第一步:乙先搶到了積木,將`三角形A`積木替換成`五角星B1`
- 第二步:乙將`五角星B1`替換成`五邊形B2`
- 第三步:乙將`五邊形B2`替換成`稜形B3`
- 第四步:乙將`稜形B3`替換成`六邊形B4`
- 第五步:乙將`六邊形B4`替換成`三角形A `
- 第六步:甲看到積木還是三角形,認為乙沒有替換,甲可以進行替換
- 第七步:甲將`三角形V`替換成了`五邊形B`
**講解:**第一步道第五步,都是乙在替換,但最後還是替換成了三角形(即是不是同一個三角形),這個就是ABA,A指最開始是三角形,B指中間被替換的B1/B2/B3/B4,第二個A就是第五步中的A,中間不論經過怎麼樣的形狀替換,最後還是變成了三角形。然後甲再將A2和A1進行形狀比較,發現都是三角形,所以認為乙沒有動過積木,甲可以進行替換。**這個就是比較並替換(CAS)中的ABA問題。**
**小結:**CAS只管開頭和結尾,中間過程不關心,只要頭尾相同,則認為可以進行修改,而中間過程很可能被其他人改過。
## 四、用原子引用類演示ABA問題
`AtomicReference`:原子引用類
- 1.首先我們需要定義一個積木類
```java
/**
積木類
* @author: 悟空聊架構
* @create: 2020-08-25
*/
class BuildingBlock {
String shape;
public BuildingBlock(String shape) {
this.shape = shape;
}
@Override
public String toString() {
return "BuildingBlock{" + "shape='" + shape + '}';
}
}
```
- 2.定義3個積木:三角形A,四邊形B,五邊形D
```java
static BuildingBlock A = new BuildingBlock("三角形");
// 初始化一個積木物件B,形狀為四邊形
static BuildingBlock B = new BuildingBlock("四邊形");
// 初始化一個積木物件D,形狀為五邊形
static BuildingBlock D = new BuildingBlock("五邊形");
```
- 初始化原子引用類
```
static AtomicReference atomicReference = new AtomicReference<>(A);
```
- 4.執行緒“乙”執行ABA操作
```java
new Thread(() -> {// 初始化一個積木物件A,形狀為三角形
atomicReference.compareAndSet(A, B); // A->B
atomicReference.compareAndSet(B, A); // B->A
},
```
- 5.執行緒“甲”執行比較並替換
``` java
new Thread(() -> {// 初始化一個積木物件A,形狀為三角形
try {
// 睡眠一秒,保證t1執行緒,完成了ABA操作
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 可以替換成功,因為乙執行緒執行了A->B->A,形狀沒變,所以甲可以進行替換。
System.out.println(atomicReference.compareAndSet(A, D) + "\t" + atomicReference.get()); // true BuildingBlock{shape='五邊形}
}, "甲").start();
```
**輸出結果:**true BuildingBlock{shape='五邊形}
**小結:**當執行緒“乙”執行ABA之後,執行緒“甲”比較後,發現預期值和當前值一致,將三角形替換成了五邊形。
## 五、那ABA到底有什麼危害?
我們看到乙不管怎麼進行操作,甲看到的還是三角形,那甲當成乙沒有改變積木形狀 又有什麼問題呢?
出現的問題場景通常是帶有消耗類的場景,比如庫存減少,商品賣出。
### 1.我們想象一下生活中的這個喝水場景:
![ABA喝水場景](http://cdn.jayh.club/blog/20200825/GtyE8wQAad1Y.png?imageslim)
(1)一家三口人,爸爸、媽媽、兒子。
(2)一天早上6點,媽媽給兒子的水杯灌滿了水(水量為A),兒子先喝了一半(水量變成B)。
(3)然後媽媽把水杯又灌滿了(水量為A),等中午再喝(媽媽執行了一個ABA操作)。
(4)爸爸7點看到水杯還是滿的(不知道是媽媽又灌滿的),於是給兒子喝了1/3(水量變成D)
(5)那在中午之前,兒子喝了1/2+1/3= 5/6的水,這不是媽媽期望的,因為媽媽只想讓兒子中午之前喝半杯水。
這個場景的ABA問題帶來的後果就是本來只用喝1/2的水,結果喝了5/6的水。
### 2.我們再想象一下電商中的場景
(1)商品Y的庫存是10(A)
(2)使用者m購買了5件(B)
(3)運營人員乙補貨5件(A)(乙執行了一個ABA操作)
(4)運營人員甲看到庫存還是10,就認為一件也沒有賣出去(不考慮交易記錄),其實已經賣出去了5件。
**那我們怎麼解決原子引用的問題呢?**
可以用加版本號的方式來解決兩個A相同的問題,比如上面的積木案例,我們可以給兩個三角形都打上一個版本號的標籤,如A1和A2,在第六步中,形狀和版本號一致甲才可以進行替換,因形狀都是三角形,而版本號一個1,一個是2,所以不能進行替換。
![ABA問題的解決方案](http://cdn.jayh.club/blog/20200825/kv9iMSh6GdN5.png?imageslim)
在Java程式碼中,我們可以用原子時間戳引用型別:`AtomicStampedReference`
## 六、帶版本號的原子引用型別
### 1.我們看一看這個原子類`AtomicStampedReference`的底層程式碼
比較並替換方法`compareAndSet`
```java
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
```
`expectedReference`:期望值
`newReference`:替換值
`expectedStamp`:期望版本號
`newStamp`:替換版本號
先比較期望值expectedReference和當前值是否相等,以及期望版本號和當前版本號是否相等,如果兩者都相等,則表示沒有被修改過,可以進行替換。
### 2.如何使用AtomicStampedReference?
![程式碼示例的原理圖](http://cdn.jayh.club/blog/20200825/yH6eiQtp089J.png?imageslim)
**(1)先定義3個積木:三角形A,四邊形B,五邊形D**
```java
// 初始化一個積木物件A,形狀為三角形
BuildingBlock A = new BuildingBlock("三角形");
// 初始化一個積木物件B,形狀為四邊形,乙會將三角形替換成四邊形
BuildingBlock B = new BuildingBlock("四邊形");
// 初始化一個積木物件B,形狀為四邊形,乙會將三邊形替換成五邊形
BuildingBlock D = new BuildingBlock("五邊形");
```
**(2)建立一個原子引用型別的例項 atomicReference**
``` java
// 傳遞兩個值,一個是初始值,一個是初始版本號
AtomicStampedReference atomicStampedReference = new AtomicStampedReference<>(A, 1);
```
**(3)建立一個執行緒“乙”執行ABA操作**
```java
new Thread(() -> {
// 獲取版本號
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t 第一次版本號" + stamp);
// 暫停執行緒“乙”1秒鐘,使執行緒“甲”可以獲取到原子引用的版本號
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
/*
* 乙執行緒開始ABA替換
* */
// 1.比較並替換,傳入4個值,期望值A,更新值B,期望版本號,更新版本號
atomicStampedReference.compareAndSet(A, B, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t 第二次版本號" + atomicStampedReference.getStamp()); //乙 第一次版本號1
// 2.比較並替換,傳入4個值,期望值B,更新值A,期望版本號,更新版本號
atomicStampedReference.compareAndSet(B, A, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1); // 乙 第二次版本號2
System.out.println(Thread.currentThread().getName() + "\t 第三次版本號" + atomicStampedReference.getStamp()); // 乙 第三次版本號3
}, "乙").start();
```
1)乙先獲取原子類的版本號,第一次獲取到的版本號為1
2)暫停執行緒“乙”1秒鐘,使執行緒“甲”可以獲取到原子引用的版本號
3)比較並替換,傳入4個值,期望值A,更新值B,期望版本號stamp,更新版本號stamp+1。A被替換為B,當前版本號為2
4)比較並替換,傳入4個值,期望值B,更新值A,期望版本號getStamp(),更新版本號getStamp()+1。B替換為A,當前版本號為3
**(4)建立一個執行緒“甲”執行D替換A操作**
```java
new Thread(() -> {
// 獲取版本號
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t 第一次版本號" + stamp); // 甲 第一次版本號1
// 暫停執行緒“甲”3秒鐘,使執行緒“乙”進行一次ABA替換操作
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = atomicStampedReference.compareAndSet(A,D,stamp,stamp + 1);
System.out.println(Thread.currentThread().getName() + "\t 修改成功否" + result + "\t 當前最新實際版本號:" + atomicStampedReference.getStamp()); // 甲 修改成功否false 當前最新實際版本號:3
System.out.println(Thread.currentThread().getName() + "\t 當前實際最新值:" + atomicStampedReference.getReference()); // 甲 當前實際最新值:BuildingBlock{shape='三角形}
}, "甲").start();
```
(1)甲先獲取原子類的版本號,版本號為1,因為乙執行緒還未執行ABA,所以甲獲取到的版本號和乙獲取到的版本號一致。
(2)暫停執行緒“甲”3秒鐘,使執行緒“乙”進行一次ABA替換操作
(3)乙執行完ABA操作後,執行緒甲執行比較替換,期望為A,實際是A,版本號期望值是1,實際版本號是3
(4)雖然期望值和實際值都是A,但是版本號不一致,所以甲不能將A替換成D,這個就避免了ABA的問題。
**小結:** 帶版本號的原子引用類可以利用CAS+版本號來比較變數是否被修改。
## 總結
本篇分析了ABA產生的原因,然後又列舉了生活中的兩個案例來分析ABA的危害。然後提出了怎麼解決ABA問題:用帶版本號的原子引用類AtomicStampedReference。
限於篇幅和側重點,CAS的優化並沒有涉及到,後續再倒騰這一塊吧。另外AtomicStampedReference的缺點本篇本沒有進行講解,限於筆者的技術水平原因,並沒有一一作答,期待後續能補上這一塊的解答。
我是悟空,一隻努力變強的碼農!我要變身超級賽亞人啦!
![悟空](http://cdn.jayh.club/blog/20200821/wp5kwn5UEWaJ.png?imageslim)
> 另外可以搜尋「悟空聊架構」或者PassJava666,一起進步!
> 我的[GitHub主頁](https://github.com/Jackson0714),關注我的`Spring Cloud` 實戰專案[《佳必過》](https://github.com/Jackson0714/PassJava-Platform)
# 公眾號
![mark](http://cdn.jayh.club/blog/20200404/GU60Sv47XT7J.png?im