【高併發】優化加鎖方式時居然死鎖了!!

寫在前面

今天,在優化程序的加鎖方式時,居然出現了死鎖!!究竟是爲何呢?!通過仔細的分析以後,終於找到了緣由。html

爲什麼須要優化加鎖方式?

在《【高併發】高併發環境下詭異的加鎖問題(你加的鎖未必安全)》一文中,咱們在轉帳類TansferAccount中使用TansferAccount.class對象對程序加鎖,以下所示。java

public class TansferAccount{
    private Integer balance;
    public void transfer(TansferAccount target, Integer transferMoney){
        synchronized(TansferAccount.class){
        	if(this.balance >= transferMoney){
                this.balance -= transferMoney;
                target.balance += transferMoney;
            }   
        }
    }
}

這種方式確實解決了轉帳操做的併發問題,可是這種方式在高併發環境下真的可取嗎?試想,若是咱們在高併發環境下使用上述代碼來處理轉帳操做,由於TansferAccount.class對象是JVM在加載TansferAccount類的時候建立的,全部的TansferAccount實例對象都會共享一個TansferAccount.class對象。也就是說,全部TansferAccount實例對象執行transfer()方法時,都是互斥的!!換句話說,全部的轉帳操做都是串行的!!編程

若是全部的轉帳操做都是串行執行的話,形成的後果就是:帳戶A爲帳戶B轉帳完成後,才能進行帳戶C爲帳戶D的轉帳操做。若是全世界的網民一塊兒執行轉帳操做的話,這些轉帳操做都串行執行,那麼,程序的性能是徹底沒法接受的!!!安全

其實,帳戶A爲帳戶B轉帳的操做和帳戶C爲帳戶D轉帳的操做徹底能夠並行執行。因此,咱們必須優化加鎖方式,提高程序的性能!!微信

初步優化加鎖方式

既然直接TansferAccount.class對程序加鎖在高併發環境下不可取,那麼,咱們到底應該怎麼作呢?!併發

仔細分析下上面的代碼業務,上述代碼的轉帳操做中,涉及到轉出帳戶this和轉入帳戶target,因此,咱們能夠分別對轉出帳戶this和轉入帳戶target加鎖,只有兩個帳戶加鎖都成功時,才執行轉帳操做。這樣就可以作到帳戶A爲帳戶B轉帳的操做和帳戶C爲帳戶D轉帳的操做徹底能夠並行執行。app

咱們能夠將優化後的邏輯用下圖表示。高併發

14

根據上面的分析,咱們能夠將TansferAccount的代碼優化成以下所示。性能

public class TansferAccount{
    //帳戶的餘額
    private Integer balance;
    //轉帳操做
    public void transfer(TansferAccount target, Integer transferMoney){
        //對轉出帳戶加鎖
        synchronized(this){
            //對轉入帳戶加鎖
            synchronized(target){
                if(this.balance >= transferMoney){
                    this.balance -= transferMoney;
                    target.balance += transferMoney;
                }   
            }
        }
    }
}

此時,上面的代碼看上去沒啥問題,但真的是這樣嗎? 我也但願程序是完美的,可是每每卻不是咱們想的那樣啊!沒錯,上面的程序會出現 死鎖, 爲何會出現死鎖啊? 接下來,咱們就開始分析一波。學習

死鎖的問題分析

TansferAccount類中的代碼看上去比較完美,可是優化後的加鎖方式居然會致使死鎖!!!這是我親測得出的結論!!

關於死鎖咱們能夠結合改進的TansferAccount類舉一個簡單的場景:假設有線程A和線程B兩個線程同時運行在兩個不一樣的CPU上,線程A執行帳戶A向帳戶B轉帳的操做,線程B執行帳戶B向帳戶A轉帳的操做。當線程A和線程B執行到 synchronized(this)代碼時,線程A得到了帳戶A的鎖,線程B得到了帳戶B的鎖。當執行到synchronized(target)代碼時,線程A嘗試得到帳戶B的鎖時,發現帳戶B已經被線程B鎖定,此時線程A開始等待線程B釋放帳戶B的鎖;而線程B嘗試得到帳戶A的鎖時,發現帳戶A已經被線程A鎖定,此時線程B開始等待線程A釋放帳戶A的鎖。

