解析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標準類

就比如上次使用多個線程對同一個變量進行自增操作的那個程序,它是線程不安全的,但是如果使用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指令實現的多線程自增操作是線程安全的。

此外,基於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!

推薦閱讀: