您好,登錄后才能下訂單哦!
本文小編為大家詳細介紹“Java多線程之常見鎖策略與CAS中的ABA問題怎么解決”,內容詳細,步驟清晰,細節處理妥當,希望這篇“Java多線程之常見鎖策略與CAS中的ABA問題怎么解決”文章能幫助大家解決疑惑,下面跟著小編的思路慢慢深入,一起來學習新知識吧。
樂觀鎖與悲觀鎖是從處理鎖沖突的態度方面來進行考量分類的。
樂觀鎖預期鎖沖突的概率很低,所以做的準備工作更少,付出更少,效率較高。
悲觀鎖預期鎖沖突的概率很高,所以做的準備工作更多,付出更多,效率較低。
對于普通的互斥鎖只有兩個操作:
加鎖
解鎖
而對于讀寫鎖來說有三個操作:
加讀鎖,如果代碼僅進行讀操作,就加讀鎖。
加寫鎖,如果代碼含有寫操作,就加寫鎖。
解鎖。
針對讀鎖與讀鎖之間,是沒有互斥關系的,因為多線程中同時讀一個變量是線程安全的,針對讀鎖與寫鎖之間以及寫鎖與寫鎖之間,是存在互斥關系的。
在java中有讀寫鎖的標準類,位于java.util.concurrent.locks.ReentrantReadWriteLock
,其中ReentrantReadWriteLock.ReadLock
為讀鎖,ReentrantReadWriteLock.WriteLock
為寫鎖。
這兩種類型的鎖與悲觀鎖樂觀鎖有一定的重疊,重量級鎖做的事情更多,開銷更大,輕量級鎖做的事情較少,開銷也就較少,在大部分情況下,可以將重量級鎖視為悲觀鎖,輕量級鎖視為樂觀鎖。
如果鎖的底層是基于內核態實現的(比如調用了操作系統提供的mutex接口)此時一般認為是重量級鎖,如果是純用戶態實現的,一般認為是輕量級鎖。
掛起等待鎖表示當獲取鎖失敗之后,對應的線程就要在內核中掛起等待(放棄CPU,進入等待隊列),需要在鎖被釋放之后由操作系統喚醒,該類型的鎖是重量級鎖的典型實現。 自旋鎖表示在獲取鎖失敗后,不會立刻放棄CPU,而是快速頻繁的再次詢問鎖的持有狀態一旦鎖被釋放了,就能立刻獲取到鎖,該類型的鎖是輕量級鎖的典型實現。
掛起等待鎖與自旋鎖的區別
最明顯的區別就是,掛起等待鎖開銷比自旋鎖要大,且掛起等待鎖效率不如自旋鎖。
掛起等待鎖會放棄CPU資源,自旋鎖不會放棄CPU資源,會一直等到鎖釋放為止。
自旋鎖相較于掛起等待鎖更能及時獲取到剛釋放的鎖。
自旋鎖相較于掛起等待鎖的劣勢就是當自旋的時間長了,會持續地銷耗CPU資源,因此自旋鎖也可以說是樂觀鎖。
公平鎖遵循先來后到的原則,多個線程在等待一把鎖的時候,誰先來嘗試拿鎖,那這把鎖就是誰的。 非公平鎖遵循隨機的原則,多個線程正在等待一把鎖時,當鎖釋放時,每個線程獲取到這把鎖的概率是均等的。
一個線程連續加鎖兩次,不會造成死鎖,那么這個鎖就是可重入鎖。 反之,一個線程連續加鎖兩次,會造成死鎖現象,那么這個鎖就是不可重入鎖。
關于死鎖是什么,稍等片刻,后面就會介紹到。
綜合上述的幾種鎖策略,synchronized
加的所到底是什么鎖?
它既是樂觀鎖也是悲觀鎖,當鎖競爭較小時它就是樂觀鎖,鎖競爭較大時它就是悲觀鎖。
它是普通互斥鎖。
它既是輕量級鎖也是重量級鎖,根據鎖競爭激烈程度自適應。
輕量級鎖部分基于自旋鎖實現,重量級鎖部分基于掛起等待鎖實現。
它是非公平鎖。
它是可重入鎖。
死鎖是指多個進程在運行過程中因爭奪資源而造成的一種僵局,當進程處于這種僵持狀態時,若無外力作用,它們都將無法再向前推進。
情況1:一個線程一把鎖 比如下面這種情況
加鎖 方法 () { 加鎖 (this) { //代碼塊 } }
首先,首次加鎖,可以成功,因為當前對象并沒有被加鎖,然后進去方法里面,再次進行加鎖,此時由于當前對象已經被鎖占用,所以會加鎖失敗然后嘗試再次加鎖,此時就會陷入一個加鎖的死循環當中,造成死鎖。
情況2:兩個線程兩把鎖 不妨將兩個線程稱為A,B,兩把鎖稱為S1,S2,當線程A已經占用了鎖S1,線程B已經占用了鎖S2,當線程A運行到加鎖S2時,由于鎖S2被線程B占用,線程A會陷入阻塞狀態,當線程B運行到加鎖S1時,由于鎖S1被線程A占用,會導致線程B陷入阻塞狀態,兩個線程都陷入了阻塞狀態,而且自然條件下無法被喚醒,造成了死鎖。
情況3:多個線程多把鎖 最典型的栗子就是哲學家就餐問題,下面我們來分析哲學家就餐問題。
哲學家就餐問題是迪杰斯特拉這位大佬提出并解決的問題,具體問題如下:
有五位非常固執的科學家每天圍在一張圓桌上面吃飯,這個圓桌上一共有5份食物和5根 筷子,哲學家們成天都坐在桌前思考,當餓了的時候就會拿起距離自己最近的2根筷子就餐,但是如果發現離得最近的筷子被其他哲學家占用了,這個哲學家就會一直等,直到旁邊的哲學家就餐完畢,這位科學家才會拿起左右的筷子進行就餐,就餐完畢后哲學家們又開始進行思考狀態,餓了就再次就餐。
當哲學家們每個人都拿起了左邊的筷子或者右邊的筷子,由于哲學家們非常地頑固,拿起一只筷子后發現另一只筷子被占用就會一直等待,所以所有的哲學家都會互相地等待,這樣就會造成所有哲學家都在等待,即死鎖。
從上述的幾種造成死鎖的情況,可以總結發生死鎖的條件:
互斥使用,一個鎖被一個線程占用后,其他線程使用不了(鎖本質,保證原子性)。
不可搶占,一個鎖被一個線程占用后,其他線程不能將鎖搶占。
請求和保持,當一個線程占據多把鎖后,除非顯式釋放鎖,否則鎖一直被該線程鎖占用。
環路等待,多個線程等待關系閉環了,比如A等B,B等C,C等A。
如何避免環路等待? 只需約定好,線程針對多把鎖加鎖時有固定的順序即可,當所有的線程都按照約定的順序加鎖就不會出現環路等待。
比如對于上述的哲學家就餐問題,我們可以對筷子進行編號,每次哲學家優先拿編號小的筷子就可以避免死鎖。
CAS即compare and awap
,即比較加交換,具體說就是將寄存器或者某個內存上的值v1
與另一個內存上的值v2
進行比較,如果相同就將v1
與需要修改的值swapV
進行交換,并返回交換是否成功的結果。
偽代碼如下:
boolean CAS(v1, v2, swapV) { if (v1 == v2) { v1=swapV; return true; } return false; }
上面的這一段偽代碼很明顯就是線程不安全的,CPU中提供了一條指令能夠一步到位實現上述偽代碼的功能,即CAS指令。該指令是具有原子性的,是線程安全的。
java標準庫中提供了基于CAS所實現的“原子類”,這些類的類名以Atomic
開頭,針對常用的int,long等進行了封裝,它們可以基于CAS的方式進行修改,是線程安全的。
就比如上次使用多個線程對同一個變量進行自增操作的那個程序,它是線程不安全的,但是如果使用CAS原子類來實現,那就是線程安全的。
其中的getAndIncrement
方法相當于i++
操作。 現在我們來使用原子類中的“getAndIncrement
方法(基于CAS實現)來實現該程序。
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); } }
運行結果:
從結果我們也能看出來,該程序是線程安全的。
上面所使用的AtomicInteger類方法getAndIncrement
實現的偽代碼如下:
class AtomicInteger { private int value;//保存的值 //自增操作 public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
首先,對于CAS指令,它的執行邏輯就是先判斷value
的值是否與oldValue
的值相同,如果相同就將原來value
的值與value+1
的值進行交換,相當于將value
的值加1
,其中oldValue
的值為提前獲取的value
值,在單線程中oldValue
的值一定與value
的值相同,但是多線程就不一定了,因為每時每刻都有可能被其他線程修改。
然后,我們再來看看下面的while
循環,該循環使用CAS指令是否成功為判斷條件,如果CAS成功了則退出循環,此時value
的值已經加1
,最終返回oldValue
,因為后置++
先使用后++
。
如果CAS指令失敗了,這就說明有新線程提前對當前的value
進行了++
,value
的值發生了改變,這時候需要重新保存value
的值給oldValue
,然后嘗試重新進行CAS操作,這樣就能保證有幾個線程操作,那就自增幾次,從而也就保證了線程安全,總的來說相當于傳統的++
操作,基于CAS的自增操作只有兩個指令,一個是將目標值加載到寄存器,然后在寄存器上進行CAS操作,前面使用傳統++
操作導致出現線程安全問題是指令交錯的情況,現在我們來畫一條時間軸,描述CAS實現的自增操作在多個線程指令交錯時的運行情況。
發現盡管指令交錯了,但是運行得到的結果預期也是相同的,也就說明基于CAS指令實現的多線程自增操作是線程安全的。
此外,基于CAS也能夠實現自旋鎖,偽代碼如下:
//這是一個自旋鎖對象,里面有一個線程引用,如果該引用不為null,說明當前鎖對象被線程占用,反之亦然。 public class SpinLock { private Thread owner; public void lock(){ // 通過 CAS 看當前鎖是否被某個線程持有. // 如果這個鎖已經被別的線程持有, 那么就自旋等待. // 如果這個鎖沒有被別的線程持有, 那么就把 owner 設為當前嘗試加鎖的線程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
根據CAS與自旋鎖的邏輯,如果當前鎖對象被線程占用,則lock
方法會反復地取獲取該鎖是否釋放,如果釋放了即owner==null
,就會利用CAS操作將占用該鎖對象的線程設置為當前線程,并退出加鎖lock
方法。
解鎖方法非常簡單,就將占用鎖對象的線程置為null
即可。
根據上面的介紹我們知道CAS指令操作的本質是先比較,滿足條件后再進行交換,在大部分情況下都能保證線程安全,但是有一種非常極端的情況,那就是一個值被修改后又被改回到原來的值,此時CAS操作也能成功執行,這種情況在大多數的情況是沒有影響的,但是也存在問題。
像上述一個值被修改后又被改回來這種情況就是CAS中的ABA問題,雖說對于大部分場景都不會有問題,但是也存在bug,比如有以下一個場景就說明了ABA問題所產生的bug:
有一天。滑稽老鐵到ATM機去取款,使用ATM查詢之后,滑稽老鐵發現它銀行卡的余額還有200
,于是滑稽老鐵想去100
塊給女朋友買小禮物,但是滑稽老鐵取款時,在點擊取款按鈕后機器卡了一下,滑稽老鐵下意識又點了一下,假設這兩部取款操作執行圖如下:
如果沒有出現意外,即使按下兩次取款按鈕也是正常的,但是在這兩次CAS操作之間,如圖滑稽老鐵的朋友給它轉賬了100塊,導致第一次CAS扣款100后的余額從100變回到了200,這時第二次CAS操作也會執行成功,導致又被扣款100塊,最終余額是100塊,這種情況是不合理的,滑稽老鐵會組織滑稽大軍討伐銀行的,合理的情況應該是第二次CAS仍然失敗,最終余額為200元。
為了解決ABA問題造成的bug,可以引入應該版本號,版本號只能增加不能減少,加載數據的時候,版本號也要一并加載,每一次修改余額都要將版本號加1
, 在進行CAS操作之前,都要對版本號進行驗證,如果版本號與之前加載的版本號不同,則放棄此次CAS指令操作。
上面的這張圖是引入版本號之后,滑稽老鐵賬戶余額變化圖,我們不難發現余額的變化是合理的。
讀到這里,這篇“Java多線程之常見鎖策略與CAS中的ABA問題怎么解決”文章已經介紹完畢,想要掌握這篇文章的知識點還需要大家自己動手實踐使用過才能領會,如果想了解更多相關內容的文章,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。