程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> 對J2EE中死鎖問題的研究

對J2EE中死鎖問題的研究

編輯:關於JAVA

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

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

在本文中,我們將討論發生在典型的重要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中的代碼所示。

清單1public 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所示。

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

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved