1. 程式人生 > >關於J2EE中死鎖問題的研究

關於J2EE中死鎖問題的研究

2006-07-10 07:00 作者: Michael Nonemacher 出處: bea 

大多數重要的應用程式都涉及高度併發性和多個抽象層。併發性與資源爭用有關,並且是導致死鎖問題增多的因素之一。多個抽象層使隔離並修復死鎖環境的工作變得更加困難。

  通常,當同時執行兩個或兩個以上的執行緒時,如果每個執行緒都佔有一個資源並請求另一個資源,這時就會出現死鎖情況。因為如果一個執行緒不能獲取資源,則所有執行緒都不能繼續執行,我們稱那個特定的執行緒被阻塞;如果每個執行緒都由於同組中另一個執行緒所佔有的資源而被阻塞,我們就稱這個執行緒組被死鎖。

  在本文中,我們將討論發生在典型的重要J2EE應用程式中的兩大類死鎖情況:“簡單”資料庫死鎖和跨資源死鎖。雖然我們的討論基於J2EE平臺,但也適用於其他技術平臺。

  資料庫死鎖



  在資料庫中,如果一個連線佔用了另一個連線所需的資料庫鎖,則它可以阻塞另一個連線。如果兩個或兩個以上的連線相互阻塞,則它們都不能繼續執行,這種情況稱為死鎖。

  資料庫死鎖問題不易處理,這是因為涉及到的鎖定通常不是顯式的。通常,對資料行進行隱式更新時,需要鎖定該資料行,執行更新,然後在提交或回滾封閉事務時釋放鎖。由於資料庫平臺、配置的隔離級以及查詢提示的不同,獲取的鎖可能是細粒度或粗粒度的,它會阻塞(或不阻塞)其他對同一資料行、表或資料庫的查詢。

  獲取的鎖依賴於內部生成的查詢計劃。當資料大小和分步隨時間發生變化時,該計劃也可能改變。這樣在一個環境中獲取一組鎖的查詢可以嘗試在另一個環境中獲取一組完全不同的鎖。必要時,資料庫可以隨意地增加它的鎖。例如,資料庫可能會選擇鎖定整頁,而不是鎖定同一資料頁中的10個數據行,這會阻塞對無需鎖定的資料行的讀寫許可權。

  基於資料庫模式,讀寫操作會要求遍歷或更新多個索引、驗證約束、執行觸發器等。每個要求都會引入更多鎖。此外,其他應用程式還可能正在訪問同一資料庫模式中的某些物件,並獲取不同於您的應用程式所具有的鎖。

  所有這些因素綜合在一起,資料庫死鎖幾乎不可能被消除了。值得慶幸的是,資料庫死鎖通常是可恢復的:當資料庫發現死鎖時,它會強制銷燬一個連線(通常是使用最少的連線),並回滾其事務。這將釋放所有與已經結束的事務相關聯的鎖,至少允許其他連線中有一個可以獲取它們正在被阻塞的鎖。

  由於資料庫具有這種典型的死鎖處理行為,所以當出現數據庫死鎖問題時,資料庫常常只能重試整個事務。當資料庫連線被銷燬時,會丟擲可被應用程式捕獲的異常,並標識為資料庫死鎖情況。如果允許死鎖異常傳播到初始化該事務的程式碼層之外,則該程式碼層可以只啟動一個新事務並重做先前所有工作。要正確使用此策略,則在事務成功提交之前,它的程式碼不能有其他操作。注意:要限制重試次數,否則易導致死鎖的程式碼塊會永久迴圈下去。

  如果出現問題就重試,這種方法有點笨。但是,由於資料庫可以自由地獲取鎖,所以幾乎不可能保證兩個或兩個以上的執行緒不發生資料庫死鎖。此方法至少能保證在出現某些罕見的資料庫死鎖情況時,應用程式能正常執行。這比要求使用者去重試操作要好得多。

  在J2EE應用程式中,開發人員可以設定一個
