1. 程式人生 > >從零開始學多執行緒之死鎖(八)

從零開始學多執行緒之死鎖(八)

死鎖

每個人手裡都有其他人需要的資源,自己又不會放下手上的資源,這麼一直等待下去,就會發生死鎖.

當一個執行緒永遠佔有一個鎖,而其他執行緒嘗試去獲得這個鎖,那麼它們將永遠被阻塞.

當執行緒A佔有鎖L時,想要獲得鎖M,同時執行緒B持有M,並嘗試得到L,兩個執行緒將永遠等待下去,這種情況是死鎖最簡單的形式(或稱致命的擁抱,deadly embrace)

資料庫不會發生死鎖的情況,它會選擇一個犧牲者,強行釋放鎖,讓程式可以繼續執行下去.

JVM不行,只能重啟程式.

死鎖並不會每次都出現

死鎖很少能立即發現.一個類如果有發生死鎖的潛在可能並不意味著每次都將發生,它只發生在該發生的時候.

當死鎖出現的時候,往往是遇到了最不幸的時候--- 在高負載下.

鎖順序死鎖

public class LeftRightDeadLock {
    private Object leftLock = new Object();
    private Object rightLock = new Object();


    public void getLeftLock(){
        synchronized (this.rightLock){
            synchronized (this.leftLock){
                //do something
            }
        }
    }


    public void getRightLock(){
        synchronized (this.leftLock){
            synchronized (this.rightLock){
                //do something.
            }
        }
    }
}

兩個執行緒分別進入getRightLock和getLeftLock方法,同時獲得第一個鎖,在等待下一個鎖的時候,就會發生鎖順序死鎖.

發生死鎖的原因: 兩個執行緒試圖通過不同的順序獲得多個相同的鎖.

如果請求的順序相同就不會出現迴圈的鎖依賴現象,就不會產生死鎖了.

如果所有執行緒以通用的固定秩序獲得鎖,程式就不會出現鎖順序死鎖問題了.

動態的鎖順序死鎖

public class DynamicDeadLock {

    public void transferMoney(Account fromAcount,Account toAccount){
        synchronized (fromAcount){
            synchronized (toAccount){
                //轉賬操作
            }
        }
    }
}

當兩個執行緒同時呼叫transferMoney,一個從X向Y轉賬,另一個從Y向X轉賬,那就會發生死鎖.

transferMoney(myAccount,yourAccount)

transferMoney(yourAccount,myAccount)

之前說了,造成死鎖的原因就是以不同的順序獲得相同的鎖.

那麼要解決這個問題,我就就必須制定鎖的順序.

System.indentityHashCode(傳入物件)方法可以得到物件的雜湊碼.我們通過雜湊碼來決定鎖的順序.

public class DynamicDeadLock {

    private Object obj = new Object();


    public void transferMoney(Account fromAcount,Account toAccount){
    //這個內部類秒啊,可以減少重複程式碼
        class Helper {
            public void transferMoney(){
                //真正的轉賬操作..
                //假裝使用 外部的兩個引數 fromAcount和toAccount做一下操作..
            }
        }
        //制定鎖的順序
        int fromHash = System.identityHashCode(fromAcount);
        int toHash = System.identityHashCode(toAccount);

        if(fromHash<toHash){
            synchronized (fromAcount){
                synchronized (toAccount){
                    new Helper().transferMoney();
                }
            }
        }else if(fromHash>toHash){
            synchronized (toAccount){
                synchronized (fromAcount){
                    new Helper().transferMoney();
                }
            }
        }else{
            //使用成員變數的鎖
            synchronized (obj){
                synchronized (fromAcount){
                    synchronized (toAccount){
                        new Helper().transferMoney();
                    }
                }
            }
        }
    }
}

雖然有點麻煩,但是減少了發生死鎖的可能性.

注意上面程式碼的最後一種else的情況,使用了一個額外的obj的鎖,這是因為極少數的情況下會出現hashcode相同的情況,當hashCode相同的時候,使用之前的兩種順序鎖,兩個執行緒同時呼叫兩個方法,引數換位,顛倒順序計算雜湊值,就又有了出現死鎖的可能,所以引入第三種鎖來保證鎖的順序,從而減少死鎖發生的可能性.

如果經常出現hash值衝突,那麼併發性會降低(因為多加了一個鎖),但是因為 System.identityHashCode的雜湊衝突出現頻率很低,所以這個技術以最小的代價,換來了最大的安全性.

如果Account具有一個唯一的,不可變的,並且具有可比性的key,比如賬號,那麼就可以通過賬號來排定物件順序,這樣就能省去obj的鎖了.

協作物件間的死鎖

public class A {

    private final B b ;

    public A(B b) {
        this.b = b;
    }

