在幾乎所有程式設計語言中,由于多線程引發的錯誤都有着難以再現的特點,程式的死鎖或其它多線程錯誤可能隻在某些特殊的情形下才出現,或在不同的VM上運作同一個程式時錯誤表現不同。是以,在編寫多線程程式時,事先認識和防範可能出現的錯誤特别重要。無論是用戶端還是伺服器端多線程Java程式,最常見的多線程問題包括死鎖、隐性死鎖和資料競争。
Java線程死鎖如何避免這一悲劇 Java線程死鎖需要如何解決,這個問題一直在我們不斷的使用中需要隻有不斷的關鍵。不幸的是,使用上鎖會帶來其他問題。讓我們來看一些常見問題以及相應的解決方法:
死鎖
死鎖是這樣一種情形:多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由于線程被無限期地阻塞,是以程式不可能正常終止。
導緻死鎖的根源在于不适當地運用“synchronized”關鍵詞來管理線程對特定對象的通路。“synchronized”關鍵詞的作用是,確定在某個時刻隻有一個線程被允許執行特定的代碼塊,是以,被允許執行的線程首先必須擁有對變量或對象的排他性的通路權。當線程通路對象時,線程會給對象加鎖,而這個鎖導緻其它也想通路同一對象的線程被阻塞,直至第一個線程釋放它加在對象上的鎖。
由于這個原因,在使用“synchronized”關鍵詞時,很容易出現兩個線程互相等待對方做出某個動作的情形。代碼一是一個導緻死鎖的簡單例子。
1class Deadlocker {
2 int field_1;
3 private Object lock_1 = new int[1];
4 int field_2;
5 private Object lock_2 = new int[1];
6
7 public void method1(int value) {
8 “synchronized” (lock_1) {
9 “synchronized” (lock_2) {
10 field_1 = 0; field_2 = 0;
11 }
12 }
13 }
14
15 public void method2(int value) {
16 “synchronized” (lock_2) {
17 “synchronized” (lock_1) {
18 field_1 = 0; field_2 = 0;
19 }
20 }
21 }
22 }
23
Java代碼
24 class Deadlocker {
25 int field_1;
26 private Object lock_1 = new int[1];
27 int field_2;
28 private Object lock_2 = new int[1];
29
30 public void method1(int value) {
31 “synchronized” (lock_1) {
32 “synchronized” (lock_2) {
33 field_1 = 0; field_2 = 0;
34 }
35 }
36 }
37
38 public void method2(int value) {
39 “synchronized” (lock_2) {
40 “synchronized” (lock_1) {
41 field_1 = 0; field_2 = 0;
42 }
43 }
44 }
45 }
參考代碼一,考慮下面的過程:
◆ 一個線程(ThreadA)調用method1()。
◆ThreadA在lock_1上同步,但允許被搶先執行。
◆ 另一個線程(ThreadB)開始執行。
◆ThreadB調用method2()。
◆ThreadB獲得lock_2,繼續執行,企圖獲得lock_1。但ThreadB不能獲得lock_1,因為ThreadA占有lock_1。
◆ 現在,ThreadB阻塞,因為它在等待ThreadA釋放lock_1。
◆ 現在輪到ThreadA繼續執行。ThreadA試圖獲得lock_2,但不能成功,因為lock_2已經被ThreadB占有了。
◆ThreadA和ThreadB都被阻塞,程式死鎖。
當然,大多數的死鎖不會這麼顯而易見,需要仔細分析代碼才能看出,對于規模較大的多線程程式來說尤其如此。好的線程分析工具,例如JProbe Threadalyzer能夠分析死鎖并指出産生問題的代碼位置。
Java線程死鎖是一個經典的多線程問題,因為不同的線程都在等待那些根本不可能被釋放的鎖,進而導緻所有的工作都無法完成。假設有兩個線程,分别代表兩個饑餓的人,他們必須共享刀叉并輪流吃飯。他們都需要獲得兩個鎖:共享刀和共享叉的鎖。
假如線程 “A”獲得了刀,而線程“B”獲得了叉。線程“A”就會進入阻塞狀态來等待獲得叉,而線程“B”則阻塞來等待“A”所擁有的刀。這隻是人為設計的例子,但盡管在運作時很難探測到,這類情況卻時常發生。雖然要探測或推敲各種情況是非常困難的,但隻要按照下面幾條規則去設計系統,就能夠避免Java線程死鎖問題:
讓所有的線程按照同樣的順序獲得一組鎖。這種方法消除了 X 和 Y 的擁有者分别等待對方的資源的問題。
将多個鎖組成一組并放到同一個鎖下。前面Java線程死鎖的例子中,可以建立一個銀器對象的鎖。于是在獲得刀或叉之前都必須獲得這個銀器的鎖。
将那些不會阻塞的可獲得資源用變量标志出來。當某個線程獲得銀器對象的鎖時,就可以通過檢查變量來判斷是否整個銀器集合中的對象鎖都可獲得。如果是,它就可以獲得相關的鎖,否則,就要釋放掉銀器這個鎖并稍後再嘗試。
最重要的是,在編寫代碼前認真仔細地設計整個系統。多線程是困難的,在開始程式設計之前詳細設計系統能夠幫助你避免難以發現Java線程死鎖的問題。我們再舉一個例子
Volatile變量,volatile關鍵字是
Java 語言為優化編譯器設計的。以下面的代碼為例:
一個優化的編譯器可能會判斷出if部分的語句永遠不會被執行,就根本不會編譯這部分的代碼。如果這個類被多線程通路, flag被前面某個線程設定之後,在它被if語句測試之前,可以被其他線程重新設定。用volatile關鍵字來聲明變量,就可以告訴編譯器在編譯的時候,不需要通過預測變量值來優化這部分的代碼。
無法通路的Java線程死鎖有時候雖然擷取對象鎖沒有問題,線程依然有可能進入阻塞狀态。在 Java 程式設計中IO就是這類問題最好的例子。當線程因為對象内的IO調用而阻塞時,此對象應當仍能被其他線程通路。該對象通常有責任取消這個阻塞的IO操作。造成阻塞調用的線程常常會令同步任務失敗。如果該對象的其他方法也是同步的,當線程被阻塞時,此對象也就相當于被冷凍住了。
其他的線程由于不能獲得對象的Java線程死鎖,就不能給此對象發消息(例如,取消 IO 操作)。必須確定不在同步代碼中包含那些阻塞調用,或确認在一個用同步阻塞代碼的對象中存在非同步方法。盡管這種方法需要花費一些注意力來保證結果代碼安全運作,但它允許在擁有對象的線程發生阻塞後,該對象仍能夠響應其他線程。
隐性死鎖
隐性死鎖由于不規範的程式設計方式引起,但不一定每次測試運作時都會出現程式死鎖的情形。由于這個原因,一些隐性死鎖可能要到應用正式釋出之後才會被發現,是以它的危害性比普通死鎖更大。下面介紹兩種導緻隐性死鎖的情況:加鎖次序和占有并等待。
加鎖次序
當多個并發的線程分别試圖同時占有兩個鎖時,會出現加鎖次序沖突的情形。如果一個線程占有了另一個線程必需的鎖,就有可能出現死鎖。考慮下面的情形,ThreadA和ThreadB兩個線程分别需要同時擁有lock_1、lock_2兩個鎖,加鎖過程可能如下:
◆ThreadA獲得lock_1;
◆ThreadA被搶占,VM排程程式轉到ThreadB;
◆ThreadB獲得lock_2;
◆ThreadB被搶占,VM排程程式轉到ThreadA;
◆ThreadA試圖獲得lock_2,但lock_2被ThreadB占有,是以ThreadA阻塞;
◆ 排程程式轉到ThreadB;
◆ThreadB試圖獲得lock_1,但lock_1被ThreadA占有,是以ThreadB阻塞;
◆ThreadA和ThreadB死鎖。
必須指出的是,在代碼絲毫不做變動的情況下,有些時候上述死鎖過程不會出現,VM排程程式可能讓其中一個線程同時獲得lock_1和lock_2兩個鎖,即線程擷取兩個鎖的過程沒有被中斷。在這種情形下,正常的死鎖檢測很難确定錯誤所在。
占有并等待
如果一個線程獲得了一個鎖之後還要等待來自另一個線程的通知,可能出現另一種隐性死鎖,考慮代碼二。
在代碼二中,Producer向隊列加入一項新的内容後通知Consumer,以便它處理新的内容。問題在于,Consumer可能保持加在隊列上的鎖,阻止Producer通路隊列,甚至在Consumer等待Producer的通知時也會繼續保持鎖。這樣,由于Producer不能向隊列添加新的内容,而Consumer卻在等待Producer加入新内容的通知,結果就導緻了死鎖。
在等待時占有的鎖是一種隐性的死鎖,這是因為事情可能按照比較理想的情況發展—Producer線程不需要被Consumer占據的鎖。盡管如此,除非有絕對可靠的理由肯定Producer線程永遠不需要該鎖,否則這種程式設計方式仍是不安全的。有時“占有并等待”還可能引發一連串的線程等待,例如,線程A占有線程B需要的鎖并等待,而線程B又占有線程C需要的鎖并等待等。
要改正代碼二的錯誤,隻需修改Consumer類,把wait()移出“synchronized”()即可。
是以避免死鎖的一個通用的經驗法則是:當幾個線程都要通路共享資源A、B、C時,保證使每個線程都按照同樣的順序去通路它們,比如都先通路A,在通路B和C。
此外,Thread類的suspend()方法也很容易導緻死鎖,是以這個方法已經被廢棄了.
資料競争
資料競争是由于通路共享資源(例如變量)時缺乏或不适當地運用同步機制引起。如果沒有正确地限定某一時刻某一個線程可以通路變量,就會出現資料競争,此時赢得競争的線程獲得通路許可,但會導緻不可預知的結果。
由于線程的運作可以在任何時候被中斷(即運作機會被其它線程搶占),是以不能假定先開始運作的線程總是比後開始運作的線程先通路到兩者共享的資料。另外,在不同的VM上,線程的排程方式也可能不同,進而使資料競争問題更加複雜。
有時,資料競争不會影響程式的最終運作結果,但在另一些時候,有可能導緻不可預料的結果。
良性資料競争
并非所有的資料競争都是錯誤。考慮代碼三的例子。假設getHouse()向所有的線程傳回同一House,可以看出,這裡會出現競争:BrickLayer從House.foundationReady_讀取,而
FoundationPourer寫入到House.foundationReady_。
盡管存在競争,但根據Java VM規範,Boolean資料的讀取和寫入都是原則性的,也就是說,VM不能中斷線程的讀取或寫入操作。一旦資料改動成功,不存在将它改回原來資料的必要(不需要“回退”),是以代碼三的資料競争是良性競争,代碼是安全的。
惡性資料競争
首先看一下代碼四的例子。
如果丈夫A和妻子B試圖通過不同的銀行櫃員機同時向同一賬戶存錢,會發生什麼事情?讓我們假設賬戶的初始餘額是100元,看看程式的一種可能的執行經過。
B存錢25元,她的櫃員機開始執行deposit()。首先取得目前餘額100,把這個餘額儲存在本地的臨時變量,然後把臨時變量加25,臨時變量的值變成125。現在,在調用setBalance()之前,線程排程器中斷了該線程。
A存入50元。當B的線程仍處于挂起狀态時,A這面開始執行deposit
():getBalance()傳回100(因為這時B的線程尚未把修改後的餘額寫入),A的線程在現有餘額的基礎上加50得到150,并把150這個值儲存到臨時變量。接着,A的線程在調用setBalance()之前,也被中斷執行。
現在,B的線程接着運作,把儲存在臨時變量中的值(125)寫入到餘額,櫃員機告訴B說交易完成,賬戶餘額是125元。接下來,A的線程繼續運作,把臨時變量的值(150)寫入到餘額,櫃員機告訴A說交易完成,賬戶餘額是150元。
最後得到的結果是什麼?B的存款消失不見,就像B根本沒有存過錢一樣。
也許有人會認為,可以把getBalance()和setBalance()改成同步方法保護Account.balance_,解決資料競争問題。其實這種辦法是行不通的。“synchronized”關鍵詞可以確定同一時刻隻有一個線程執行getBalance()或setBalance()方法,但這不能在一個線程操作期間阻止另一個線程修改賬戶餘額。
要正确運用“synchronized”關鍵詞,就必須認識到這裡要保護的是整個交易過程不被另一個線程幹擾,而不僅僅是對資料通路的某一個步驟進行保護。
是以,本例的關鍵是當一個線程獲得目前餘額之後,要保證其它的線程不能修改餘額,直到第一個線程的餘額處理工作全部完成。正确的修改方法是把deposit()和withdraw()改成同步方法。
死鎖、隐性死鎖和資料競争是Java多線程程式設計中最常見的錯誤。要寫出健壯的多線程代碼,正确了解和運用“synchronized”關鍵詞是很重要的。另外,好的線程分析工具,例如JProbe
Threadalyzer,能夠極大地簡化錯誤檢測。對于分析那些不一定每次執行時都會出現的錯誤,分析工具尤其有用。