EJB
呼叫以使用Bean託管事務(BMT)——開發人員啟動、提交或回滾特定的事務或容器託管事務(CMT)——呼叫方法前啟動事務,並在方法完成後提交或回滾事務。如果EJB供應商提供retry-on-deadlock引數,從而可以通過容器託管事務自動完成此操作,那當然再好不過了。如果沒有這種自動功能,開發人員最終將僅為了對死鎖進行重試而強制EJB呼叫使用Bean託管事務。

  遇到死鎖問題和鎖定其他執行緒的鎖的具體頻率在很大程度上取決於資料庫平臺、硬體、資料庫模式和查詢。在使用基於鎖的併發控制的資料庫(如MSSQL)中,未提交的寫操作會阻止讀操作,而未提交的讀操作會阻止寫操作,使資料庫更易出現死鎖問題。在多版本併發控制(MVCC)資料庫(如Oracle)中,未提交的寫操作不阻止讀操作——讀操作僅檢視舊版本資料行。這雖然會引入其他問題,但不會造成同樣多的死鎖機會。我們要讓自己熟悉這些資料庫鎖定模式,並注意自己正在使用的型別。

  在查詢、修復以及避免資料庫死鎖方面,有一些很好的參考方法,但它們都不能徹底消除死鎖的可能性。

  跨資源死鎖

  當死鎖情況不完全侷限於資料庫時,將更難找到它。資料庫對佔有和請求的鎖有識別能力,所以能檢測整個資料庫中的死鎖;此外,資料庫事務在確定哪些東西是原子、哪些不是方面提供了一個良好的界線,所以能輕鬆地回滾事務,使其從死鎖中恢復。其他環境(如Java虛擬機器)中的死鎖或可跨環境的死鎖更加危險,因為環境不能(或沒有)檢測到這些死鎖並嘗試恢復。更糟糕的是,這些死鎖會產生綜合效果——如果兩個執行緒佔有某些資源集時出現死鎖,則其他任何嘗試訪問其中一個資源的執行緒也將被阻塞,該執行緒已經獲取的所有資源也被阻塞。這些死鎖常常不易發現,但對常見模式有一定的瞭解將有助於識別和修復死鎖問題。

  當環境中出現可疑的死鎖情況時,您就需要考慮一些問題了。這些問題的答案將說明您正在處理的情形是下列情形中的哪一種(如果有的話),並提供了修復以下問題的詳細資訊。要考慮的一些重要事項包括:

  • 涉及什麼執行緒,它們的呼叫堆疊是什麼?這需要進行一些詳細的分析,將實際的死鎖執行緒從那些只是被死鎖的執行緒阻塞了的執行緒中分離出來。
  • 這種死鎖情況總是在特定的程式碼路徑中出現(每次執行這些特定的操作時),還是依賴於兩個或兩個以上同時執行的程式碼路徑呢?
  • 涉及的資料庫連線是什麼?每個連線佔有的資料庫鎖是什麼?每個連線嘗試獲取的資料庫鎖是什麼?每個資料庫連線響應的Java虛擬機器執行緒是什麼?

  下一小節介紹了三種常見的發生跨資源死鎖的情形。

