Java中多線程的ABA場景問題分析
前言
本文是筆者在日常開發(fā)過程中遇到的對 CAS 、 ABA 問題以及 JUC(java.util.concurrent)中 AtomicReference 相關(guān)類的設(shè)計的一些思考記錄。 對需要處理 ABA 問題,或有諸如筆者一樣的設(shè)計疑問探索好奇心的讀者可能會帶來一些啟發(fā)。
本文主體由三部分構(gòu)成:
- 首先闡述多線程場景數(shù)據(jù)同步的常用語言工具
- 接著闡述什么是 ABA 問題,以及產(chǎn)生的原因和可能帶來的影響
- 再探索 JUC 中官方為解決 ABA 問題而做一些工具類設(shè)計
文章的最后會對多線程數(shù)據(jù)同步常用解決方案做了簡短地經(jīng)驗性總結(jié)與概括。
受限于筆者的理解與知識水平,文章的一些術(shù)語表述難免可能會失偏頗,對于有理解歧義或爭議的部分,歡迎大家探討和指正。
一、異步場景常用工具
在Java中的多線程數(shù)據(jù)同步的場景,常會出現(xiàn):
- 關(guān)鍵字
volatile - 關(guān)鍵字
synchronized - 可重入鎖/讀寫鎖
java.util.concurrent.locks.* - 容器同步包裝,如
Collections.synchronizedXxx() - 新的線程安全容器,如
CopyOnWriteArrayList/ConcurrentHashMap - 阻塞隊列
java.util.concurrent.BlockingQueue - 原子類
java.util.concurrent.atomic.* - 以及 JUC 中其他工具諸如
CountDownLatch/Exchanger/FutureTask等角色。
其中 volatile 關(guān)鍵字用于刷新數(shù)據(jù)緩存,即保證在 A 線程修改某數(shù)據(jù)后,B 線程中可見,這里面涉及的線程緩存和指令重排因篇幅原因不在本文探討范圍之內(nèi)。而不論是 synchronized 關(guān)鍵字下的對象鎖,還是基于同步器 AbstractQueuedSynchronizer 的 Lock 實現(xiàn)者們,它們都屬于悲觀鎖。而在同步容器包裝、新的線程程安全容器和阻塞隊列中都使用的是悲觀鎖;只是各類的內(nèi)部使用不同的 Lock 實現(xiàn)類和 JUC 工具,另外不同容器在加鎖粒度和加鎖策略上分別做了處理和優(yōu)化。
這里值得一說的,也是本文聚焦的重點則是原子類,即 java.util.concurrent.atomic.* 包下的幾個類庫諸如 AtomicBoolean/AtomicInteger/AtomicReference
二、CAS 與 ABA 問題
我們知道在使用悲觀鎖的場景中,如果有有一個線程搶先取得了鎖,那么其他想要獲得鎖的線程就得被阻塞等待,直到占鎖線程完成計算釋放鎖資源。而現(xiàn)代 CPU 提供了硬件級指令來實現(xiàn)同步原語,也就是說可以讓線程在運行過程中檢測是否有其他線程也在對同一塊內(nèi)存進行讀寫,基于此 Java 提供了使用忙循環(huán)來取代阻塞的系列工具類 AutomicXxx,這屬于是一種樂觀鎖的實現(xiàn)。其常規(guī)使用方式形如:
public class Requester {
private AtomicBoolean isRequesting = new AtomicBoolean(false)
public void request() {
// 修改成功時返回true;compareAndSet 方法由 Native 層調(diào)硬件指令實現(xiàn)
if (!isRequesting.compareAndSet(false, true)) {
return;
}
try {
// do sth...
} finally {
isRequesting.set(false)
}
}
}
進入到 JDK11 AtomicBoolean 的源碼中,可以看到 compareAndSet 最終調(diào)用 Native 層的方式如下。其實在舊的版本中 JDK 是使用 Unsafe 類處理的,在入?yún)?shù)中有傳入狀態(tài)變量的字段偏移值,新版本則將兩者封裝到 VarHandle 中采用DL方式查找依賴(筆者猜測可能和JDK9模塊化改造有關(guān)):
// 舊版
public class AtomicBoolean {
private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
private static final long VALUE;
static {
try {
VALUE = U.objectFieldOffset
(AtomicBoolean.class.getDeclaredField("value"));
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
private volatile int value;
public final boolean compareAndSet(boolean expect, boolean update) {
return U.compareAndSwapInt(this, VALUE, (expect ? 1 : 0), (update ? 1 : 0));
}
}
// 新版
public class AtomicBoolean {
private static final VarHandle VALUE;
static {
try {
MethodHandles.Lookup l = MethodHandles.lookup();
VALUE = l.findVarHandle(AtomicBoolean.class, "value", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private volatile int value;
public final boolean compareAndSet(boolean expectedValue, boolean newValue) {
return VALUE.compareAndSet(this, (expectedValue ? 1 : 0), (newValue ? 1 : 0));
}
}
猶如入倉有 this 和 value 的偏移值,則 Native 層可根據(jù)此二者值定位到某塊棧內(nèi)存,這樣對于基本類型沒什么問題。原子類型體系中使用 AtomicReference 來引用復(fù)合類型實例,但 Java 中 Object 類型在棧中保存的只是堆中對象數(shù)據(jù)塊的地址,其結(jié)構(gòu)形如下圖:

而實際運行過程中,調(diào)用 AtomicReference#compareAndSet() 時,Native層只會對比棧中內(nèi)存的值,而不會關(guān)注其指向的堆中數(shù)據(jù)。這樣說可能有點抽象,看一段實驗代碼:
StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");
AtomicReference<StringBuilder> ref = new AtomicReference<>(varA);
ref.compareAndSet(varA, varB); // (1)
System.out.println(ref.get()); // (2) varB->123
varB.append('4'); // (3) changed varB->1234
if (ref.compareAndSet(varB, varA)) { // (4)
System.out.println("CAS succeed"); // (5) CAS succeed
}
System.out.println(ref.get()); // abc
喜歡動手的讀者可以嘗試自定義一個類,觀察下 Compare 過程是否真的沒有調(diào)用對象的 equals 方法。
ref 在經(jīng)過處理后再 (2) 處引用變量B,而在注釋 (3) 處將 B 值修改了,但由于原子類不會檢查堆中數(shù)據(jù),所以還是能通過注釋 (4) 處的相等比較走到注釋 (5) 。這也就引入了 所謂的 ABA 問題:
- 假設(shè),線程 1 的任務(wù)希望將變量從 A 變?yōu)?C ,但執(zhí)行到一半被線程 2 搶走 CPU
- 線程 2 將變量從 A 改成了 B ,此時 CPU 時間片又被系統(tǒng)分給了線程 3
- 線程 3 講變量從 B 又設(shè)置成一個新的 A 。
- 線程 1 獲取時間片,檢查變量發(fā)現(xiàn)其仍然是 A(但 A 對象內(nèi)部的數(shù)據(jù)已經(jīng)改變了),檢查通過將變量置為 C 。
若業(yè)務(wù)場景中,線程 1 不在意變量經(jīng)過了一輪變化,也不在意 A 中數(shù)據(jù)是否有變化,則該問題無關(guān)痛癢。而若線程 1 對這兩個變化敏感,則將變量置為 C 的操作就不符合預(yù)期了。用維基百科的例子來表述,其大意是:
你提著有很多現(xiàn)金的包去機場,這時來了個辣妹挑 逗你,并趁你不注意時用一個看起來一樣的空包換了你的現(xiàn)金包,然后她就走了;此時你檢查了下發(fā)現(xiàn)你的包還在,于是就匆忙拿著包趕飛機去了。
換個角度看這幾個關(guān)鍵字:
- 有現(xiàn)金的包:指向堆中數(shù)據(jù)的棧引用
- 辣妹挑 逗:其他線程搶占 CPU
- 看起來一樣空包:其他線程修改堆中數(shù)據(jù)
- 發(fā)現(xiàn)包還在:僅檢查棧中內(nèi)存的地址值是否一致
三、用 JUC 工具處理 ABA 問題
為處理 ABA 問題,JDK 提供了另外兩個工具類:AtomicMarkableReference 和 AtomicStampedReference 他們除了對比棧中對象的引用地址外,另外還保存了一個 boolean 或 int 類型的標(biāo)記值,用于 CAS 比較。
StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");
AtomicStampedReference<StringBuilder> ref = new AtomicStampedReference<>(varA, varA.toString().hashCode());
ref.compareAndSet(varA, varB, varA.toString().hashCode(), varB.toString().hashCode());
System.out.println(ref.get(new int[1]));
varB.append('4');
// CAS失敗,因為Stamp值對不上
if (ref.compareAndSet(varB, varA, varB.toString().hashCode(), varA.toString().hashCode())) {
System.out.println("compareAndSet: succeed");
}
System.out.println(ref.get(new int[1]));
注:這種設(shè)計和為快速判斷文件是否相同,而比較文件摘要值(MD5、SHA值)和預(yù)期是否一致的思想倒有異曲同工之妙。
總結(jié)
通常在多線程場景中,這些工具的應(yīng)用場景具有各自的適用特征:
- 若各線程讀寫數(shù)據(jù)沒有競爭關(guān)系,則可考慮僅使用
volatile關(guān)鍵字; - 若各線程對某數(shù)據(jù)的讀寫需要去重,則可優(yōu)先考慮使用樂觀鎖實現(xiàn),即用原子類型;
- 若各線程有競爭關(guān)系且不去重必須按順序搶占某資源,即必須用鎖阻塞,若沒有多條件隊列的訴求則可先考慮使用
synchronized添加對象鎖(但需注意鎖對象的不可變和私有化),否則考慮用Lock實現(xiàn)類,但特別的如需讀寫分鎖以實現(xiàn)共享鎖則只能用Lock了。 - 若需使用線程安全容器,出于性能考慮優(yōu)先考慮
java.util.concurrent.*類,如ConcurrentHashMap、CopyOnWriteArrayList;再考慮使用容器同步包裝Collections.synchronizedXxx()。而阻塞隊列則多用于生產(chǎn)-消費模型中的任務(wù)容器,典型如用在線程池中。
以上就是Java中多線程的ABA場景問題分析的詳細內(nèi)容,更多關(guān)于Java 多線程ABA分析的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
SpringBoot CountDownLatch多任務(wù)并行處理的實現(xiàn)方法
本篇文章主要介紹了SpringBoot CountDownLatch多任務(wù)并行處理的實現(xiàn)方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-04-04
SpringBoot項目啟動時增加自定義Banner的簡單方法
最近看到springboot可以自定義啟動時的banner,然后自己試了一下,下面這篇文章主要給大家介紹了SpringBoot項目啟動時增加自定義Banner的簡單方法,需要的朋友可以參考下2022-01-01
java8 stream sort自定義復(fù)雜排序案例
這篇文章主要介紹了java8 stream sort自定義復(fù)雜排序案例,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10
java中循環(huán)刪除list中元素的方法總結(jié)
下面小編就為大家?guī)硪黄猨ava中循環(huán)刪除list中元素的方法總結(jié)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2016-12-12
spring security獲取用戶信息為null或者串值的解決
這篇文章主要介紹了spring security獲取用戶信息為null或者串值的解決,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-03-03

