死結
死結是這樣一種情形:多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程式不可能正常終止。
導致死結的根源在於不適當地運用“synchronized”關鍵詞來管理線程對特定對象的訪問。“synchronized”關鍵詞的作用是,確保在某個時刻只有一個線程被允許執行特定的代碼塊,因此,被允許執行的線程首先必須擁有對變數或對象的排他性的訪問權。當線程訪問對象時,線程會給對象加鎖,而這個鎖導致其它也想訪問同一對象的線程被阻塞,直至第一個線程釋放它加在對象上的鎖。
由於這個原因,在使用“synchronized”關鍵詞時,很容易出現兩個線程互相等待對方做出某個動作的情形。代碼一是一個導致死結的簡單例子。
//代碼一
class Deadlocker {
int field_1;
private Object lock_1 = new int[1];
int field_2;
private Object lock_2 = new int[1];
public void method1(int value) {
“synchronized” (lock_1) {
“synchronized” (lock_2) {
field_1 = 0; field_2 = 0;
}
}
}
public void method2(int value) {
“synchronized” (lock_2) {
“synchronized” (lock_1) {
field_1 = 0; field_2 = 0;
}
}
}
}
參考代碼一,考慮下面的過程:
◆ 一個線程(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能夠分析死結並指出產生問題的代碼位置。
隱性死結
隱性死結由於不規範的編程方式引起,但不一定每次測試回合時都會出現程式死結的情形。由於這個原因,一些隱性死結可能要到應用正式發布之後才會被發現,因此它的危害性比普通死結更大。下面介紹兩種導致隱性死結的情況:加鎖次序和佔有並等待。
加鎖次序
當多個並發的線程分別試圖同時佔有兩個鎖時,會出現加鎖次序衝突的情形。如果一個線程佔有了另一個線程必需的鎖,就有可能出現死結。考慮下面的情形,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兩個鎖,即線程擷取兩個鎖的過程沒有被中斷。在這種情形下,常規的死結檢測很難確定錯誤所在。
佔有並等待
如果一個線程獲得了一個鎖之後還要等待來自另一個線程的通知,可能出現另一種隱性死結,考慮代碼二。
//代碼二
public class queue {
static java.lang.Object queueLock_;
Producer producer_;
Consumer consumer_;
public class Producer {
void produce() {
while (!done) {
“synchronized” (queueLock_) {
produceItemAndAddItToQueue();
“synchronized” (consumer_) {
consumer_.notify();
}
}
}
}
public class Consumer {
consume() {
while (!done) {
“synchronized” (queueLock_) {
“synchronized” (consumer_) {
consumer_.wait();
}
removeItemFromQueueAndProcessIt();
}
}
}
}
}
}
在代碼二中,Producer向隊列加入一項新的內容後通知Consumer,以便它處理新的內容。問題在於,Consumer可能保持加在隊列上的鎖,阻止Producer訪問隊列,甚至在Consumer等待Producer的通知時也會繼續保持鎖。這樣,由於Producer不能向隊列添加新的內容,而Consumer卻在等待Producer加入新內容的通知,結果就導致了死結。
在等待時佔有的鎖是一種隱性的死結,這是因為事情可能按照比較理想的情況發展—Producer線程不需要被Consumer佔據的鎖。儘管如此,除非有絕對可靠的理由肯定Producer線程永遠不需要該鎖,否則這種編程方式仍是不安全的。有時“佔有並等待”還可能引發一連串的線程等待,例如,線程A佔有線程B需要的鎖並等待,而線程B又佔有線程C需要的鎖並等待等。
要改正代碼二的錯誤,只需修改Consumer類,把wait()移出“synchronized”()即可。
開啟JAVA死結之迷
在談到線程死結的時候,我們首先必須瞭解什麼是Java線程。一個程式的進程會包含多個線程,一個線程就是運行在一個進程中的一個邏輯流。多線程允許在程式中並發執行多個指令流,每個指令流都稱為一個線程,彼此間互相獨立。
線程又稱為輕量級進程,它和進程一樣擁有獨立的執行控制,由作業系統負責調度,區別在於線程沒有獨立的儲存空間,而是和所屬進程中的其它線程共用一個儲存空間,這使得線程間的通訊較進程簡單。筆者的經驗是編寫多線程式,必須注意每個線程是否幹擾了其他線程的工作。每個進程開始生命週期時都是單一線程,稱為“主線程”,在某一時刻主線程會建立一個對等線程。如果主線程停滯則系統就會切換到其對等線程。和一個進程相關的線程此時會組成一個對等線程池,一個線程可以殺死其任意對等線程。
因為每個線程都能讀寫相同的共用資料。這樣就帶來了新的麻煩:由於資料共用會帶來同步問題,進而會導致死結的產生。
二. 死結的機制
由多線程帶來的效能改善是以可靠性為代價的,主要是因為有可能產生線程死結。死結是這樣一種情形:多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程式不能正常運行。簡單的說就是:線程死結時,第一個線程等待第二個線程釋放資源,而同時第二個線程又在等待第一個線程釋放資源。這裡舉一個通俗的例子:如在行人路上兩個人迎面相遇,為了給對方讓道,兩人同時向一側邁出一步,雙方無法通過,又同時向另一側邁出一步,這樣還是無法通過。假設這種情況一直持續下去,這樣就會發生死結現象。
導致死結的根源在於不適當地運用“synchronized”關鍵詞來管理線程對特定對象的訪問。“synchronized”關鍵詞的作用是,確保在某個時刻只有一個線程被允許執行特定的代碼塊,因此,被允許執行的線程首先必須擁有對變數或對象的排他性訪問權。當線程訪問對象時,線程會給對象加鎖,而這個鎖導致其它也想訪問同一對象的線程被阻塞,直至第一個線程釋放它加在對象上的鎖。
Java中每個對象都有一把鎖與之對應。但Java不提供單獨的lock和unlock操作。下面筆者分析死結的兩個過程“上鎖”和“鎖死” 。
1
(1) 上鎖
許多線程在執行中必須考慮與其他線程之間共用資料或協調執行狀態,就需要同步機制。因此大多數應用程式要求線程互相通訊來同步它們的動作,在 Java 程式中最簡單實現同步的方法就是上鎖。在 Java 編程中,所有的對象都有鎖。線程可以使用 synchronized 關鍵字來獲得鎖。在任一時刻對於給定的類的執行個體,方法或同步的代碼塊只能被一個線程執行。這是因為代碼在執行之前要求獲得對象的鎖。
為了防止同時訪問共用資源,線程在使用資源的前後可以給該資源上鎖和開鎖。給共用變數上鎖就使得 Java 線程能夠快速方便地通訊和同步。某個線程若給一個對象上了鎖,就可以知道沒有其他線程能夠訪問該對象。即使在搶佔式模型中,其他線程也不能夠訪問此對象,直到上鎖的線程被喚醒、完成工作並開鎖。那些試圖訪問一個上鎖對象的線程通常會進入睡眠狀態,直到上鎖的線程開鎖。一旦鎖被開啟,這些睡眠進程就會被喚醒並移到準備就緒隊列中。
(2)鎖死
如果程式中有幾個競爭資源的並發線程,那麼保證均衡是很重要的。系統均衡是指每個線程在執行過程中都能充分訪問有限的資源,系統中沒有餓死和死結的線程。當多個並發的線程分別試圖同時佔有兩個鎖時,會出現加鎖衝突的情形。如果一個線程佔有了另一個線程必需的鎖,互相等待時被阻塞就有可能出現死結。
在編寫多線程代碼時,筆者認為死結是最難處理的問題之一。因為死結可能在最意想不到的地方發生,所以尋找和修正它既費時又費力。例如,常見的例子如下面這段程式。
public int sumArrays(int[] a1, int[] a2)
...{
int value = 0;
int size = a1.length;
if (size == a2.length) ...{
synchronized(a1) ...{ //1
synchronized(a2) ...{ //2
for (int i=0; i<size; i++)
value += a1[i] + a2[i];
}
}
}
return value;
}
這段代碼在求和操作中訪問兩個數組對象之前鎖定了這兩個數組對象。它形式簡短,編寫也適合所要執行的任務;但不幸的是,它有一個潛在的問題。這個問題就是它埋下了死結的種子。
三. 如何檢測死結的根源
Java並不提供對死結的檢測機制。筆者認為常用分析Java代碼問題的最有效工具仍然是java thread dump。當死結發生時,JVM通常處於掛起狀態,thread dump可以給出靜態穩定的資訊,尋找死結只需要尋找有問題的線程。Java虛擬機器死結發生時,從作業系統上觀察,虛擬機器的CPU佔用率為零,很快會從top或prstat的輸出中消失。這時可以收集thread dump,尋找"waiting for monitor entry"的thread,如果大量thread都在等待給同一個地址上鎖(因為對於Java,一個對象只有一把鎖),這說明很可能死結發生了。
為了確定問題,筆者建議在隔幾分鐘後再次收集一次thread dump,如果得到的輸出相同,仍然是大量thread都在等待給同一個地址上鎖,那麼肯定是死結了。如何找到當前持有鎖的線程是解決問題的關鍵。一般方法是搜尋thread dump,尋找"locked,找到持有鎖的線程。如果持有鎖的線程還在等待給另一個對象上鎖,那麼還是按上面的辦法順藤摸瓜,直到找到死結的根源為止。
另外,在thread dump裡還會經常看到這樣的線程,它們是等待一個條件而主動放棄鎖的線程。有時也需要分析這類線程,尤其是線程等待的條件。
四. 幾種常見死結及對策
解決死結沒有簡單的方法,這是因為線程產生死結都各有各的原因,而且往往具有很高的負載。大多數軟體測試產生不了足夠多的負載,所以不可能暴露所有的線程錯誤。在這裡中,筆者將討論開發過程常見的4類典型的死結和解決對策。
(1)資料庫死結
在資料庫中,如果一個串連佔用了另一個串連所需的資料庫鎖,則它可以阻塞另一個串連。如果兩個或兩個以上的串連相互阻塞,則它們都不能繼續執行,這種情況稱為資料庫死結。
資料庫死結問題不易處理,通常資料行進行更新時,需要鎖定該資料行,執行更新,然後在提交或復原封閉事務時釋放鎖。由於資料庫平台、配置的隔離級以及查詢提示的不同,擷取的鎖可能是細粒度或粗粒度的,它會阻塞(或不阻塞)其他對同一資料行、表或資料庫的查詢。基於資料庫模式,讀寫操作會要求遍曆或更新多個索引、驗證約束、執行觸發器等。每個要求都會引入更多鎖。此外,其他應用程式還可能正在訪問同一資料庫模式中的某些對象,並擷取不同應用程式所具有的鎖。
所有這些因素綜合在一起,資料庫死結幾乎不可能被消除了。值得慶幸的是,資料庫死結通常是可恢複的:當資料庫發現死結時,它會強制銷毀一個串連(通常是使用最少的串連),並復原其事務。這將釋放所有與已經結束的事務相關聯的鎖,至少允許其他串連中有一個可以擷取它們正在被阻塞的鎖。
由於資料庫具有這種典型的死結處理行為,所以當出現資料庫死結問題時,資料庫常常只能重試整個事務。當資料庫連接被銷毀時,會拋出可被應用程式捕獲的異常,並標識為資料庫死結。如果允許死結異常傳播到初始化該事務的代碼層之外,則該代碼層可以啟動一個新事務並重做先前所有工作。
當出現問題就重試,由於資料庫可以自由地擷取鎖,所以幾乎不可能保證兩個或兩個以上的線程不發生資料庫死結。此方法至少能保證在出現某些資料庫死結情況時,應用程式能正常運行。
(2)資源集區耗盡死結
用戶端的增加導致資源集區耗盡死結是由於負載而造成的,即資源集區太小,而每個線程需要的資源超過了池中的可用資源。假設串連池最多有10個串連,同時有10個對外部並發調用。這些線程中每一個都需要一個資料庫連接用來清空池。現在,每個線程都執行嵌套的調用。則所有線程都不能繼續,但又都不放棄自己的第一個資料庫連接。這樣,10個線程都將被死結。
研究此類死結,會發現線程儲存中有大量等待擷取資源的線程,以及同等數量的空閑且未阻塞的活動資料庫串連。當應用程式死結時,如果可以在運行時檢測串連池,就能確認串連池實際上已空。
修複此類死結的方法包括:增加串連池的大小或者重構代碼,以便單個線程不需要同時使用很多資料庫連接。或者可以設定內部調用使用不同的串連池,即使外部調用的串連池為空白,內部調用也能使用自己的串連池繼續。
(3)單線程、多衝突資料庫連接死結
對同一線程執行嵌套的調用有時出現死結,此情形即使在非高負載系統中通常也會發生。當第一個(外部)串連已擷取第二個(內部)串連所需要的資料庫鎖,則第二個串連將永久阻塞第一個串連,並等待第一個串連被提交或復原,這就出現了死結情形。因為資料庫沒有注意到兩個串連之間的關係,所以資料庫不會將此情形檢測為死結。這樣即使不存在並發,此代碼也將導致死結。此情形有多種具體的變種,可以涉及多個線程和兩個以上的資料庫連接。
(4)Java虛擬機器鎖與資料庫鎖衝突
這種情形發生在資料庫鎖與Java虛擬機器鎖並存的時候。在這種情況下,一個線程佔有一個資料庫鎖並嘗試擷取Java虛擬機器鎖。同時,另一個線程佔有Java虛擬機器鎖並嘗試擷取資料庫鎖。此時,資料庫發現一個串連阻塞了另一個串連,但由於無法阻止串連繼續,所以不會檢測到死結。Java虛擬機器發現同步的鎖中有一個線程,並有另一個嘗試進入的線程,所以即使Java虛擬機器能檢測到死結並對它們進行處理,它還是不會檢測到這種情況。
總而言之,JAVA應用程式中的死結是一個大問題——它能導致整個應用程式慢慢終止,還很難被分離和修複,尤其是當開發人員不熟悉如何分析死結環境的時候。
五. 死結的經驗法則
筆者在開發中總結以下死結問題的經驗。
(1) 對大多數的Java程式員來說最簡單的防止死結的方法是對競爭的資源引入序號,如果一個線程需要幾個資源,那麼它必須先得到小序號的資源,再申請大序號的資源。可以在Java代碼中增加同步關鍵字的使用,這樣可以減少死結,但這樣做也會影響效能。如果負載過重,資料庫內部也有可能發生死結。
(2)瞭解資料庫鎖的發生行為。假定任何資料庫訪問都有可能陷入資料庫死結狀況,但是都能正確進行重試。例如瞭解如何從應用伺服器擷取完整的線程轉儲以及從資料庫擷取資料庫連接列表(包括互相阻塞的串連),知道每個資料庫連接與哪個Java線程相關聯。瞭解Java線程和資料庫連接之間映射的最簡單方法是向串連池訪問模式添加日誌記錄功能。
(3)當進行嵌套的調用時,瞭解哪些調用使用了與其它調用同樣的資料庫連接。即使嵌套調用運行在同一個全域事務中,它仍將使用不同的資料庫連接,而不會導致嵌套死結。
(4)確保在峰值並發時有足夠大的資源集區。
(5)避免執行資料庫調用或在佔有Java虛擬機器鎖時,執行其他與Java虛擬機器無關的操作。
最重要的是,多線程設計雖然是困難的,但在開始編程之前詳細設計系統能夠協助你避免難以發現死結的問題。死結在語言層面上不能解決,就需要一個良好設計來避免死結。