前言
《阿里巴巴 Java開發手冊》讀後感—擁抱規範,遠離傷害:https://blog.csdn.net/f641385712/article/details/84930279
寫這篇博文的原因,是因爲我今天在看阿里的規範手冊的時候(記錄在了這裏:《阿里巴巴 Java開發手冊》讀後感—擁抱規範,遠離傷害),發現了有一句規範是這麼寫的:
如果是count++操作,使用如下類實現: AtomicInteger count = new AtomicInteger(); count.addAndGet(1);如果是 JDK8,推薦使用 LongAdder 對象,比 AtomicLong 性能更好(減少樂觀鎖的重試次數)。
這裏面提到了Atomic系列來進行原子操作。之前我在各個地方使用過AtomicInteger很多次,但一直沒有做一個系統性的瞭解和做筆記。因此本此恰藉此機會,把這塊的知識點好好梳理一下, 並希望在學習的過程中解決掉問題
簡單例子鋪墊
廢話不多說,展示代碼:
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
Count count = new Count();
// 100個線程對共享變量進行加1
for (int i = 0; i < 100; i++) {
service.execute(() -> count.increase());
}
// 等待上述的線程執行完 和三個方法的區別 這裏不做概述,反正都能關閉
service.shutdown();
//service.shutdownNow();
service.awaitTermination(2, TimeUnit.SECONDS);
service.shutdown();
System.out.println(count.getCount());
}
//計數類
private static class Count {
// 共享變量
private Integer count = 0;
public Integer getCount() {
return count;
}
public void increase() {
count++;
}
}
你們猜猜執行的結果會是多少?是100嗎?
我相信稍微基礎好一點的,或者說遇見過類似問題的,答案都是No吧。我執行了多次,結果是不確定的:29、69、48、99都有。。。
(備註:類似的方案,有時候可以通過volatile關鍵字,此處不對此關鍵字做過多的討論,它是一種內存可見性方案,並不是真正意義上的鎖喲)
根據結果我們得知:上面的代碼是線程不安全的!如果線程安全的代碼,多次執行的結果是一致的!
原因分析
什麼上述的結果不確定呢?我們可以發現問題所在:**count++並不是原子操作。**因爲count++需要經過讀取-修改-寫入三個步驟。舉個例子還原一下真相:
如果某一個時刻:線程A讀到count的值是10,線程B讀到count的值也是10
線程A對count++,此時count的值爲11
線程B對count++,此時count的值也是11(因爲線程B讀到的count是10)
所以到這裏應該知道爲啥我們的結果是不確定了吧。
怎麼破?
要得出正確的結果100,怎麼辦?
synchronized
在increase()加synchronized鎖就好了:
public synchronized void increase() {
count++;
}
這樣子無論執行多少次,得出的都是100。這個對於只要求解決問題,但不在乎效率,不想深挖的人,肯定已經ok了。但是我們僅僅只是對於這麼簡單的一個++,就動用這麼"強悍的"Synchronized未免有點太小題大作了。
Synchronized悲觀鎖,是獨佔的,意味着如果有別的線程在執行,當前線程只能是等待!
那麼接下來針對我們頻繁碰到這個問題,JDK5提供的原子操作就要登場了
Atomic原子操作
在JDK1.5+的版本中,Doug Lea和他的團隊還爲我們提供了一套用於保證線程安全的原子操作。
JDK1.5的版本中爲我們提供了java.util.concurrent.atomic原子操作包。所謂“原子”操作,是指一組不可分割的操作:操作者對目標對象進行操作時,要麼完成所有操作後其他操作者才能操作;要麼這個操作者不能進行任何操作。
有了他們,我們就很好解決上面遇到的問題了,只需要採用AtomicInteger稍加改動就OK了~~
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
AtomicInteger count = new AtomicInteger();
// 100個線程對共享變量進行加1
for (int i = 0; i < 100; i++) {
service.execute(() -> count.incrementAndGet());
}
// 等待上述的線程執行完 和三個方法的區別 這裏不做概述,反正都能關閉
service.shutdown();
//service.shutdownNow();
service.awaitTermination(2, TimeUnit.SECONDS);
service.shutdown();
System.out.println(count.get());
}
改用Atomic來執行後,我們發現不管執行多少次,結果都是正確的100;
JDK1.5以後這種輕量級的解決方案不再推薦使用synchronized,而使用Atomic代替,因爲效率更高
源碼分析
AotmicInteger其實就是對int的包裝,然後裏面內部使用CAS算法來保證操作的原子性
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
可以看到,內部主要依賴於unsafe提供的CAS算法來實現的,因此我們很有必要了解一下,到底什麼是CAS呢?
CAS解釋
先概念走一波
比較並交換(compare and swap, CAS),是原子操作的一種,可用於在多線程編程中實現不被打斷的數據交換操作,從而避免多線程同時改寫某一數據時由於執行順序不確定性以及中斷的不可預知性產生的數據不一致問題。 該操作通過將內存中的值與指定數據進行比較,當數值一樣時將內存中的數據替換爲新的值。
從定義中我們可以總結出CAS有三個操作數:
內存值V
舊的預期值A
要修改的新值B
爲了方便大家理解也爲了我記憶深刻點,我特意自己嘗試着畫了一些圖解(下同):
可以發現CAS有兩種情況:
如果內存值V和我們的預期值A相等,則將內存值修改爲B,操作成功!
如果內存值V和我們的預期值A不相等,一般也有兩種情況:
1、重試(自旋) 2、什麼都不做
CAS失敗重試(自旋)
上面的例子,我們啓動的100個線程,實質上都對結果進行了+1。但是可以想象到,肯定存在多個線程同一時刻同時想+1的情況,因此可見下圖:
雖然這幅圖只畫了兩個線程的情況,舉一反三,任意多個線程的情況都是一樣的處理方式。
CAS失敗—什麼都不做
這個我就不再畫圖,說白了就是Z線程進來後,發現預期值和內存值不一樣的時候,就什麼都不做,就CAS失敗,直接結束掉線程了。這個有些場景也會這麼去幹
CAS爲什麼是原子的呢?
有的人可能會問:CAS明明就有多部操作,但什麼就是原子的呢?
解釋如下:
Unsafe底層實際上是調用C代碼,C代碼調用匯編,最後生成出一條CPU指令cmpxchg,完成操作。這也就爲啥CAS是原子性的,因爲它是一條CPU指令,不會被打斷。
CAS是原子性的,雖然你可能看到比較後再修改(compare and swap)覺得會有兩個操作,但終究是原子性的!
CAS帶來的ABA問題
什麼是ABA問題呢?結束上面的例子
線程A和線程C同時讀到count變量,所以線程A和線程C的內存值和預期值都爲10
此時線程A使用CAS將count值修改成100
修改完後,就在這時,線程B進來了(因爲CPU隨機,所以是有可能先執行B再執行C的),讀取得到count的值爲100(內存值和預期值都是100),將count值修改成10
線程C拿到執行權,發現內存值是10,預期值也是10,將count值修改成11
產生的問題是:線程C無法得知線程A和線程B修改過的count值,這樣是有風險的。,如下:
場景:蛋糕店回饋客戶,對於會員卡餘額小於20的客戶一次性贈送20,刺激消費,每個客戶只能贈送一次
public static void main(String[] args) {
//在這裏使用AtomicReference 裏面裝着用戶的餘額 初始卡餘額小於20
final AtomicReference<Integer> money = new AtomicReference<>(19);
//模擬一個生產者消費者模型
// 模擬多個線程更新數據庫,爲用戶充值
for (int i = 0; i < 3; i++) {
new Thread(() -> {
while (true) {
while (true) {
Integer m = money.get();
if (m < 20) {
if (money.compareAndSet(m, m + 20)) {
System.out.println("餘額小於20,充值成功。餘額:"
+ money.get() + "元");
break;
}
} else {
System.out.println("餘額大於20,無需充值!");
break;
}
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 用戶消費進程,模擬消費行爲
new Thread(() -> {
//在這裏的for循環,太快很容易看不到結果
for (int i = 0; i < 1000; i++) {
while (true) {
Integer m = money.get();
if (m > 10) {
System.out.println("大於10元");
if (money.compareAndSet(m, m - 10)) {
System.out.println("成功消費10,卡餘額:" + money.get());
break;
}
} else {
System.out.println("餘額不足!");
break;
}
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
輸出:
餘額小於20,充值成功。餘額:39元
餘額大於20,無需充值!
餘額大於20,無需充值!
大於10元
成功消費10,卡餘額:29
大於10元
成功消費10,卡餘額:19
大於10元
成功消費10,卡餘額:9
餘額小於20,充值成功。餘額:29元
餘額大於20,無需充值!
餘額大於20,無需充值!
大於10元
成功消費10,卡餘額:19
大於10元
成功消費10,卡餘額:9
餘額不足!
餘額大於20,無需充值!
餘額大於20,無需充值!
餘額小於20,充值成功。餘額:29元
我們看到,這個帳號先後反覆多次進行充值。,怎麼回事呢?
原因是帳戶餘額被反覆修改,修改後的值等於原來的值,使得CAS操作無法正確判斷當前的數據狀態。這在業務上是不允許的(只有高併發下才可能會出現哦,並不是說記錄下贈送次數就能簡單解決的哦)。
ABA問題如何解決
其實java也考慮到了這個問題,所以提供給予我們解決方案了
我們可以使用JDK給我們提供的AtomicStampedReference和AtomicMarkableReference類。
用代碼解決上面的充值問題:該動起來也是非常的簡單
public static void main(String[] args) {
//在這裏使用AtomicReference 裏面裝着用戶的餘額 初始卡餘額小於20
final AtomicStampedReference<Integer> money = new AtomicStampedReference<>(19, 0);
for (int i = 0; i < 3; i++) {
//拿到當前的版本號
final int timestamp = money.getStamp();
new Thread(() -> {
while (true) {
while (true) {
Integer m = money.getReference();
if (m < 20) {
//注意此處:timestamp版本號做了+1操作
if (money.compareAndSet(m, m + 20, timestamp,
timestamp + 1)) {
System.out.println("餘額小於20,充值成功。餘額:"
+ money.getReference() + "元");
break;
}
} else {
System.out.println("餘額大於20,無需充值!");
break;
}
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 用戶消費進程,模擬消費行爲
new Thread(() -> {
for (int i = 0; i < 100; i++) {
while (true) {
//拿到當前的版本號
int timestamp = money.getStamp();
Integer m = money.getReference();
if (m > 10) {
System.out.println("大於10元");
if (money.compareAndSet(m, m - 10, timestamp,
timestamp + 1)) {
System.out.println("成功消費10,卡餘額:"
+ money.getReference());
break;
}
} else {
System.out.println("餘額不足!");
break;
}
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
運行看輸出結果爲:
餘額小於20,充值成功。餘額:39元
餘額大於20,無需充值!
餘額大於20,無需充值!
大於10元
成功消費10,卡餘額:29
大於10元
成功消費10,卡餘額:19
大於10元
成功消費10,卡餘額:9
餘額不足!
餘額不足!
餘額不足!
餘額不足!
我們發現,只爲他充值了一次,之後一直消費都是餘額不足的狀態了。因此當高併發又可能存在ABA的情況下,這樣就能徹底杜絕問題了
簡單來說就是在給爲這個對象提供了一個版本,並且這個版本如果被修改了,是自動更新的。原理大概就是:維護了一個Pair對象,Pair對象存儲我們的對象引用和一個stamp值。每次CAS比較的是兩個Pair對象
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
Atomic原子變量類的使用
java.util.concurrent.atomic原子操作包爲我們提供了四類原子操作:
提供類如下截圖:
原子更新基本類型
AtomicBoolean:布爾型
AtomicInteger:整型
AtomicLong:長整型
原子更新數組
AtomicIntegerArray:數組裏的整型
AtomicLongArray:數組裏的長整型
AtomicReferenceArray:數組裏的引用類型
原子更新引用
AtomicReference<V>:引用類型
AtomicStampedReference:帶有版本號的引用類型(可以防止ABA問題)
AtomicMarkableReference:帶有標記位的引用類型
原子更新字段
AtomicIntegerFieldUpdater:對象的屬性是整型
AtomicLongFieldUpdater:對象的屬性是長整型
AtomicReferenceFieldUpdater:對象的屬性是引用類型
JDK8新增
DoubleAccumulator、LongAccumulator、
DoubleAdder、LongAdder
是對AtomicLong等類的改進。比如LongAccumulator與LongAdder在高併發環境下比AtomicLong更高效。
原子更新基本類型
這個使用案例就略了,相信大家再使用他們已經0阻礙了
原子更新數組
當你操作的共享是個數組的話,就可以用這個很方便解決問題了
public static void main(String[] args) {
AtomicIntegerArray atomicArray = new AtomicIntegerArray(5);
// 設置指定索引位的數值
atomicArray.set(0, 5);
// 也可以通過以下方法設置 (實際上默認值爲0,這裏加了5)
// atomicArray.addAndGet(0, 5);
// -- 0表示角標
int current = atomicArray.decrementAndGet(0);
System.out.println("current = " + current);
}
get(int i):獲取數組指定位置的值,並不會改變原來的值
set(int i, int newValue):爲數組指定索引位設置一個新值
getAndSet(int i, int newValue):獲取數組指定位置的原始值後,用newValue這個新值進行覆蓋。
getAndAdd(int i, int delta):獲取數組指定索引位的原始值後,爲數組指定索引位的值增加delta。那麼還有個類似的操作爲:addAndGet。
incrementAndGet、decrementAndGet
原子更新引用
使用場景:上面ABA問題有一個非常經典例子,請參加上面
若有類似的使用場景,用對應來存儲數據,那麼使用這個會非常的方便。例子其實非常簡單,這裏就不貼出來了,主要介紹一些幾個常用的API方法吧:
get()
compareAndSet(V expect, V update):如果當前值與給定的expect相等,(注意是引用相等而不是equals()相等),更新爲指定的update值。
.getAndSet(V newValue):原子地設爲給定值並返回舊值。
set(V newValue):不管三七二十一,直接把內存裏值設置爲此值。
原子更新字段
這個可以算是原子更新引用更新引用的一個很好補充。上面根性我們只能全量更新,並且對象的地址都完全變化了。比如我們要更新一個學生的成績,如果你new一個帶有新成績的Student進來,那就相當於Student對象都變了,顯然是不符合我們要求的。
因此java提供了我們針對字段的跟新的原子操作,可謂是一個很好的補充。
當然啦,它使用起來還是稍微有點麻煩的,它是基於反射實現,該字段還不能是private的,且必須被volatile 修飾。
這個在業務上幾乎涉及不到,但是在我們框架設計行,還是有可能被適用到的。比如我們內部定義一顆樹,可以設計爲:
private volatile Node left, right;
1
因爲使用極少,因此有興趣的朋友可以自己去玩玩,這裏就略過吧
JDK8新增
DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder
今天看到阿里巴巴的手冊裏面說 ,如果你使用的JDK8和以上版本,建議使用LongAdder代替AotmicLong
受限於文章篇幅,關於他們的使用以及和LongAdder和AotmicLong的性能測試對比,請移步這篇博文專門講解:【小家java】AtomicLong可以拋棄了,請使用LongAdder代替(或使用LongAccumulator)
悲觀鎖和樂觀鎖(Java都提供了對應支持)
爲了更好的理解上面的一些操作原理,本文有必要稍帶講解一些悲觀鎖和樂觀鎖的概念以及區別
在本文講解悲觀鎖和樂觀鎖,主要代表是synchronized和CAS的區別
悲觀鎖
悲觀鎖是一種獨佔鎖,它假設的前提是“衝突一定會發生”,所以處理某段可能出現數據衝突的代碼時,這個代碼段就要被某個線程獨佔。而獨佔意味着“其它即將執行這段代碼的其他線程”都將進入“阻塞”/“掛起”狀態。
synchronized關鍵字就是java對於悲觀鎖的實現。
由於悲觀鎖的影響下,其他線程都將進入 阻塞/掛起 狀態。而我們知道,CPU執行線程狀態切換是要耗費相當資源的,這主要涉及到CPU寄存器的操作。所以悲觀鎖在性能上不會有太多驚豔的表現(但是一般也不至於成爲性能瓶頸,所以各位也不要一棒子打死)
樂觀鎖
樂觀鎖假定“衝突不一定會出現”,如果出現衝突則進行重試,直到衝突消失。 由於樂觀鎖的假定條件,所以樂觀鎖不會獨佔資源,性能自然在**多數情況下**就會好於悲觀鎖。
AtomicInteger是一個標準的樂觀鎖實現,sun.misc.Unsafe是JDK提供的樂觀鎖的支持。
public final long getAndAddLong(Object var1, long var2, long var4) {
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
爲什麼是多數情況呢?因爲一旦多線程對某個資源的搶佔頻度達到了某種規模,就會導致樂觀鎖內部出現多次更新失敗的情況,最終造成樂觀鎖內部進入一種“活鎖”狀態。這時樂觀鎖的性能反而沒有悲觀鎖好。
如果我們很好的理解了樂觀鎖,並且能很熟練的應用的話,我們可以把它運用到我們項目了,幫助改善性能,比一遇到併發問題就去使用悲觀鎖的選手,顯得更加的NB轟轟了有木有
————————————————
原文鏈接:https://blog.csdn.net/f641385712/article/details/84933751