讀古今文學網 > 編寫高質量代碼:改善Java程序的151個建議 > 建議128:預防線程死鎖 >

建議128:預防線程死鎖

線程死鎖(DeadLock)是多線程編碼中最頭疼的問題,也是最難重現的問題,因為Java是單進程多線程語言,一旦線程死鎖,則很難通過外科手術式的方法使其起死回生,很多時候只有借助外部進程重啟應用才能解決問題。我們看看下面的多線程代碼是否會產生死鎖:


class Foo implements Runnable{

public void run(){

//執行遞歸函數

fun(10);

}

//遞歸函數

public synchronized void fun(int i){

if(--i>0){

for(int j=0;j<i;j++){

System.out.print(\"*\");

}

System.out.println(i);

fun(i);

}

}

}


注意fun方法是一個遞歸函數,而且還加上了synchronized關鍵字,它保證同時只有一個線程能夠執行,想想synchronized關鍵字的作用:當一個帶有synchronized關鍵字的方法在執行時,其他synchronized方法會被阻塞,因為線程持有該對象的鎖。比如有這樣的代碼:


static class Foo{

public synchronized void m1(){

try{

Thread.sleep(1000);

}catch(InterruptedException e){

//異常處理

}

System.out.println(\"m1執行完畢\");

}

public synchronized void m2(){

System.out.println(\"m2執行完畢\");

}}

public static void main(Stringargs)throws Exception{

final Foo foo=new Foo();

//定義一個線程

Thread t=new Thread(new Runnable(){

public void run(){

foo.m1();

}

});

t.start();

//等待t1線程啟動完畢

Thread.sleep(10);

//m2方法需要等待m1執行完畢

foo.m2();

}


相信讀者明白會先輸出「m1執行完畢」,然後再輸出「m2執行完畢」,因為m1方法在執行時,線程t持有foo對象的鎖,要想主線程獲得m2方法的執行權限就必須等待m1方法執行完畢,也就是釋放當前鎖。明白了這個問題,我們思考一下上例中帶有synchronized的遞歸函數是否能執行?會不會產生死鎖?運行結果如下:


*********9

********8

*******7

******6

*****5

****4

***3

**2

*1


一個倒三角形,沒有產生死鎖,正常執行,這是為何呢?很奇怪,是嗎?那是因為在運行時當前線程(Thread-0)獲得了foo對象的鎖(synchronized雖然是標注在方法上的,但實際作用的是整個對象),也就是該線程持有了foo對象的鎖,所以它可以多次重入fun方法,也就是遞歸了。可以這樣來思考該問題,一個寶箱有N把鑰匙,分別由N個海盜持有(也就是我們Java中的線程了),但是同一時間只能由一把鑰匙打開寶箱,獲取寶物,只有在上一個海盜關閉了寶箱(釋放鎖)後,其他海盜才能繼續打開鎖獲取寶物,這裡還有一個規則:一旦一個海盜打開了寶箱,則該寶箱內的所有寶物對他來說都是開放的,即使是「寶箱中的寶箱」(即內箱)對他也是開放的。可以用以下代碼來表述。


class Foo implements Runnable{

public void run(){

method1();

}

public synchronized void method1(){

method2();

}

public synchronized void method2(){

//Do Something

}

}


方法method1是synchronized修飾的,方法method2也是synchronized修飾的,method1調用method2是沒有任何問題的,因為是同一個線程持有對象鎖,在一個線程內多個synchronized方法重入完全是可行的,此種情況下不會產生死鎖。

那什麼情況下會產生死鎖呢?看如下代碼:


//資源A

static class A{

public synchronized void a1(B b){

String name=Thread.currentThread().getName();

System.out.println(name+\"進入A.a1()\");

try{

//休眠1秒,仍然持有鎖

Thread.sleep(1000);

}catch(Exception e){

//異常處理

}

System.out.println(name+\"試圖訪問B.b2()\");

b.b2();

}

public synchronized void a2(){

System.out.println(\"進入a.a2()\");

}

}

//資源B

static class B{

public synchronized void b1(A a){

String name=Thread.currentThread().getName();

System.out.println(name+\"進入B.b1()\");

try{

//休眠1秒,仍然持有鎖

Thread.sleep(1000);

}catch(Exception e){

//異常處理

}

System.out.println(name+\"試圖訪問A.a2()\");

a.a2();

}

public synchronized void b2(){

System.out.println(\"進入B.b2()\");

}

}

public static void main(String args){

final A a=new A();

final B b=new B();

//線程A

new Thread(new Runnable(){

public void run(){

a.a1(b);

}

},\"線程A\").start();

//線程B

new Thread(new Runnable(){

public void run(){

b.b1(a);

}

},\"線程B\").start();

}


此段程序定義了兩個資源A和B,然後在兩個線程A、B中使用了該資源,由於兩個資源之間有交互操作,並且都是同步方法,因此在線程A休眠1秒鐘後,它會試圖訪問資源B的b2方法,但是線程B持有該類的鎖,並同時在等待A線程釋放其鎖資源,所以此時就出現了兩個線程在互相等待釋放資源的情況,也就是死鎖了,運行結果如下:


線程A進入A.a1()

線程B進入B.b1()

線程B試圖訪問A.a2()

線程A試圖訪問B.b2()


此種情況下,線程A和線程B會一直互等下去,直到有外界干擾為止,比如終止一個線程,或者某一線程自行放棄資源的爭搶,否則這兩個線程就始終處於死鎖狀態了。我們知道要達到線程死鎖需要四個條件:

互斥條件:一個資源每次只能被一個線程使用。

資源獨佔條件:一個線程因請求資源而阻塞時,對已獲得的資源保持不放。

不剝奪條件:線程已獲得的資源在未使用完之前,不能強行剝奪。

循環等待條件:若幹線程之間形成一種頭尾相接的循環等待資源關係。

只有滿足了這些條件才可能產生線程死鎖,這也同時告誡我們如果要解決線程死鎖問題,就必須從這四個條件入手,一般情況下可以按照以下兩種方式來解決:

(1)避免或減少資源共享

一個資源被多個線程共享,若採用了同步機制,則產生的死鎖可能性很大,特別是在項目比較龐大的情況下,很難杜絕死鎖,對此最好的解決辦法就是減少資源共享。

例如一個B/S結構的辦公系統可以完全忽略資源共享,這是因為此類系統有三個特徵:一是並發訪問不會太高,二是讀操作多於寫操作,三是數據質量要求比較低,因此即使出現數據資源不同步的情況也不可能產生太大的影響,完全可以不使用同步技術。但是如果是一個支付清算系統就必須慎重考慮資源同步問題了,因為此類系統一是數據質量要求非常高(如果產生數據不同步的情況那可是重大生產事故),二是並發量大,不設置數據同步則會產生非常多的運算邏輯失效的情況,這會導致交易失敗,產生大量的「髒」數據,系統可靠性將大大降低。

(2)使用自旋鎖

回到前面的例子,線程A在等待線程B釋放資源,而線程B又在等待線程A釋放資源,僵持不下,那如果線程B設置了超時時間是不是就可以解決該死鎖問題了呢?比如線程B在等待2秒後還是無法獲得資源,則自行終結該任務,代碼如下:


public void b2(){

try{

//立刻獲得鎖,或者2秒等待鎖資源

if(lock.tryLock(2,TimeUnit.SECONDS)){

System.out.println(\"進入B.b2()\");

}

}catch(InterruptedException e){

//異常處理

}finally{

//釋放鎖

lock.unlock();

}

}


上面代碼中使用tryLock實現了自旋鎖(Spin Lock),它跟互斥鎖一樣,如果一個執行單元要想訪問被自旋鎖保護的共享資源,則必須先得到鎖,在訪問完共享資源後,也必須釋放鎖。如果在獲取自旋鎖時,沒有任何執行單元保持該鎖,那麼將立即得到鎖;如果在獲取自旋鎖時鎖已經有保持者,那麼獲取鎖操作將「自旋」在那裡,直到該自旋鎖的保持者釋放了鎖為止。在我們的例子中就是線程A等待線程B釋放鎖,在2秒內不斷嘗試是否能夠獲得鎖,達到2秒後還未獲得鎖資源,線程A則結束運行,線程B將獲得資源繼續執行,死鎖解除。

對於死鎖的描述最經典的案例是哲學家進餐(五位哲學家圍坐在一張圓形餐桌旁,人手一根筷子,做以下兩件事情:吃飯和思考。要求吃東西的時候停止思考,思考的時候停止吃東西,而且必須使用兩根筷子才能吃東西),解決此問題的方法很多,比如引入服務生(資源調度)、資源分級等方法都可以很好地解決此類死鎖問題。在我們Java多線程並發編程中,死鎖很難避免,也不容易預防,對付它的最好辦法是測試:提高測試覆蓋率,建立有效的邊界測試,加強資源監控,這些方法能使死鎖無處遁形,即使發生了死鎖現象也能迅速查找到原因,提高系統的可靠性。