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

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