跨資源死鎖情形之1:客戶端的增加導致資源池耗盡

  我們要介紹的第一種死鎖情形是單純由於負載而造成的,即資源池太小,而每個執行緒需要的資源超過了池中的可用資源。例如,考慮一個使用資料庫連線的EJB呼叫,執行一個巢狀的EJB呼叫(使用同一連線池中不同的資料庫連線)。例如,如果該巢狀的EJB呼叫宣告為RequiresNew,就會出現死鎖情形。

  在正常負載或者有足夠大小的連線池的情況下,EJB呼叫將從池中獲取一個數據庫連線,然後呼叫巢狀的EJB。巢狀的EJB呼叫將從池中獲取另一個數據庫連線,提交內部事務,然後向池返回連線。外部EJB呼叫將提交自己的事務,並向池返回其連線。

  但是,假設連線池最多有10個連線,同時有10個對外部EJB的併發呼叫。這些執行緒中每一個都需要一個數據庫連線用來清空池。現在,每個執行緒都執行巢狀的EJB呼叫(需要獲取第二個資料庫連線)。則所有執行緒都不能繼續,但又都不放棄自己的第一個資料庫連線。這樣,10個執行緒都將被死鎖。

  如果研究此類死鎖情形,會發現執行緒轉儲中有大量等待獲取資源的執行緒,以及同等數量的空閒且未阻塞的活動資料庫連線。當應用程式死鎖時,如果可以在執行時檢測連線池,應該能確認連線池實際上已空。

  修復此類死鎖的方法包括:增加連線池的大小或者重構程式碼,以便單個執行緒不需要同時使用很多資料庫連線。如果單執行緒需要的最大資料庫連線數為M,且可能的最大併發呼叫數為N,則要避免此問題,在池中所需的最小連線數為(N*(M01))+1。或者可以設定內部EJB呼叫以使用不同的連線池,即使外部呼叫的連線池為空,內部呼叫也能使用自己的連線池繼續。

  跨資源死鎖情形之2:單執行緒、多衝突資料庫連線

  對同一執行緒執行巢狀的EJB呼叫時還會出現第二種跨資源死鎖情形,此情形即使在非高負載系統中通常也會發生。同上面的示例一樣,兩個EJB呼叫使用不同的連線來連線到同一個資料庫。因為只有巢狀呼叫完成後呼叫方才能繼續,所以呼叫方的資料庫連線實際上被巢狀呼叫的資料庫連線阻塞了,雖然資料庫沒有注意到這種關係。如果第一個(外部)連線已獲取第二個(內部)連線所需要的資料庫鎖,則第二個連線將永久阻塞第一個連線,並等待第一個連線被提交或回滾,這就出現了死鎖情形。因為資料庫沒有注意到兩個連線之間的關係,所以資料庫不會將此情形檢測為死鎖。

  作為一個具體的示例,考慮一個數據載入EJB呼叫。此EJB呼叫獲取一個大型物件,並在不同階段中將其儲存在資料庫中。當它執行資料載入時,它會更新一個單獨的表,以記錄掛起資料載入操作的狀態。我們希望狀態更新立即可見,但不希望在未完成的狀態下看到載入的資料,所以要通過呼叫“RequiresNew” EJB來完成。總的來說,這種不完善的資料載入方法如清單1中的程式碼所示。

  清單1

