Java 程式死鎖問題原理及解決方案

developerworks-周明耀發表於2016-08-25

已經出版的《大話Java效能優化》請大家多多支援,《深入學習JVM&G1 GC》、《動手學習Apache ZooKeeper》預計2016年下半年出版。

Java 語言通過 synchronized 關鍵字來保證原子性,這是因為每一個 Object 都有一個隱含的鎖,這個也稱作監視器物件。在進入 synchronized 之前自動獲取此內部鎖,而一旦離開此方式,無論是完成或者中斷都會自動釋放鎖。顯然這是一個獨佔鎖,每個鎖請求之間是互斥的。相對於眾多高階鎖 (Lock/ReadWriteLock 等),synchronized 的代價都比後者要高。但是 synchronzied 的語法比較簡單,而且也比較容易使用和理解。Lock 一旦呼叫了 lock() 方法獲取到鎖而未正確釋放的話很有可能造成死鎖,所以 Lock 的釋放操作總是跟在 finally 程式碼塊裡面,這在程式碼結構上也是一次調整和冗餘。Lock 的實現已經將硬體資源用到了極致,所以未來可優化的空間不大,除非硬體有了更高的效能,但是 synchronized 只是規範的一種實現,這在不同的平臺不同的硬體還有很高的提升空間,未來 Java 鎖上的優化也會主要在這上面。既然 synchronzied 都不可能避免死鎖產生,那麼死鎖情況會是經常容易出現的錯誤,下面具體描述死鎖發生的原因及解決方法。

死鎖描述

死鎖是作業系統層面的一個錯誤,是程式死鎖的簡稱,最早在 1965 年由 Dijkstra 在研究銀行家演算法時提出的,它是計算機作業系統乃至整個併發程式設計領域最難處理的問題之一。

事實上,計算機世界有很多事情需要多執行緒方式去解決,因為這樣才能最大程度上利用資源,才能體現出計算的高效。但是,實際上來說,計算機系統中有很多一次只能由一個程式使用的資源的情況,例如印表機,同時只能有一個程式控制它。在多通道程式設計環境中,若干程式往往要共享這類資源,而且一個程式所需要的資源還很有可能不止一個。因此,就會出現若干程式競爭有限資源,又推進順序不當,從而構成無限期迴圈等待的局面。我們稱這種狀態為死鎖。簡單一點描述,死鎖是指多個程式迴圈等待它方佔有的資源而無限期地僵持下去的局面。很顯然,如果沒有外力的作用,那麼死鎖涉及到的各個程式都將永遠處於封鎖狀態。

系統發生死鎖現象不僅浪費大量的系統資源,甚至導致整個系統崩潰,帶來災難性後果。所以,對於死鎖問題在理論上和技術上都必須予以高度重視。

銀行家演算法

一個銀行家如何將一定數目的資金安全地借給若干個客戶,使這些客戶既能借到錢完成要乾的事,同時銀行家又能收回全部資金而不至於破產。銀行家就像一個作業系統,客戶就像執行的程式,銀行家的資金就是系統的資源。

銀行家演算法需要確保以下四點:

  1. 當一個顧客對資金的最大需求量不超過銀行家現有的資金時就可接納該顧客;
  2. 顧客可以分期貸款, 但貸款的總數不能超過最大需求量;
  3. 當銀行家現有的資金不能滿足顧客尚需的貸款數額時,對顧客的貸款可推遲支付,但總能使顧客在有限的時間裡得到貸款;
  4. 當顧客得到所需的全部資金後,一定能在有限的時間裡歸還所有的資金。
清單 1. 銀行家演算法實現

死鎖示例

死鎖問題是多執行緒特有的問題,它可以被認為是執行緒間切換消耗系統效能的一種極端情況。在死鎖時,執行緒間相互等待資源,而又不釋放自身的資源,導致無窮無盡的等待,其結果是系統任務永遠無法執行完成。死鎖問題是在多執行緒開發中應該堅決避免和杜絕的問題。