    public  synchronized  void methodA(){
        //do something.

        //呼叫B的同步的方法
        b.methodB();

    }
}


public class B {
    private final A a;

    public B(A a) {
        this.a = a;
    }

    public synchronized void methodB(){
        //do something

        //呼叫A的同步的方法
        a.methodA();
    }
}

在持有鎖的時候呼叫外部方法是在挑戰活躍度問題,外部方法可能會獲得其他鎖(產生死鎖的風險),或者遭遇嚴重超時的阻塞,當你持有鎖的時候會延遲其他試圖獲得該鎖的執行緒

開放呼叫

在持有鎖的時候呼叫一個外部方法很難進行分析,因此是危險的.

當呼叫的方法不需要持有鎖時,這被稱為開放呼叫(open call). 依賴於開放呼叫的類更容易與其他的類合作.

使用開放呼叫來避免死鎖類似於使用封裝來提供執行緒安全:對一個有效封裝的類進行執行緒安全分析,要比分析沒有封裝的類容易得多.

類似地,分析一個完全依賴於開放呼叫的程式的程式活躍度,比分析哪些非開放呼叫的程式更簡單.

儘量讓你自己使用開放呼叫,這比獲得多重鎖後識別程式碼路徑更簡單,因為可以確保使用一致的順序獲得鎖.

不使用synchronized修飾方法,減少synchronized包住的程式碼塊,來避免協作物件間的死鎖.

public class A {

    private final B b;

    public A(B b) {
        this.b = b;
    }

 
   public void methodA() {
        //關鍵在這
        synchronized (this) {
            //do something.
        }

        //呼叫B的同步的方法
        b.methodB();

    }
}

除了能避免死鎖以外,因為同步的程式碼塊變小,所以使得響應速度得到提高.

在程式中儘量使用開放呼叫.依賴於開放呼叫的程式,相比於那些在持有鎖的時候還呼叫外部方法的程式,更容易進行死鎖自由度(deadlock-freedom)的分析.

在同步方法之間互相呼叫的時候,儘量使用開放呼叫來避免死鎖.

避免和診斷死鎖

使用定時的鎖

使用顯示的Lock類中定時tryLock方法來替代synchronized,可以設定超時時間,超時會失敗,這樣避免了死鎖.

其他的活躍度失敗.

除了死鎖,還有一些其他的活躍度危險:

  • 飢餓
  • 丟失訊號
  • 活鎖

飢餓

當執行緒訪問它所需要的資源時卻被永久拒絕,以至於不能再繼續進行,這樣就發生了飢餓(starvation).

引發飢餓的情況:

  • 使用執行緒的優先順序不當
  • 在鎖中執行無終止的構建(無限迴圈,或者無盡等待資源).

歸根結底是因為執行緒不能再執行.

執行緒優先順序並不是方便的工具,它改變執行緒優先順序的效果往往不明顯;提高一個執行緒的優先順序往往什麼都不能改變,或者總是會引起一個執行緒的排程優先高於其他執行緒,從而導致飢餓.

抵制使用執行緒優先順序的誘惑,因為這會增加平臺依賴性,並且可能引起活躍度問題.大多數併發應用程式可以對所有執行緒使用相同的優先順序.

弱響應性

當計算密集型後臺計算任務影響到響應性時,這種情況下可以使用執行緒優先順序.降低執行後臺任務的執行緒的優先順序,從而提高程式的響應性.

活鎖

活鎖(livelock)是執行緒活躍度失敗的另一種形式,儘管沒有被阻塞,執行緒缺仍然不能繼續,因為他不斷重試相同的操作,缺總是失敗.

例如程式處理一段程式碼出錯了,業務邏輯使它回退重複執行,然後有錯了,再回退重新執行,如此反覆.這就是活鎖.

這種形式的活躍通常來源於過渡的錯誤恢復程式碼,誤將不可修復的錯誤當做是可修復的錯誤.

還有另一個例子: 多個相互協作的執行緒間,他們為了彼此響應而修改了狀態,使得沒有一個執行緒能夠繼續前進,那麼就發生了活鎖.

就好比兩個有禮貌的人在路上相遇,他們給對方讓路,於是在另一條路又遇上了,如此反覆...

在併發程式中,通過隨機等待和撤回來進行重試能夠相當有效地避免活鎖的發生.

總結:

活躍度失敗是非常嚴重的問題,因為除了中止應用程式,沒有任何機制可以恢復這種失敗.

最常見的活躍度失敗是死鎖.應該在設計時就避免鎖順序死鎖:確保多個執行緒在獲得多個鎖時,使用一致的順序.

最好的解決方法是在程式中使用開放呼叫,這會大大減少一個執行緒一次請求多個鎖的情況.

下篇會更新提高響應速度的方式.