public void bulkLoadData(DataBatch batch) { 
 int batchId = batch.getId(); 

 // Since this executeUpdate call doesn誸 happen in a separate 
 // transaction, it wouldn't be visible anyway, but the effect is 
 // far worse: a cross-resource deadlock. 
 executeUpdate("update batch_status set status='Started' " + 
  "where batch_id=" + batchId); 
 validateData(batch); 
 updateBatchStatus(batchId, "Validated"); // RequiresNew EJB call 
 loadDataStage1(batch); 
 updateBatchStatus(batchId, "Stage 1 complete"); // RequiresNew EJB call 
 loadDataStage2(batch); 
 updateBatchStatus(batchId, "Stage 2 complete"); // RequiresNew EJB call 
 finalizeDataLoad(batch); 
 updateBatchStatus(batchId, "Complete"); // RequiresNew EJB call 
} 

  在上面的示例中,使用updateBatchStatus方法執行“RequiresNew” EJB呼叫實際上可以更新batch_status資料庫表,即使沒有看到當前事務的效果,也能立即看到狀態的改變。對executeUpdate的呼叫不是EJB呼叫,所以它和bulkLoadData的其他部分在同一個事務中執行。

  如上所述,即使不存在併發,此程式碼也將導致死鎖。當bulkLoadData呼叫executeUpdate方法時,它更新現有的資料庫行,這涉及為該行獲取寫鎖。對updateBatchStatus的巢狀EJB呼叫將在單獨的資料庫連線上執行,並嘗試執行一個非常相似的查詢,但它將阻塞,因為不能獲取必需的寫鎖。從資料庫的角度來說,只要提交或回滾第一個連線的事務,第二個連線就可以繼續。但是,Java虛擬機器不允許在完成所有對updateBatchStatus的呼叫前完成bulkLoadD呼叫,這樣就出現了死鎖情形。

  該示例表明,一個更新會阻塞另一個更新,所以它會在任何資料庫中導致死鎖。如果初始更新查詢是一個簡單的選擇查詢,那麼該示例僅在使用基於鎖的併發控制的資料庫上導致死鎖,在這種資料庫中,一個連線的讀鎖可以阻止另一個連接獲取寫鎖。不管在哪種情況下,此類死鎖即不依賴於同步,也不依賴於負載,而且執行緒轉儲將顯示一個等待資料庫響應的Java執行緒,但該執行緒與兩個有效的資料庫連線相關聯。在這些資料庫連線中,有一個將處於空閒狀態,但會阻塞其他連線。

  此情形有多種具體的變種,可以涉及多個執行緒和兩個以上的資料庫連線。例如,外部EJB呼叫的資料庫連線可能已經獲取了資料庫鎖,該鎖阻塞了另一個無關資料庫連線的繼續,但這個無關資料庫連線已經獲取了阻塞巢狀EJB呼叫的資料庫操作的鎖。這個特例是依賴於同步的,並將顯示多個等待資料庫響應的Java執行緒。其中至少有一個Java執行緒將與兩個活動資料庫連線相關聯。

  跨資源死鎖情形之3:Java虛擬機器鎖與資料庫鎖相沖突

  第三種死鎖情形發生在資料庫鎖與Java虛擬機器鎖並存的時候。在這種情況下,一個執行緒佔有一個數據庫鎖並嘗試獲取Java虛擬機器鎖(嘗試進入同步的鎖)。同時,另一個執行緒佔有Java虛擬機器鎖並嘗試獲取資料庫鎖。再次地,資料庫發現一個連線阻塞了另一個連線,但由於無法阻止連線繼續,所以不會檢測到死鎖。Java虛擬機器發現同步的鎖中有一個執行緒,並有另一個嘗試進入的執行緒,所以即使Java虛擬機器能檢測到死鎖並對它們進行處理,它還是不會檢測到這種情況。

  為了說明此種死鎖情形,我們以一個簡單的(不完善的)read-through cache為例。該cache是資料庫表中備份的HashMap。如果出現快取命中,它就從HashMap返回一個值。但在快取缺失的情況下,它將從資料庫讀取值,將其新增到HashMap,然後返回該值,如清單2所示。

  清單 2

public class SimpleCache { 
 private Map cache = new HashMap(); 

 public synchronized Object get(String key) { 
  if (cache.containsKey(key)) { 
   return cache.get(key); 
  } else { 
   Object value = queryForValue(key); 
   cache.put(key, value); 
   return value; 
  } 
 } 

 private Object queryForValue(String key) { 
  return executeQuery("select value from cache_table " + 
   "where key='" + key + "'"); 
 } 

 public synchronized void clearCache() { 
  cache.clear(); 
 } 

 // other methods omitted for brevity 
} 


  這是一個簡單的遍歷cache。注意:get()方法是同步的,這是因為我們訪問了非執行緒安全容器,並要求containsKey/put組合在快取缺失時是原子性的。

  該cache相當簡單易懂:它約定,如果更改支援快取的表中的資料,則應呼叫clearCache(),這樣快取就可以避免處理陳舊的資料。產生的快取缺失將相應地重新進入快取。

  我們現在來考慮可以更改此資料並清除快取的程式碼:

public void updateData(String key, String value) { 
    executeUpdate("update cache_table set value='" + value + 
       "' where key='" + key + "'"); 
    SimpleCache.getInstance().clearCache(); 
}  

  上面的程式碼在簡單的例子中能正常執行。但是,在使用基於鎖的併發控制的資料庫中,updateData中的查詢將阻止queryForValue中的選擇查詢的執行,因為update語句將獲取一個寫鎖,從而阻止選擇查詢獲取同一資料行上的讀鎖。如果同步沒有問題,一個執行緒可以嘗試讀取快取中的給定值,並在另一個執行緒在資料庫中更新該值時得到快取缺失。如果資料庫先執行update語句,它將阻塞select語句繼續執行。但是,執行select語句的執行緒來自同步的get方法,所以它獲取了SimpleCache上的鎖。要返回updateData中的執行緒,它必須呼叫clearCache(),但不能獲取鎖(clearCache()是同步的)。

  當處理此情形的例項時,將有一個等待資料庫響應的Java執行緒和一個等待獲取Java虛擬機器鎖的執行緒。每個執行緒將與一個數據庫連線相關聯,其中一個連線阻塞另一個連線。修復方法是佔有Java虛擬機器鎖時避免執行資料庫操作,可以重寫leCache的get()方法,如下所示:
   

