Java面試題~從一個線程并發(fā)安全場景談談Java的鎖、CAS算法以及ABA問題
作者:
修羅debug
版權聲明:本文為博主原創(chuàng)文章,遵循 CC 4.0 by-sa 版權協(xié)議,轉載請附上原文出處鏈接和本聲明。
摘要:
對于“并發(fā)安全”,想必各位小伙伴都有所耳聞,它指的是“系統(tǒng)中某一時刻多個線程并發(fā)訪問一段或者一個有線程安全性問題的代碼、方法后,最終出現(xiàn)不正確的結果或者并非最初所預料的結果”。
有問題并有對應的解決方案,而“并發(fā)安全性問題”對應的解決方案也無非是根據(jù)項目所處的環(huán)境而采取相應的措施,如單體環(huán)境下采用加synchronized同步鎖、原子操作鎖atomicXXX 以及 lock等方式;分布式環(huán)境下采取分布式鎖(基于Redis、ZooKeeper、Redisson…)等方式加以解決。
本文我們將以一個簡單的場景:“模擬 網(wǎng)站訪問的計數(shù)統(tǒng)計”為案例,介紹在多線程并發(fā)的場景下出現(xiàn)的結果及其相應的解決方案,包括synchronized、atomicXXX、lock以及介紹CAS算法和ABA相關的問題。
內(nèi)容:
所謂的“網(wǎng)站訪問計數(shù)器”,其實也很好理解,就是統(tǒng)計用戶在前端瀏覽器訪問網(wǎng)站的次數(shù),當然啦,在本文中,我們將盡量寫一個簡化版的“網(wǎng)站訪問計數(shù)器”,用于演示“并發(fā)安全” 出現(xiàn)的場景。廢話不多說,下面進入代碼實戰(zhàn)環(huán)節(jié)
一、 模擬“網(wǎng)站訪問計數(shù)器”代碼實戰(zhàn)
(1)如下代碼所示,我們直接在類中編寫“訪問計數(shù)”的核心代碼(其實就是一個靜態(tài)變量的共享、數(shù)值疊加罷了):
public class WebAccessTotal {
//方式一
private static int total;
public void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//開啟5個線程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每個線程都讓total增200,模擬訪問 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正確的情況下會輸出1000
System.out.println(total);
}
}
在上述代碼中,我們采用了一個靜態(tài)變量total模擬 統(tǒng)計網(wǎng)站的訪問量(雖然粗糙了點~~~),右鍵點擊運行main方法,觀察控制臺輸出的結果,會發(fā)現(xiàn)其結果竟然并非我們所預料的1000,而是 < 1000,如下圖所示,輸出結果竟然是 412,有點令人大跌眼鏡:
(2)不過,這個結果卻又在情理之中,因為incrementTotal() 方法中的這段代碼:total++在多線程并發(fā)訪問時存在安全性 的問題,具體原因在于 ++ 操作將會被拆分為多個步驟執(zhí)行,即:int tmp=total+1; total=tmp; 兩個步驟:
假設total的初始值為 8,當并發(fā)的兩個線程A,B同時調(diào)用 incrementTotal() 方法時,此時很有可能 A.total=8 ,B.total=8,在執(zhí)行完該方法之后,A.tmp=9,B.tmp=9,最終該方法的輸出結果為:9,而實際上其輸出結果應該為:10(因為調(diào)用了兩次哦)
而這個過程正是出現(xiàn)“線程不安全”的源頭,既然出現(xiàn)了問題,那么就應當想辦法進行解決,下面我們進入問題解決方案的實戰(zhàn)!
二、 基于Synchronized同步代碼塊 保證線程安全
(1)首先,我們先采用 Synchronized 關鍵字同步代碼塊,試試其最終輸出的結果,其源代碼如下所示:
//方式二
private static int total;
public synchronized void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//開啟5個線程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每個線程都讓total增200,模擬訪問 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正確的情況下會輸出1000
System.out.println("輸出結果:"+total);
}
點擊運行,查看控制臺的輸出結果,會發(fā)現(xiàn)結果如我們所料,正是 1000 ,不多也不少:
(2)而加了 Synchronized 關鍵字 之所以能起到這種效果,主要在于它限定了瞬時訪問該方法時多個線程的“同步操作”,即當并發(fā)的多個線程要想訪問該方法時,需要獲取得到該方法的“鎖”,只有獲取到了“鎖”才能執(zhí)行方法里面的代碼邏輯,而同一時刻也就只能有一個線程可以獲取得到(其他沒獲取到的需要堵塞式等待),從而也就導致了 total++ 變成了 一個 “原子性操作”,即要么都做了,要買都不做。
對于 Synchronized 關鍵字,我們都知道它是 Java內(nèi)置的鎖,屬于悲觀、同步鎖的一種,其加鎖的粒度是相當粗的,在高并發(fā)場景下其性能也不是很好。
三、 基于JUC的Lock鎖和原子操作類AtoimicXX保證線程安全
(1)下面,我們基于JUC里面的Lock鎖和原子操作類AtomicXXX 為上述方法加“鎖”,看看是否可以保證線程訪問的安全。
(2)首先是JUC開發(fā)工具包下的Lock接口及其實現(xiàn)類,其完整的代碼如下所示:
//方式四
private static Lock lock=new ReentrantLock();
private static int total;
public void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//開啟5個線程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每個線程都讓total增200,模擬訪問 200次
for (int j = 0; j < 200; j++) {
//獲取鎖-執(zhí)行業(yè)務邏輯-釋放鎖
lock.lock();
testCount.incrementTotal();
lock.unlock();
}
}).start();
}
sleep(2000);
//正確的情況下會輸出1000
System.out.println("輸出結果:"+total);
}
而使用原子操作類AtoimicXX保證多個線程并發(fā)訪問“共享的代碼塊”的完整源碼如下所示:
//方式三
private static AtomicInteger total=new AtomicInteger(0);
public void incrementTotal(){
total.getAndIncrement();
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//開啟5個線程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每個線程都讓total增200,模擬訪問 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正確的情況下會輸出1000
System.out.println("輸出結果:"+total);
}
點擊運行代碼,會發(fā)現(xiàn)其輸出結果均為:1000,說明這兩種方式都可以實現(xiàn)線程并發(fā)訪問時的安全性;
Lock是java里面juc包下的接口,其實現(xiàn)類有多種,如ReadLock、WriteLock、ReentrantLock,相對于synchronized而言,Lock的加鎖和解鎖需要配對出現(xiàn),即需要手動釋放鎖,如果沒有及時解鎖(釋放鎖),那很可能會發(fā)生死鎖;
除此之外,synchronized是一種同步堵塞鎖,當并發(fā)時的某個線程獲取不到鎖時,它將永久等待下去,而Lock可以通過lock.tryLock();嘗試獲取鎖,如果嘗試獲取不到鎖,可以結束等待;另外,synchronized具有可重入、不可中斷、非公平的特性,而Lock鎖具有可重入、可以判斷、可以公平?。ü剑壕€程必須排隊,必須先來后到;非公平:線程可以插隊)
下面,我們重點分析一下JUC下的原子操作類AtomicXX為何也可以實現(xiàn)多線程并發(fā)訪問時保證“線程安全性”問題!
在介紹原理之前,先來啰嗦一下“原子操作類”是什么玩意,所謂的“原子操作類”,它指的是JUC包下一系列以Atomic開頭的包裝類,如AtomicBoolean,AtomicInteger,AtomicLong,它們分別用于Boolean,Integer,Long類型的原子性操作,每個原子操作類內(nèi)部都采取了CAS算法來保證線程安全性,那啥叫“CAS算法”呢?
四、CAS算法介紹
(1)CAS,是“Compare And Swap”的縮寫,中文簡稱就是“比較并替換”,在CAS算法中,它使用了3個基本操作數(shù):內(nèi)存地址對應的值V,舊的預期值A(舊值),要修改的新值B(新值),
當且僅當預期值A和內(nèi)存值V相同時,才將內(nèi)存值修改為B,否則什么都不做,最后返回現(xiàn)在的V值。
簡單的理解就是:“我認為V的值應該是A,如果是A的話我就把他改成B,如果不是A的話(那就證明被別人修改過了),那我就不修改了,避免多人 同時修改導致數(shù)據(jù)出錯,換句話說:要想修改成功,必須保證A和V中的值是一樣的,修改前有個對比的過程。”
(2)為了方便各位小伙伴理解,debug特意繪制了一個 CAS 算法底層的執(zhí)行邏輯(在這里仍然是以 ++ 操作為案例):
在上圖中,線程1首次更新失敗后,并不會立即放棄,而是重新獲取內(nèi)存中最新的值,然后再進行下一步的嘗試“提交更新”操作,直至成功,這個過程稱為 “自旋”,而CAS的實現(xiàn),底層其實是通過 volatile 關鍵字來實現(xiàn)的!
volatile 可以保證線程獲取的當前值是內(nèi)存中的最新值,即保證線程間的可見性!
值得一提的是,上圖CAS機制的執(zhí)行流程其實跟 debug在此之前講過的“分布式鎖實戰(zhàn)課程”中的樂觀鎖實現(xiàn)機制一毛一樣,只不過在那里我們是采用 “比較version” 的方式來實現(xiàn)樂觀鎖的代碼。
跟synchronized相比,synchronized屬于悲觀鎖,悲觀的認為程序中的并發(fā)情況很嚴重,所以要嚴防死守,在高并發(fā)情況下效率相當?shù)拖拢ㄒ驗槠浣档土怂矔r并發(fā)量)。
而CAS屬于樂觀鎖,樂觀的認為程序中的并發(fā)情況不那么嚴重,所以讓線程不斷地去重試更新,然后實際上synchronized已經(jīng)改造了,帶有鎖升級的功能,效率現(xiàn)如今也可以拼得過CAS。
(3)當然,有優(yōu)點,也就有缺點,下面羅列出CAS算法的幾條缺點:
A.CPU開銷可能過大
在并發(fā)比較大的時候,若多線程反復嘗試更新某個變量,卻又一直更新不成功,循環(huán)往復(自旋),會給CPU帶來很大的壓力
B.代碼塊的原子性
CAS機制所保證的只是一個變量的原子操作,而不能保證整個代碼塊的原子性,比如需要保證三個變量共同進行原子性的更新,就不得不使用synchronized或lock等機制了
C.ABA問題:下面簡單介紹一下
五、ABA問題詳解
(1)線程1準備通過CAS將變量的值由A替換為B,在此之前,線程2將變量的值由A替換為C、又由C替換為A。
(2)然后線程1執(zhí)行CAS時發(fā)現(xiàn)變量的值仍是A,所以CAS成功,這么看似乎沒啥問題,但是如果操作的對象是個鏈表的話,雖然值是一樣,但是鏈表的位置卻不一樣了,“當年的那個A已經(jīng)不是當時的那個A了” !
(3)深究其原因,其主要在于 我們在做 compare and swap 中的 swap 時僅僅只是比較了節(jié)點對象的內(nèi)存值。為了預防這種問題的發(fā)生,我們一般都是采用在swap時帶上 version版本號(而這其實就是樂觀鎖的實現(xiàn))。
因此,任何一種技術都有其兩面性存在,在用的很爽、沒問題的同時,也勢必會存在一些令人頭疼的問題。
對于ABA問題,JDK也提供了兩種方式加以解決,AtomicStampedReference、AtomicMarkableReference 便可以解決問題。至于這兩者底層的實現(xiàn)機制,咱們抽空再找個時間討論了!
總結:
本文開篇主要是通過一個“網(wǎng)站訪問計數(shù)器”的場景案例,介紹多線程并發(fā)訪問共享的代碼塊時所出現(xiàn)的“并發(fā)安全性”問題,緊接著我們先后采用了synchronize關鍵字、Lock鎖和原子操作類AtomicXX保證實現(xiàn)該共享代碼的“并發(fā)安全”,之后,我們還特意比較了synchronize關鍵字 和 其他兩者的區(qū)別,可以說 前者是 悲觀鎖的典型代表;而后兩者是 樂觀機制的 一些實現(xiàn)。
除此之外,我們還介紹了原子操作類 AtomicXX 底層實現(xiàn)機制,即CAS算法,而CAS算法之所以可以實現(xiàn),則在于 volatile 修飾了共享的變量,保證線程之間變量的可見性,核心偽代碼為 unsafe.compareAndSwapInt(V,A,B);當然啦,宇宙萬物向來都具有“陰陽兩極”兩種特性,AtomicXX 也不例外,其如果操作不當,將會帶來 ABA 的問題,而AtomicStampedReference、AtomicMarkableReference則可以解決這種問題(畢竟 天無絕人之路)
補充:
1、若想學習其他的技術干貨,可以前往Debug自建的技術社區(qū)進行學習觀看,包括技術專欄、博客和課程哦:https://www.fightjava.com/
2、關注一下Debug的技術微信公眾號,最新的技術文章、課程以及技術專欄將會第一時間在公眾號發(fā)布哦!