一般來說,要出現死鎖問題需要滿足以下條件:

1. 互斥條件:一個資源每次只能被一個執行緒使用。

2. 請求與保持條件:一個程式因請求資源而阻塞時,對已獲得的資源保持不放。

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

4. 迴圈等待條件:若干程式之間形成一種頭尾相接的迴圈等待資源關係。

只要破壞死鎖 4 個必要條件之一中的任何一個,死鎖問題就能被解決。

我們先來看一個示例,前面說過,死鎖是兩個甚至多個執行緒被永久阻塞時的一種執行局面,這種局面的生成伴隨著至少兩個執行緒和兩個或者多個資源。程式碼清單 2 所示的示例中,我們編寫了一個簡單的程式,它將會引起死鎖發生,然後我們就會明白如何分析它。

清單 2. 死鎖示例

在上面的程式中同步執行緒正完成 Runnable 的介面,它工作的是兩個物件,這兩個物件向對方尋求死鎖而且都在使用同步阻塞。在主函式中,我使用了三個為同步執行緒執行的執行緒,而且在其中每個執行緒中都有一個可共享的資源。這些執行緒以向第一個物件獲取封鎖這種方式執行。但是當它試著向第二個物件獲取封鎖時,它就會進入等待狀態,因為它已經被另一個執行緒封鎖住了。這樣,線上程引起死鎖的過程中,就形成了一個依賴於資源的迴圈。當我執行上面的程式時,就產生了輸出,但是程式卻因為死鎖無法停止。輸出如清單 3 所示。

清單 3. 清單 2 執行輸出

在此我們可以清楚地在輸出結果中辨認出死鎖局面,但是在我們實際所用的應用中,發現死鎖並將它排除是非常難的。

死鎖情況診斷

JVM 提供了一些工具可以來幫助診斷死鎖的發生,如下面程式清單 4 所示,我們實現了一個死鎖,然後嘗試通過 jstack 命令追蹤、分析死鎖發生。

清單 4. 死鎖程式碼

jstack 可用於匯出 Java 應用程式的執行緒堆疊,-l 選項用於列印鎖的附加資訊。我們執行 jstack 命令,輸出入清單 5 和 6 所示,其中清單 5 裡面可以看到執行緒處於執行狀態,程式碼中呼叫了擁有鎖投票、定時鎖等候和可中斷鎖等候等特性的 ReentrantLock 鎖機制。清單 6 直接列印出出現死鎖情況,報告 north 和 sourth 兩個執行緒互相等待資源,出現了死鎖。

清單 5. jstack 執行輸出 1
清單 6. jstack 執行輸出片段 2

死鎖解決方案

死鎖是由四個必要條件導致的,所以一般來說,只要破壞這四個必要條件中的一個條件,死鎖情況就應該不會發生。

  1. 如果想要打破互斥條件,我們需要允許程式同時訪問某些資源,這種方法受制於實際場景,不太容易實現條件;
  2. 打破不可搶佔條件,這樣需要允許程式強行從佔有者那裡奪取某些資源,或者簡單一點理解,佔有資源的程式不能再申請佔有其他資源,必須釋放手上的資源之後才能發起申請,這個其實也很難找到適用場景;
  3. 程式在執行前申請得到所有的資源,否則該程式不能進入準備執行狀態。這個方法看似有點用處,但是它的缺點是可能導致資源利用率和程式併發性降低;
  4. 避免出現資源申請環路,即對資源事先分類編號,按號分配。這種方式可以有效提高資源的利用率和系統吞吐量,但是增加了系統開銷,增大了程式對資源的佔用時間。

如果我們在死鎖檢查時發現了死鎖情況,那麼就要努力消除死鎖,使系統從死鎖狀態中恢復過來。消除死鎖的幾種方式:

1. 最簡單、最常用的方法就是進行系統的重新啟動,不過這種方法代價很大,它意味著在這之前所有的程式已經完成的計算工作都將付之東流,包括參與死鎖的那些程式,以及未參與死鎖的程式;