這樣,線程A持有帳戶A的鎖並等待線程B釋放帳戶B的鎖,線程B持有帳戶B的鎖並等待線程A釋放帳戶A的鎖,死鎖發生了!!

死鎖的必要條件

在如何解決死鎖以前,咱們先來看下發生死鎖時有哪些必要的條件。若是要發生死鎖,則必須存在如下四個必要條件,四者缺一不可。

  • 互斥條件

在一段時間內某資源僅爲一個線程所佔有。此時如有其餘線程請求該資源,則請求線程只能等待。

  • 不可剝奪條件

線程所得到的資源在未使用完畢以前,不能被其餘線程強行奪走,即只能由得到該資源的線程本身來釋放(只能是主動釋放)。

  • 請求與保持條件

線程已經保持了至少一個資源,但又提出了新的資源請求,而該資源已被其餘線程佔有,此時請求線程被阻塞,但對本身已得到的資源保持不放。

  • 循環等待條件

既然死鎖的發生必須存在上述四個條件,那麼,你們是否是就可以想到如何預防死鎖了呢?

死鎖的預防

併發編程中,一旦發生了死鎖的現象,則基本沒有特別好的解決方法,通常狀況下只能重啓應用來解決。所以,解決死鎖的最好方法就是預防死鎖。

發生死鎖時,必然會存在死鎖的四個必要條件。也就是說,若是咱們在寫程序時,只要「破壞」死鎖的四個必要條件中的一個,就可以避免死鎖的發生。接下來,咱們就一塊兒來探討下如何「破壞」這四個必要條件。

  • 破壞互斥條件

互斥條件是咱們沒辦法破壞的,由於咱們使用鎖爲的就是線程之間的互斥。這一點須要特別注意!!!!

  • 破壞不可剝奪條件

破壞不可剝奪的條件的核心就是讓當前線程本身主動釋放佔有的資源,關於這一點,synchronized是作不到的,咱們可使用java.util.concurrent包下的Lock來解決。此時,咱們須要將TansferAccount類的代碼修改爲相似以下所示。

public class TansferAccount{
    private Lock thisLock = new ReentrantLock();
    private Lock targetLock = new ReentrantLock();
    //帳戶的餘額
    private Integer balance;
    //轉帳操做
    public void transfer(TansferAccount target, Integer transferMoney){
        boolean isThisLock = thisLock.tryLock();
        if(isThisLock){
            try{
                boolean isTargetLock = targetLock.tryLock();
                if(isTargetLock){
                    try{
                         if(this.balance >= transferMoney){
                            this.balance -= transferMoney;
                            target.balance += transferMoney;
                        }   
                    }finally{
                        targetLock.unlock
                    }
                }
            }finally{
                thisLock.unlock();
            }
        }
    }
}

其中Lock中有兩個tryLock方法,分別以下所示。

  • tryLock()方法

tryLock()方法是有返回值的,它表示用來嘗試獲取鎖,若是獲取成功,則返回true,若是獲取失敗(即鎖已被其餘線程獲取),則返回false,也就說這個方法不管如何都會當即返回。在拿不到鎖時不會一直在那等待。

  • tryLock(long time, TimeUnit unit)方法

tryLock(long time, TimeUnit unit)方法和tryLock()方法是相似的,只不過區別在於這個方法在拿不到鎖時會等待必定的時間,在時間期限以內若是還拿不到鎖,就返回false。若是一開始拿到鎖或者在等待期間內拿到了鎖,則返回true。

  • 破壞請求與保持條件

