解析Java多線程之常見鎖策略與CAS中的ABA問題
本篇文章將介紹常見的鎖策略以及CAS中的ABA問題,前面介紹使用synchronized
關(guān)鍵字來保證線程的安全性,本質(zhì)上就是對對象進行加鎖操作,synchronized
所加的鎖到底是什么類型的鎖呢?本文帶你一探究竟。
??1.常見的鎖策略
??1.1樂觀鎖與悲觀鎖
樂觀鎖與悲觀鎖是從處理鎖沖突的態(tài)度方面來進行考量分類的。
- 樂觀鎖預(yù)期鎖沖突的概率很低,所以做的準備工作更少,付出更少,效率較高。
- 悲觀鎖預(yù)期鎖沖突的概率很高,所以做的準備工作更多,付出更多,效率較低。
??1.2讀寫鎖與普通互斥鎖
對于普通的互斥鎖只有兩個操作:
- 加鎖
- 解鎖
而對于讀寫鎖來說有三個操作:
- 加讀鎖,如果代碼僅進行讀操作,就加讀鎖。
- 加寫鎖,如果代碼含有寫操作,就加寫鎖。
- 解鎖。
針對讀鎖與讀鎖之間,是沒有互斥關(guān)系的,因為多線程中同時讀一個變量是線程安全的,針對讀鎖與寫鎖之間以及寫鎖與寫鎖之間,是存在互斥關(guān)系的。
在java中有讀寫鎖的標準類,位于java.util.concurrent.locks.ReentrantReadWriteLock
,其中ReentrantReadWriteLock.ReadLock
為讀鎖,ReentrantReadWriteLock.WriteLock
為寫鎖。
??1.3重量級鎖與輕量級鎖
這兩種類型的鎖與悲觀鎖樂觀鎖有一定的重疊,重量級鎖做的事情更多,開銷更大,輕量級鎖做的事情較少,開銷也就較少,在大部分情況下,可以將重量級鎖視為悲觀鎖,輕量級鎖視為樂觀鎖。
如果鎖的底層是基于內(nèi)核態(tài)實現(xiàn)的(比如調(diào)用了操作系統(tǒng)提供的mutex接口)此時一般認為是重量級鎖,如果是純用戶態(tài)實現(xiàn)的,一般認為是輕量級鎖。
??1.4掛起等待鎖與自旋鎖
掛起等待鎖表示當獲取鎖失敗之后,對應(yīng)的線程就要在內(nèi)核中掛起等待(放棄CPU,進入等待隊列),需要在鎖被釋放之后由操作系統(tǒng)喚醒,該類型的鎖是重量級鎖的典型實現(xiàn)。 自旋鎖表示在獲取鎖失敗后,不會立刻放棄CPU,而是快速頻繁的再次詢問鎖的持有狀態(tài)一旦鎖被釋放了,就能立刻獲取到鎖,該類型的鎖是輕量級鎖的典型實現(xiàn)。
??掛起等待鎖與自旋鎖的區(qū)別
- 最明顯的區(qū)別就是,掛起等待鎖開銷比自旋鎖要大,且掛起等待鎖效率不如自旋鎖。
- 掛起等待鎖會放棄CPU資源,自旋鎖不會放棄CPU資源,會一直等到鎖釋放為止。
- 自旋鎖相較于掛起等待鎖更能及時獲取到剛釋放的鎖。
- 自旋鎖相較于掛起等待鎖的劣勢就是當自旋的時間長了,會持續(xù)地銷耗CPU資源,因此自旋鎖也可以說是樂觀鎖。
??1.5公平鎖與非公平鎖
公平鎖遵循先來后到的原則,多個線程在等待一把鎖的時候,誰先來嘗試拿鎖,那這把鎖就是誰的。 非公平鎖遵循隨機的原則,多個線程正在等待一把鎖時,當鎖釋放時,每個線程獲取到這把鎖的概率是均等的。
??1.6可重入鎖與不可重入鎖
一個線程連續(xù)加鎖兩次,不會造成死鎖,那么這個鎖就是可重入鎖。 反之,一個線程連續(xù)加鎖兩次,會造成死鎖現(xiàn)象,那么這個鎖就是不可重入鎖。
關(guān)于死鎖是什么,稍等片刻,后面就會介紹到。
??綜合上述的幾種鎖策略,synchronized
加的所到底是什么鎖?
- 它既是樂觀鎖也是悲觀鎖,當鎖競爭較小時它就是樂觀鎖,鎖競爭較大時它就是悲觀鎖。
- 它是普通互斥鎖。
- 它既是輕量級鎖也是重量級鎖,根據(jù)鎖競爭激烈程度自適應(yīng)。
- 輕量級鎖部分基于自旋鎖實現(xiàn),重量級鎖部分基于掛起等待鎖實現(xiàn)。
- 它是非公平鎖。
- 它是可重入鎖。
??1.7死鎖問題
??1.7.1常見死鎖的情況
死鎖是指多個進程在運行過程中因爭奪資源而造成的一種僵局,當進程處于這種僵持狀態(tài)時,若無外力作用,它們都將無法再向前推進。
??情況1:一個線程一把鎖 比如下面這種情況
加鎖 方法 () { 加鎖 (this) { //代碼塊 } }
首先,首次加鎖,可以成功,因為當前對象并沒有被加鎖,然后進去方法里面,再次進行加鎖,此時由于當前對象已經(jīng)被鎖占用,所以會加鎖失敗然后嘗試再次加鎖,此時就會陷入一個加鎖的死循環(huán)當中,造成死鎖。
??情況2:兩個線程兩把鎖 不妨將兩個線程稱為A,B,兩把鎖稱為S1,S2,當線程A已經(jīng)占用了鎖S1,線程B已經(jīng)占用了鎖S2,當線程A運行到加鎖S2時,由于鎖S2被線程B占用,線程A會陷入阻塞狀態(tài),當線程B運行到加鎖S1時,由于鎖S1被線程A占用,會導致線程B陷入阻塞狀態(tài),兩個線程都陷入了阻塞狀態(tài),而且自然條件下無法被喚醒,造成了死鎖。
??情況3:多個線程多把鎖 最典型的栗子就是哲學家就餐問題,下面我們來分析哲學家就餐問題。
??1.7.2哲學家就餐問題
哲學家就餐問題是迪杰斯特拉這位大佬提出并解決的問題,具體問題如下:
有五位非常固執(zhí)的科學家每天圍在一張圓桌上面吃飯,這個圓桌上一共有5份食物和5根 筷子,哲學家們成天都坐在桌前思考,當餓了的時候就會拿起距離自己最近的2根筷子就餐,但是如果發(fā)現(xiàn)離得最近的筷子被其他哲學家占用了,這個哲學家就會一直等,直到旁邊的哲學家就餐完畢,這位科學家才會拿起左右的筷子進行就餐,就餐完畢后哲學家們又開始進行思考狀態(tài),餓了就再次就餐。
當哲學家們每個人都拿起了左邊的筷子或者右邊的筷子,由于哲學家們非常地頑固,拿起一只筷子后發(fā)現(xiàn)另一只筷子被占用就會一直等待,所以所有的哲學家都會互相地等待,這樣就會造成所有哲學家都在等待,即死鎖。
??從上述的幾種造成死鎖的情況,可以總結(jié)發(fā)生死鎖的條件:
- 互斥使用,一個鎖被一個線程占用后,其他線程使用不了(鎖本質(zhì),保證原子性)。
- 不可搶占,一個鎖被一個線程占用后,其他線程不能將鎖搶占。
- 請求和保持,當一個線程占據(jù)多把鎖后,除非顯式釋放鎖,否則鎖一直被該線程鎖占用。
- 環(huán)路等待,多個線程等待關(guān)系閉環(huán)了,比如A等B,B等C,C等A。
??如何避免環(huán)路等待? 只需約定好,線程針對多把鎖加鎖時有固定的順序即可,當所有的線程都按照約定的順序加鎖就不會出現(xiàn)環(huán)路等待。
比如對于上述的哲學家就餐問題,我們可以對筷子進行編號,每次哲學家優(yōu)先拿編號小的筷子就可以避免死鎖。
??2.CAS指令與ABA問題
??2.1CAS指令
CAS即compare and awap
,即比較加交換,具體說就是將寄存器或者某個內(nèi)存上的值v1
與另一個內(nèi)存上的值v2
進行比較,如果相同就將v1
與需要修改的值swapV
進行交換,并返回交換是否成功的結(jié)果。
偽代碼如下:
boolean CAS(v1, v2, swapV) { if (v1 == v2) { v1=swapV; return true; } return false; }
上面的這一段偽代碼很明顯就是線程不安全的,CPU中提供了一條指令能夠一步到位實現(xiàn)上述偽代碼的功能,即CAS指令。該指令是具有原子性的,是線程安全的。
java標準庫中提供了基于CAS所實現(xiàn)的“原子類”,這些類的類名以Atomic
開頭,針對常用的int,long等進行了封裝,它們可以基于CAS的方式進行修改,是線程安全的。
就比如上次使用多個線程對同一個變量進行自增操作的那個程序,它是線程不安全的,但是如果使用CAS原子類來實現(xiàn),那就是線程安全的。
其中的getAndIncrement
方法相當于i++
操作。 現(xiàn)在我們來使用原子類中的“getAndIncrement
方法(基于CAS實現(xiàn))來實現(xiàn)該程序。
import java.util.concurrent.atomic.AtomicInteger; public class Main { private static final int CNT = 50000; public static void main(String[] args) throws InterruptedException { AtomicInteger count = new AtomicInteger(); Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { count.getAndIncrement(); } }); thread1.start(); Thread thread2 = new Thread(() -> { for (int i = 0; i < CNT; i++) { count.getAndIncrement(); } }); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } }
??運行結(jié)果:
從結(jié)果我們也能看出來,該程序是線程安全的。
上面所使用的AtomicInteger類方法getAndIncrement
實現(xiàn)的偽代碼如下:
class AtomicInteger { private int value;//保存的值 //自增操作 public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
首先,對于CAS指令,它的執(zhí)行邏輯就是先判斷value
的值是否與oldValue
的值相同,如果相同就將原來value
的值與value+1
的值進行交換,相當于將value
的值加1
,其中oldValue
的值為提前獲取的value
值,在單線程中oldValue
的值一定與value
的值相同,但是多線程就不一定了,因為每時每刻都有可能被其他線程修改。
然后,我們再來看看下面的while
循環(huán),該循環(huán)使用CAS指令是否成功為判斷條件,如果CAS成功了則退出循環(huán),此時value
的值已經(jīng)加1
,最終返回oldValue
,因為后置++
先使用后++
。
如果CAS指令失敗了,這就說明有新線程提前對當前的value
進行了++
,value
的值發(fā)生了改變,這時候需要重新保存value
的值給oldValue
,然后嘗試重新進行CAS操作,這樣就能保證有幾個線程操作,那就自增幾次,從而也就保證了線程安全,總的來說相當于傳統(tǒng)的++
操作,基于CAS的自增操作只有兩個指令,一個是將目標值加載到寄存器,然后在寄存器上進行CAS操作,前面使用傳統(tǒng)++
操作導致出現(xiàn)線程安全問題是指令交錯的情況,現(xiàn)在我們來畫一條時間軸,描述CAS實現(xiàn)的自增操作在多個線程指令交錯時的運行情況。
發(fā)現(xiàn)盡管指令交錯了,但是運行得到的結(jié)果預(yù)期也是相同的,也就說明基于CAS指令實現(xiàn)的多線程自增操作是線程安全的。
此外,基于CAS也能夠?qū)崿F(xiàn)自旋鎖,偽代碼如下:
//這是一個自旋鎖對象,里面有一個線程引用,如果該引用不為null,說明當前鎖對象被線程占用,反之亦然。 public class SpinLock { private Thread owner; public void lock(){ // 通過 CAS 看當前鎖是否被某個線程持有. // 如果這個鎖已經(jīng)被別的線程持有, 那么就自旋等待. // 如果這個鎖沒有被別的線程持有, 那么就把 owner 設(shè)為當前嘗試加鎖的線程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
根據(jù)CAS與自旋鎖的邏輯,如果當前鎖對象被線程占用,則lock
方法會反復地取獲取該鎖是否釋放,如果釋放了即owner==null
,就會利用CAS操作將占用該鎖對象的線程設(shè)置為當前線程,并退出加鎖lock
方法。
解鎖方法非常簡單,就將占用鎖對象的線程置為null
即可。
??2.2ABA問題
根據(jù)上面的介紹我們知道CAS指令操作的本質(zhì)是先比較,滿足條件后再進行交換,在大部分情況下都能保證線程安全,但是有一種非常極端的情況,那就是一個值被修改后又被改回到原來的值,此時CAS操作也能成功執(zhí)行,這種情況在大多數(shù)的情況是沒有影響的,但是也存在問題。
像上述一個值被修改后又被改回來這種情況就是CAS中的ABA問題,雖說對于大部分場景都不會有問題,但是也存在bug,比如有以下一個場景就說明了ABA問題所產(chǎn)生的bug:
有一天。滑稽老鐵到ATM機去取款,使用ATM查詢之后,滑稽老鐵發(fā)現(xiàn)它銀行卡的余額還有200
,于是滑稽老鐵想去100
塊給女朋友買小禮物,但是滑稽老鐵取款時,在點擊取款按鈕后機器卡了一下,滑稽老鐵下意識又點了一下,假設(shè)這兩部取款操作執(zhí)行圖如下:
如果沒有出現(xiàn)意外,即使按下兩次取款按鈕也是正常的,但是在這兩次CAS操作之間,如圖滑稽老鐵的朋友給它轉(zhuǎn)賬了100塊,導致第一次CAS扣款100后的余額從100變回到了200,這時第二次CAS操作也會執(zhí)行成功,導致又被扣款100塊,最終余額是100塊,這種情況是不合理的,滑稽老鐵會組織滑稽大軍討伐銀行的,合理的情況應(yīng)該是第二次CAS仍然失敗,最終余額為200元。
為了解決ABA問題造成的bug,可以引入應(yīng)該版本號,版本號只能增加不能減少,加載數(shù)據(jù)的時候,版本號也要一并加載,每一次修改余額都要將版本號加1
, 在進行CAS操作之前,都要對版本號進行驗證,如果版本號與之前加載的版本號不同,則放棄此次CAS指令操作。
上面的這張圖是引入版本號之后,滑稽老鐵賬戶余額變化圖,我們不難發(fā)現(xiàn)余額的變化是合理的。
總結(jié)一下,本篇文章介紹了常見的鎖策略,并說明了synchronized
關(guān)鍵字加的鎖類型不是單一一種鎖類型的,根據(jù)可重入鎖與非可重入鎖引出了死鎖的概念與死鎖條件,最后介紹了CAS指令以及CAS鎖產(chǎn)生的ABA問題及其解決方案。
到此這篇關(guān)于Java多線程之常見鎖策略與CAS中的ABA問題的文章就介紹到這了,更多相關(guān)Java多線程常見鎖策略內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring Security Oauth2.0 實現(xiàn)短信驗證碼登錄示例
本篇文章主要介紹了Spring Security Oauth2.0 實現(xiàn)短信驗證碼登錄示例,具有一定的參考價值,感興趣的小伙伴們可以參考一下2018-01-01Java使用新浪微博API開發(fā)微博應(yīng)用的基本方法
這篇文章主要介紹了Java使用新浪微博API開發(fā)微博應(yīng)用的基本方法,文中還給出了一個不使用任何SDK實現(xiàn)Oauth授權(quán)并實現(xiàn)簡單的發(fā)布微博功能的實現(xiàn)方法,需要的朋友可以參考下2015-11-11避免多個jar通過maven打包導致同名配置文件覆蓋沖突問題
這篇文章主要介紹了避免多個jar通過maven打包導致同名配置文件覆蓋沖突問題,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-05-05Struts2返回json格式數(shù)據(jù)代碼實例
這篇文章主要介紹了Struts2返回json格式數(shù)據(jù)代碼實例,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-04-04SSh結(jié)合Easyui實現(xiàn)Datagrid的分頁顯示
這篇文章主要為大家詳細介紹了SSh結(jié)合Easyui實現(xiàn)Datagrid的分頁顯示的相關(guān)資料,感興趣的小伙伴們可以參考一下2016-06-06mybatis參數(shù)String與Integer類型的判斷方式
這篇文章主要介紹了mybatis參數(shù)String與Integer類型的判斷方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03