2. 撤消程式,剝奪資源。終止參與死鎖的程式,收回它們佔有的資源,從而解除死鎖。這時又分兩種情況:一次性撤消參與死鎖的全部程式,剝奪全部資源;或者逐步撤消參與死鎖的程式,逐步收回死鎖程式佔有的資源。一般來說,選擇逐步撤消的程式時要按照一定的原則進行,目的是撤消那些代價最小的程式,比如按程式的優先順序確定程式的代價;考慮程式執行時的代價和與此程式相關的外部作業的代價等因素;

3. 程式回退策略,即讓參與死鎖的程式回退到沒有發生死鎖前某一點處,並由此點處繼續執行,以求再次執行時不再發生死鎖。雖然這是個較理想的辦法,但是操作起來系統開銷極大,要有堆疊這樣的機構記錄程式的每一步變化,以便今後的回退,有時這是無法做到的。

其實即便是商業產品,依然會有很多死鎖情況的發生,例如 MySQL 資料庫,它也經常容易出現死鎖案例。

MySQL 死鎖情況解決方法

假設我們用 Show innodb status 檢查引擎狀態時發現了死鎖情況,如清單 7 所示。

清單 7. MySQL 死鎖

我們假設涉事的資料表上面有一個索引,這次的死鎖就是由於兩條記錄同時訪問到了相同的索引造成的。

我們首先來看看 InnoDB 型別的資料表,只要能夠解決索引問題,就可以解決死鎖問題。MySQL 的 InnoDB 引擎是行級鎖,需要注意的是,這不是對記錄進行鎖定,而是對索引進行鎖定。在 UPDATE、DELETE 操作時,MySQL 不僅鎖定 WHERE 條件掃描過的所有索引記錄,而且會鎖定相鄰的鍵值,即所謂的 next-key locking;

如語句 UPDATE TSK_TASK SET UPDATE_TIME = NOW() WHERE ID > 10000 會鎖定所有主鍵大於等於 1000 的所有記錄,在該語句完成之前,你就不能對主鍵等於 10000 的記錄進行操作;當非簇索引 (non-cluster index) 記錄被鎖定時,相關的簇索引 (cluster index) 記錄也需要被鎖定才能完成相應的操作。

再分析一下發生問題的兩條 SQL 語句:

執行時,MySQL 會使用 KEY_TSKTASK_MONTIME2 索引,因此首先鎖定相關的索引記錄,因為 KEY_TSKTASK_MONTIME2 是非簇索引,為執行該語句,MySQL 還會鎖定簇索引(主鍵索引)。

假設“update TSK_TASK set STATUS_ID=1067,UPDATE_TIME=now () where ID in (9921180)”幾乎同時執行時,本語句首先鎖定簇索引 (主鍵),由於需要更新 STATUS_ID 的值,所以還需要鎖定 KEY_TSKTASK_MONTIME2 的某些索引記錄。

這樣第一條語句鎖定了 KEY_TSKTASK_MONTIME2 的記錄,等待主鍵索引,而第二條語句則鎖定了主鍵索引記錄,而等待 KEY_TSKTASK_MONTIME2 的記錄,這樣死鎖就產生了。

我們通過拆分第一條語句解決了死鎖問題:即先查出符合條件的 ID:select ID from TSK_TASK where STATUS_ID=1061 and MON_TIME < date_sub(now(), INTERVAL 30 minute);然後再更新狀態:update TSK_TASK set STATUS_ID=1064 where ID in (….)。

結束語

我們發現,死鎖雖然是較早就被發現的問題,但是很多情況下我們設計的程式裡還是經常發生死鎖情況。我們不能只是分析如何解決死鎖這類問題,還需要具體找出預防死鎖的方法,這樣才能從根本上解決問題。總的來說,還是需要系統架構師、程式設計師不斷積累經驗,從業務邏輯設計層面徹底消除死鎖發生的可能性。

(本文曾於2015年8月24日發表於IBM開發者論壇)

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

Java 程式死鎖問題原理及解決方案

相關文章