破壞請求與保持條件,咱們能夠一次性申請所須要的全部資源,例如在咱們完成轉帳操做的過程當中,咱們一次性申請帳戶A和帳戶B,兩個帳戶都申請成功後,再執行轉帳的操做。此時,咱們須要再建立一個申請資源的類ResourcesRequester,這個類的做用就是申請資源和釋放資源。同時,TansferAccount類中須要持有一個ResourcesRequester類的單例對象,當咱們須要執行轉帳操做時,首先向ResourcesRequester同時申請轉出帳戶和轉入帳戶兩個資源,申請成功後,再鎖定兩個資源;當轉帳操做完成後,釋放鎖並釋放ResourcesRequester類申請的轉出帳戶和轉入帳戶資源。

ResourcesRequester類的代碼以下所示。

public class ResourcesRequester{
    //存放申請資源的集合
    private List<Object> resources = new ArrayList<Object>();
    //一次申請全部的資源
    public synchronized boolean applyResources(Object source, Object target){
        if(resources.contains(source) || resources.contains(target)){
            return false;
        }
        resources.add(source);
        resources.add(targer);
        return true;
    }
    
    //釋放資源
    public synchronized void releaseResources(Object source, Object target){
        resources.remove(source);
        resources.remove(target);
    }
}

此時,TansferAccount類的代碼以下所示。

public class TansferAccount{
    //帳戶的餘額
    private Integer balance;
    //ResourcesRequester類的單例對象
    private ResourcesRequester requester;
   
    //轉帳操做
    public void transfer(TansferAccount target, Integer transferMoney){
        //自旋申請轉出帳戶和轉入帳戶,直到成功
        while(!requester.applyResources(this, target)){
            //循環體爲空
            ;
        }
        try{
            //對轉出帳戶加鎖
            synchronized(this){
                //對轉入帳戶加鎖
                synchronized(target){
                    if(this.balance >= transferMoney){
                        this.balance -= transferMoney;
                        target.balance += transferMoney;
                    }   
                }
            }
        }finally{
            //最後釋放帳戶資源
            requester.releaseResources(this, target);
        }

    }
}
  • 破壞循環等待條件

破壞循環等待條件,則能夠經過對資源排序,按照必定的順序來申請資源,而後按照順序來鎖定資源,能夠有效的避免死鎖。

例如,在咱們的轉帳操做中,每每每一個帳戶都會有一個惟一的id值,咱們在鎖定帳戶資源時,能夠按照id值從小到大的順序來申請帳戶資源,並按照id從小到大的順序來鎖定帳戶,此時,程序就不會再進行循環等待了。

程序代碼以下所示。

public class TansferAccount{
    //帳戶的id
    private Integer id;
    //帳戶的餘額
    private Integer balance;
    //轉帳操做
    public void transfer(TansferAccount target, Integer transferMoney){
        TansferAccount beforeAccount = this;
        TansferAccount afterAccount = target;
        if(this.id > target.id){
            beforeAccount = target;
            afterAccount = this;
        }
        //對轉出帳戶加鎖
        synchronized(beforeAccount){
            //對轉入帳戶加鎖
            synchronized(afterAccount){
                if(this.balance >= transferMoney){
                    this.balance -= transferMoney;
                    target.balance += transferMoney;
                }   
            }
        }
    }
}

總結

在併發編程中,使用細粒度鎖來鎖定多個資源時,要時刻注意死鎖的問題。另外,避免死鎖最簡單的方法就是阻止循環等待條件,將系統中全部的資源設置標誌位、排序,規定全部的線程申請資源必須以必定的順序來操做進而避免死鎖。

寫在最後

若是以爲文章對你有點幫助,請微信搜索並關注「 冰河技術 」微信公衆號,跟冰河學習高併發編程技術。

最後,附上併發編程須要掌握的核心技能知識圖,祝你們在學習併發編程時,少走彎路。

sandahexin_20200322

相關文章
相關標籤/搜索