解析Java多線程之常見鎖策略與CAS中的ABA問題
本篇文章將介紹常見的鎖策略以及CAS中的ABA問題,前面介紹使用synchronized
關鍵字來保證線程的安全性,本質上就是對對象進行加鎖操作,synchronized
所加的鎖到底是什麼類型的鎖呢?本文帶你一探究竟。
🍀1.常見的鎖策略
🍂1.1樂觀鎖與悲觀鎖
樂觀鎖與悲觀鎖是從處理鎖沖突的態度方面來進行考量分類的。
- 樂觀鎖預期鎖沖突的概率很低,所以做的準備工作更少,付出更少,效率較高。
- 悲觀鎖預期鎖沖突的概率很高,所以做的準備工作更多,付出更多,效率較低。
🍂1.2讀寫鎖與普通互斥鎖
對於普通的互斥鎖隻有兩個操作:
- 加鎖
- 解鎖
而對於讀寫鎖來說有三個操作:
- 加讀鎖,如果代碼僅進行讀操作,就加讀鎖。
- 加寫鎖,如果代碼含有寫操作,就加寫鎖。
- 解鎖。
針對讀鎖與讀鎖之間,是沒有互斥關系的,因為多線程中同時讀一個變量是線程安全的,針對讀鎖與寫鎖之間以及寫鎖與寫鎖之間,是存在互斥關系的。
在java中有讀寫鎖的標準類,位於java.util.concurrent.locks.ReentrantReadWriteLock
,其中ReentrantReadWriteLock.ReadLock
為讀鎖,ReentrantReadWriteLock.WriteLock
為寫鎖。
🍂1.3重量級鎖與輕量級鎖
這兩種類型的鎖與悲觀鎖樂觀鎖有一定的重疊,重量級鎖做的事情更多,開銷更大,輕量級鎖做的事情較少,開銷也就較少,在大部分情況下,可以將重量級鎖視為悲觀鎖,輕量級鎖視為樂觀鎖。
如果鎖的底層是基於內核態實現的(比如調用瞭操作系統提供的mutex接口)此時一般認為是重量級鎖,如果是純用戶態實現的,一般認為是輕量級鎖。
🍂1.4掛起等待鎖與自旋鎖
掛起等待鎖表示當獲取鎖失敗之後,對應的線程就要在內核中掛起等待(放棄CPU,進入等待隊列),需要在鎖被釋放之後由操作系統喚醒,該類型的鎖是重量級鎖的典型實現。 自旋鎖表示在獲取鎖失敗後,不會立刻放棄CPU,而是快速頻繁的再次詢問鎖的持有狀態一旦鎖被釋放瞭,就能立刻獲取到鎖,該類型的鎖是輕量級鎖的典型實現。
🍄掛起等待鎖與自旋鎖的區別
- 最明顯的區別就是,掛起等待鎖開銷比自旋鎖要大,且掛起等待鎖效率不如自旋鎖。
- 掛起等待鎖會放棄CPU資源,自旋鎖不會放棄CPU資源,會一直等到鎖釋放為止。
- 自旋鎖相較於掛起等待鎖更能及時獲取到剛釋放的鎖。
- 自旋鎖相較於掛起等待鎖的劣勢就是當自旋的時間長瞭,會持續地銷耗CPU資源,因此自旋鎖也可以說是樂觀鎖。
🍂1.5公平鎖與非公平鎖
公平鎖遵循先來後到的原則,多個線程在等待一把鎖的時候,誰先來嘗試拿鎖,那這把鎖就是誰的。 非公平鎖遵循隨機的原則,多個線程正在等待一把鎖時,當鎖釋放時,每個線程獲取到這把鎖的概率是均等的。
🍂1.6可重入鎖與不可重入鎖
一個線程連續加鎖兩次,不會造成死鎖,那麼這個鎖就是可重入鎖。 反之,一個線程連續加鎖兩次,會造成死鎖現象,那麼這個鎖就是不可重入鎖。
關於死鎖是什麼,稍等片刻,後面就會介紹到。
🍄綜合上述的幾種鎖策略,synchronized
加的所到底是什麼鎖?
- 它既是樂觀鎖也是悲觀鎖,當鎖競爭較小時它就是樂觀鎖,鎖競爭較大時它就是悲觀鎖。
- 它是普通互斥鎖。
- 它既是輕量級鎖也是重量級鎖,根據鎖競爭激烈程度自適應。
- 輕量級鎖部分基於自旋鎖實現,重量級鎖部分基於掛起等待鎖實現。
- 它是非公平鎖。
- 它是可重入鎖。
🍂1.7死鎖問題
🍁1.7.1常見死鎖的情況
死鎖是指多個進程在運行過程中因爭奪資源而造成的一種僵局,當進程處於這種僵持狀態時,若無外力作用,它們都將無法再向前推進。
🍄情況1:一個線程一把鎖 比如下面這種情況
加鎖 方法 () { 加鎖 (this) { //代碼塊 } }
首先,首次加鎖,可以成功,因為當前對象並沒有被加鎖,然後進去方法裡面,再次進行加鎖,此時由於當前對象已經被鎖占用,所以會加鎖失敗然後嘗試再次加鎖,此時就會陷入一個加鎖的死循環當中,造成死鎖。
🍄情況2:兩個線程兩把鎖 不妨將兩個線程稱為A,B,兩把鎖稱為S1,S2,當線程A已經占用瞭鎖S1,線程B已經占用瞭鎖S2,當線程A運行到加鎖S2時,由於鎖S2被線程B占用,線程A會陷入阻塞狀態,當線程B運行到加鎖S1時,由於鎖S1被線程A占用,會導致線程B陷入阻塞狀態,兩個線程都陷入瞭阻塞狀態,而且自然條件下無法被喚醒,造成瞭死鎖。
🍄情況3:多個線程多把鎖 最典型的栗子就是哲學傢就餐問題,下面我們來分析哲學傢就餐問題。
🍁1.7.2哲學傢就餐問題
哲學傢就餐問題是迪傑斯特拉這位大佬提出並解決的問題,具體問題如下:
有五位非常固執的科學傢每天圍在一張圓桌上面吃飯,這個圓桌上一共有5份食物和5根 筷子,哲學傢們成天都坐在桌前思考,當餓瞭的時候就會拿起距離自己最近的2根筷子就餐,但是如果發現離得最近的筷子被其他哲學傢占用瞭,這個哲學傢就會一直等,直到旁邊的哲學傢就餐完畢,這位科學傢才會拿起左右的筷子進行就餐,就餐完畢後哲學傢們又開始進行思考狀態,餓瞭就再次就餐。
當哲學傢們每個人都拿起瞭左邊的筷子或者右邊的筷子,由於哲學傢們非常地頑固,拿起一隻筷子後發現另一隻筷子被占用就會一直等待,所以所有的哲學傢都會互相地等待,這樣就會造成所有哲學傢都在等待,即死鎖。
🍄從上述的幾種造成死鎖的情況,可以總結發生死鎖的條件:
- 互斥使用,一個鎖被一個線程占用後,其他線程使用不瞭(鎖本質,保證原子性)。
- 不可搶占,一個鎖被一個線程占用後,其他線程不能將鎖搶占。
- 請求和保持,當一個線程占據多把鎖後,除非顯式釋放鎖,否則鎖一直被該線程鎖占用。
- 環路等待,多個線程等待關系閉環瞭,比如A等B,B等C,C等A。
🍄如何避免環路等待? 隻需約定好,線程針對多把鎖加鎖時有固定的順序即可,當所有的線程都按照約定的順序加鎖就不會出現環路等待。
比如對於上述的哲學傢就餐問題,我們可以對筷子進行編號,每次哲學傢優先拿編號小的筷子就可以避免死鎖。
🍀2.CAS指令與ABA問題
🍂2.1CAS指令
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
即可。
🍂2.2ABA問題
根據上面的介紹我們知道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指令操作。
上面的這張圖是引入版本號之後,滑稽老鐵賬戶餘額變化圖,我們不難發現餘額的變化是合理的。
總結一下,本篇文章介紹瞭常見的鎖策略,並說明瞭synchronized
關鍵字加的鎖類型不是單一一種鎖類型的,根據可重入鎖與非可重入鎖引出瞭死鎖的概念與死鎖條件,最後介紹瞭CAS指令以及CAS鎖產生的ABA問題及其解決方案。
到此這篇關於Java多線程之常見鎖策略與CAS中的ABA問題的文章就介紹到這瞭,更多相關Java多線程常見鎖策略內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!