public Object get(String key) { 
    synchronized(this) { 
       if (cache.containsKey(key)) { 
          return cache.get(key); 
       } 
    } 
    Object value = queryForValue(key); 
    synchronized(this) { 
       cache.put(key, value); 
    } 
    return value; 
}  


  既然現在我們知道了會發生此死鎖情況,就可以使用Thread.holdsLock()向queryForValue方法新增檢查以嘗試避免死鎖情況:

private Object queryForValue(String key) { 
      assert(!Thread.holdsLock(this)); 
      return executeQuery(...); 
} 

  上例中的Thread.holdsLock()很有用,但是隻有在我們知道需要留心哪個鎖時它才會發揮作用。如果有一個類似的方法可以確定當前執行緒佔有哪個Java虛擬機器鎖,那麼會很有用。任何執行任何種類的RPC呼叫、資料庫訪問等的程式碼片段都可以丟擲異常或記錄警告,指示在佔有Java虛擬機器鎖時執行這些操作會有危險。

  注意:雖然我們修復了上例中的死鎖問題,但它仍有缺陷,因為在提交updateData的事務之前清空了快取。如果在呼叫clearCache後、提交updateData事務前出現快取缺失,則該快取將載入舊資料,因為新資料尚未可見。這裡的修復方法是僅在提交更改後清空快取。注意,這隻在MVCC資料庫中發生。在基於鎖的資料庫中,掛起的update將阻塞快取的讀操作,所以在提交update的事務後快取才能讀取正確值。

經驗法則

  下面的這些指導可以幫您避免死鎖問題,或者至少在出現死鎖時能診斷並修復它們。

  • 保持事務簡短。
  • 瞭解資料庫鎖行為(以及事務分離層)。
  • 假定任何資料庫訪問都有可能陷入資料庫死鎖狀況,但是能正確重試。
  • 事務完成前不要更新任何非事務狀態(記憶體狀態、快取等)。
  • 確保在峰值併發時有足夠大的資源池。
  • 嘗試不在同一時刻獲取多個資源。如果必需,則按相同的順序每次獲取一個資源。
  • 瞭解如何從應用伺服器獲取完整的執行緒轉儲以及從資料庫獲取資料庫連線列表(包括互相阻塞的連線),知道每個資料庫連線與哪個Java執行緒相關聯。瞭解Java執行緒和資料庫連線之間對映的最簡單方法是向連線池訪問模式新增日誌記錄功能。
  • 當進行巢狀的EJB呼叫時,瞭解哪些呼叫使用與呼叫方同樣的資料庫連線。即使巢狀的呼叫執行在同一個全域性事務中,它仍將使用不同的資料庫連線,而這會導致跨資源死鎖。
  • 避免執行資料庫呼叫和EJB呼叫,或在佔有Java虛擬機器鎖時,執行其他與Java虛擬機器無關的操作。如果有需要留心的特定Java虛擬機器鎖,就使用assert(!Thread.holdsLock(...)),從而避免以後的程式碼更改不會在無意間違背此規則。

  結束語

  J2EE應用程式中的跨資源死鎖是一個大問題——它能導致整個應用程式慢慢終止,還很難被分離和修復,尤其是當開發人員不熟悉如何分析死鎖環境的時候。我們討論的情形將有助於您理解一些常見的死鎖情形,併為您提供查詢死鎖的思路。更重要的是,我們概括的經驗法則提供了一些要在程式碼中遵守的慣例,從而避免所有類似的死鎖問題。