天天看點

【高并發】優化加鎖方式時竟然死鎖了!!

寫在前面

今天,在優化程式的加鎖方式時,竟然出現了死鎖!!到底是為什麼呢?!經過仔細的分析之後,終于找到了原因。

為何需要優化加鎖方式?

在《

【高并發】高并發環境下詭異的加鎖問題(你加的鎖未必安全)

》一文中,我們在轉賬類TansferAccount中使用TansferAccount.class對象對程式加鎖,如下所示。

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轉賬的操作完全可以并行執行。

我們可以将優化後的邏輯用下圖表示。

【高并發】優化加鎖方式時竟然死鎖了!!

根據上面的分析,我們可以将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;
                }   
            }
        }
    }
}      

總結

在并發程式設計中,使用細粒度鎖來鎖定多個資源時,要時刻注意死鎖的問題。另外,避免死鎖最簡單的方法就是阻止循環等待條件,将系統中所有的資源設定标志位、排序,規定所有的線程申請資源必須以一定的順序來操作進而避免死鎖。