開啓JAVA死鎖之迷

引言 
    【IT168 專稿】一般來說,每一種使用線程的語言中都存在線程死鎖問題,Java開發中遇到線程死鎖問題也是非常普遍。筆者在程序開發中就常常碰到死鎖的問題,並經常束手無策。本文分享筆者在JAVA開發中對線程死鎖的一些看法。 

    一. 什麼是線程 
    在談到線程死鎖的時候,我們首先必須瞭解什麼是Java線程。一個程序的進程會包含多個線程,一個線程就是運行在一個進程中的一個邏輯流。多線程允許在程序中併發執行多個指令流,每個指令流都稱爲一個線程,彼此間互相獨立。 

    線程又稱爲輕量級進程,它和進程一樣擁有獨立的執行控制,由操作系統負責調度,區別在於線程沒有獨立的存儲空間,而是和所屬進程中的其它線程共享一個存儲空間,這使得線程間的通信較進程簡單。筆者的經驗是編寫多線程序,必須注意每個線程是否干擾了其他線程的工作。每個進程開始生命週期時都是單一線程,稱爲“主線程”,在某一時刻主線程會創建一個對等線程。如果主線程停滯則系統就會切換到其對等線程。和一個進程相關的線程此時會組成一個對等線程池,一個線程可以殺死其任意對等線程。 

    因爲每個線程都能讀寫相同的共享數據。這樣就帶來了新的麻煩:由於數據共享會帶來同步問題,進而會導致死鎖的產生。 

    二. 死鎖的機制 
    由多線程帶來的性能改善是以可靠性爲代價的,主要是因爲有可能產生線程死鎖。死鎖是這樣一種情形:多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程序不能正常運行。簡單的說就是:線程死鎖時,第一個線程等待第二個線程釋放資源,而同時第二個線程又在等待第一個線程釋放資源。這裏舉一個通俗的例子:如在人行道上兩個人迎面相遇,爲了給對方讓道,兩人同時向一側邁出一步,雙方無法通過,又同時向另一側邁出一步,這樣還是無法通過。假設這種情況一直持續下去,這樣就會發生死鎖現象。 

    導致死鎖的根源在於不適當地運用“synchronized”關鍵詞來管理線程對特定對象的訪問。“synchronized”關鍵詞的作用是,確保在某個時刻只有一個線程被允許執行特定的代碼塊,因此,被允許執行的線程首先必須擁有對變量或對象的排他性訪問權。當線程訪問對象時,線程會給對象加鎖,而這個鎖導致其它也想訪問同一對象的線程被阻塞,直至第一個線程釋放它加在對象上的鎖。 

    Java中每個對象都有一把鎖與之對應。但Java不提供單獨的lock和unlock操作。下面筆者分析死鎖的兩個過程“上鎖”和“鎖死” 。

(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虛擬機無關的操作。 
  
    最重要的是,多線程設計雖然是困難的,但在開始編程之前詳細設計系統能夠幫助你避免難以發現死鎖的問題。死鎖在語言層面上不能解決,就需要一個良好設計來避免死鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章