1. 程式人生 > >java中產生死鎖的原因及如何避免

java中產生死鎖的原因及如何避免

1. Java中導致死鎖的原因

Java中死鎖最簡單的情況是,一個執行緒T1持有鎖L1並且申請獲得鎖L2,而另一個執行緒T2持有鎖L2並且申請獲得鎖L1,因為預設的鎖申請操作都是阻塞的,所以執行緒T1和T2永遠被阻塞了。導致了死鎖。這是最容易理解也是最簡單的死鎖的形式。但是實際環境中的死鎖往往比這個複雜的多。可能會有多個執行緒形成了一個死鎖的環路,比如:執行緒T1持有鎖L1並且申請獲得鎖L2,而執行緒T2持有鎖L2並且申請獲得鎖L3,而執行緒T3持有鎖L3並且申請獲得鎖L1,這樣導致了一個鎖依賴的環路:T1依賴T2的鎖L2,T2依賴T3的鎖L3,而T3依賴T1的鎖L1。從而導致了死鎖。

從這兩個例子,我們可以得出結論,產生死鎖可能性的最根本原因是:執行緒在獲得一個鎖L1的情況下再去申請另外一個鎖L2,也就是鎖L1想要包含了鎖L2,也就是說在獲得了鎖L1,並且沒有釋放鎖L1的情況下,又去申請獲得鎖L2,這個是產生死鎖的最根本原因

。另一個原因是預設的鎖申請操作是阻塞的

2. Java中如何避免死鎖

既然我們知道了產生死鎖可能性的原因,那麼就可以在編碼時進行規避。Java是面向物件的程式語言,程式的最小單元是物件,物件封裝了資料和操作,所以Java中的鎖一般也是以物件為單位的,物件的內建鎖保護物件中的資料的併發訪問。所以如果我們能夠避免在物件的同步方法中呼叫其它物件的同步方法,那麼就可以避免死鎖產生的可能性。如下所示的程式碼,就存在死鎖的可能性:

複製程式碼
public class ClassB {
    private String address;
    // ...
    
    public synchronized
void method1(){ // do something } // ... ... }
複製程式碼 複製程式碼
public class ClassA {
    private int id;
    private String name;
    private ClassB b;
    // ...
    
    public synchronized void m1(){
        // do something
        b.method1();
    }
    // ... ...
}
複製程式碼

上面的ClassA.m1()方法,在物件的同步方法中又呼叫了ClassB的同步方法method1(),所以存在死鎖發生的可能性。我們可以修改如下,避免死鎖:

複製程式碼
public class ClassA {
    private int id;
    private String name;
    private ClassB b;
    // ...
    
    public void m2(){
        synchronized(this){
            // do something
        }
        b.method1();
    }
    // ... ...
}
複製程式碼

這樣的話減小了鎖定的範圍,兩個鎖的申請就沒有發生交叉,避免了死鎖的可能性,這是最理性的情況,因為鎖沒有發生交叉。但是有時是不允許我們這樣做的。此時,如果只有ClassA中只有一個m1這樣的方法,需要同時獲得兩個物件上的鎖,並且不會將例項屬性 b 溢位(return b;),而是將例項屬性 b 封閉在物件中,那麼也不會發生死鎖。因為無法形成死鎖的閉環。但是如果ClassA中有多個方法需要同時獲得兩個物件上的鎖,那麼這些方法就必須以相同的順序獲得鎖。

比如銀行轉賬的場景下,我們必須同時獲得兩個賬戶上的鎖,才能進行操作,兩個鎖的申請必須發生交叉。這時我們也可以打破死鎖的那個閉環,在涉及到要同時申請兩個鎖的方法中,總是以相同的順序來申請鎖,比如總是先申請 id 大的賬戶上的鎖 ,然後再申請 id 小的賬戶上的鎖,這樣就無法形成導致死鎖的那個閉環。

複製程式碼
public class Account {
    private int id;    // 主鍵
    private String name;
    private double balance;
    
    public void transfer(Account from, Account to, double money){
        if(from.getId() > to.getId()){
            synchronized(from){
                synchronized(to){
                    // transfer
                }
            }
        }else{
            synchronized(to){
                synchronized(from){
                    // transfer
                }
            }
        }
    }

    public int getId() {
        return id;
    }
}
複製程式碼

這樣的話,即使發生了兩個賬戶比如 id=1的和id=100的兩個賬戶相互轉賬,因為不管是哪個執行緒先獲得了id=100上的鎖,另外一個執行緒都不會去獲得id=1上的鎖(因為他沒有獲得id=100上的鎖),只能是哪個執行緒先獲得id=100上的鎖,哪個執行緒就先進行轉賬。這裡除了使用id之外,如果沒有類似id這樣的屬性可以比較,那麼也可以使用物件的hashCode()的值來進行比較。

上面我們說到,死鎖的另一個原因是預設的鎖申請操作是阻塞的,所以如果我們不使用預設阻塞的鎖,也是可以避免死鎖的。我們可以使用ReentrantLock.tryLock()方法,在一個迴圈中,如果tryLock()返回失敗,那麼就釋放以及獲得的鎖,並睡眠一小段時間。這樣就打破了死鎖的閉環。

比如:執行緒T1持有鎖L1並且申請獲得鎖L2,而執行緒T2持有鎖L2並且申請獲得鎖L3,而執行緒T3持有鎖L3並且申請獲得鎖L1

此時如果T3申請鎖L1失敗,那麼T3釋放鎖L3,並進行睡眠,那麼T2就可以獲得L3了,然後T2執行完之後釋放L2, L3,所以T1也可以獲得L2了執行完然後釋放鎖L1, L2,然後T3睡眠醒來,也可以獲得L1, L3了。打破了死鎖的閉環。

這些情況,都還是比較好處理的,因為它們都是相關的,我們很容易意識到這裡有發生死鎖的可能性,從而可以加以防備。很多情況的場景都不會很明顯的讓我們察覺到會存在發生死鎖的可能性。所以我們還是要注意:

一旦我們在一個同步方法中,或者說在一個鎖的保護的範圍中,呼叫了其它物件的方法時,就要十而分的小心

1)如果其它物件的這個方法會消耗比較長的時間,那麼就會導致鎖被我們持有了很長的時間;

2)如果其它物件的這個方法是一個同步方法,那麼就要注意避免發生死鎖的可能性了;

最好是能夠避免在一個同步方法中呼叫其它物件的延時方法和同步方法。如果不能避免,就要採取上面說到的編碼技巧,打破死鎖的閉環,防止死鎖的發生。同時我們還可以儘量使用“不可變物件”來避免鎖的使用,在某些情況下還可以避免物件的共享,比如 new 一個新的物件代替共享的物件,因為鎖一般是物件上的,物件不相同了,也就可以避免死鎖,另外儘量避免使用靜態同步方法,因為靜態同步相當於全域性鎖。還有一些封閉技術可以使用:比如堆疊封閉,執行緒封閉,ThreadLocal,這些技術可以減少物件的共享,也就減少了死鎖的可能性。

總結一下

     死鎖的根本原因1)是多個執行緒涉及到多個鎖,這些鎖存在著交叉,所以可能會導致了一個鎖依賴的閉環;2)預設的鎖申請操作是阻塞的所以要避免死鎖,就要在一遇到多個物件鎖交叉的情況,就要仔細審查這幾個物件的類中的所有方法,是否存在著導致鎖依賴的環路的可能性。要採取各種方法來杜絕這種可能性。