一、定義
線程死鎖是指由於兩個或者多個線程互相持有對方所需要的資源,導致這些線程處於等待狀態,無法前往執行。當線程進入對象的synchronized代碼塊時,便占有了資源,直到它退出該代碼塊或者調用wait方法,才釋放資源,在此期間,其他線程將不能進入該代碼塊。當線程互相持有對方所需要的資源時,會互相等待對方釋放資源,如果線程都不主動釋放所占有的資源,將產生死鎖。
當然死鎖的產生是必須要滿足一些特定條件的:
1.互斥條件:進程對於所分配到的資源具有排它性,即一個資源只能被一個進程占用,直到被該進程釋放
2.請求和保持條件:一個進程因請求被占用資源而發生阻塞時,對已獲得的資源保持不放。
3.不剝奪條件:任何一個資源在沒被該進程釋放之前,任何其他進程都無法對他剝奪占用
4.循環等待條件:當發生死鎖時,所等待的進程必定會形成一個環路(類似於死循環),造成永久阻塞。
package com.sxy.thread;
/**
* 線程Thread1率先占有了resource1, 繼續運行時需要resource2, 但此時resource2卻被線程Thread2占有了,
* 因此只能等待Thread2釋放resource2才能夠繼續運行; 同時,Thread2也需要resource1,
* 它只能等待Thread1釋放resource1才能夠繼續運行, 因此,Thread1和Thread2都處於等待狀態,
* 誰也無法繼續運行,即產生了死鎖。
*
* @author sunxy
*/
public class DeadLock {
public static void main(String[] args) {
dead_lock();
}
private static void dead_lock() {
// 兩個資源
final Object resource1 = "resource1";
final Object resource2 = "resource2";
// 第一個線程,想先占有resource1,再嘗試着占有resource2
Thread t1 = new Thread() {
public void run() {
// 嘗試占有resource1
synchronized (resource1) {
// 成功占有resource1
System.out.println("Thread1 1:locked resource1");
// 休眠一段時間
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 嘗試占有resource2,如果不能占有,該線程會一直等到
synchronized (resource2) {
System.out.println("Thread1 1:locked resource2");
}
}
}
};
// 第二個線程,想先占有resource2,再占有resource1
Thread t2 = new Thread() {
public void run() {
// 嘗試占有resource2
synchronized (resource2) {
// 成功占有resource2
System.out.println("Thread 2 :locked resource2");
// 休眠一段時間
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 嘗試占有resource1,如果不能占有,該線程會一直等到
synchronized (resource1) {
System.out.println("Thread1 2:locked resource1");
}
}
}
};
// 啟動線程
t1.start();
t2.start();
}
}
死鎖的另一種:遞歸死鎖,舉例:
所謂遞歸函數就是自調用函數,在函數體內直接或間接的調用自己,即函數的嵌套是函數本身。
遞歸方式有兩種:直接遞歸和間接遞歸,直接遞歸就是在函數中出現調用函數本身。間接遞歸,指函數中調用了其他函數,而該其他函數又調用了本函數。
那什么時候使用遞歸呢?一般來說當你要在某段代碼邏輯中使用循環迭代的時候但是迭代的次數在迭代之前無法知曉的情況下使用遞歸。打個比方你要在一個文件夾中查找某個文件,而這個文件夾底下有N多子文件夾和文件,當你在不知道有多少層文件夾和文件的情況下你就得用到遞歸了。
遞歸的優點就是讓代碼顯得很簡潔,同時有些應用場景不得不使用遞歸比如前面說的找文件。遞歸是個好東西但是在某些時候也會給你帶來一些麻煩。比如在多線程的環境下使用遞歸,遇到了多線程那么就不得不面對同步的問題。而遞歸程序遇到同步的時候很容易出問題。多線程的遞歸就是指遞歸鏈中的某個方法由另外一個線程來操作,以下代碼的意思都是這個意思即調用recursive()和businessLogic()並非一個線程(如果是在一個線程中就不存在死鎖問題,例如下面的recursive變成private就不存在問題。)
- public class Test {
- public void recursive(){
- this.businessLogic();
- }
- public synchronized void businessLogic(){
- System.out.println("處理業務邏輯");
- System.out.println("保存到<a href="http://lib.csdn.net/base/mysql" class='replace_word' title="MySQL知識庫" target='_blank' style='color:#df3434; font-weight:bold;'>數據庫</a>");
- this.recursive();
- }
- }
以上這段代碼就是個能形成死鎖的代碼,事實上這個“synchronized”放在“businessLogic()”和“recursive()”都會形成死鎖,並且是多線程的情況下就會鎖住!他的邏輯順序是先執行recursive()方法然后接下來執行businessLogic()方法同時將businessLogic()方法鎖住,接下來程序進入businessLogic()方法內部執行完打印語句后開始執行recursive(),進入recursive()后准備執行businessLogic(),等等問題來了!之前執行的businessLogic()的鎖還沒有放開這次又執行到這里了,當然是過不去的了,形成了死鎖!從這個例子我們總結出來一個規律就是在遞歸的時候在遞歸鏈上面的方法上加鎖肯定會出現死鎖(所謂遞歸鏈就是指recursive()鏈向businessLogic(),businessLogic()又鏈回recursive()),解決這個問題的方法就是避免在遞歸鏈上加鎖,請看以下的例子
- public class Test {
- public void recursive(){
- this.businessLogic();
- }
- public void businessLogic(){
- System.out.println("處理業務邏輯");
- this.saveToDB();
- this.recursive();
- }
- public synchronized void saveToDB(){
- System.out.println("保存到數據庫");
- }
- }
saveToDB()不在這條遞歸鏈上面自然不會出現死鎖,所以說在遞歸中加鎖是件很危險的事情,實在逃不過要加鎖就加在最小的粒度的程序代碼上以減小死鎖的概率。
避免死鎖:
在有些情況下死鎖是可以避免的。本文將展示三種用於避免死鎖的技術:
加鎖順序
當多個線程需要相同的一些鎖,但是按照不同的順序加鎖,死鎖就很容易發生。
如果能確保所有的線程都是按照相同的順序獲得鎖,那么死鎖就不會發生。看下面這個例子:
Thread 1: lock A lock B Thread 2: wait for A lock C (when A locked) Thread 3: wait for A wait for B wait for C
如果一個線程(比如線程3)需要一些鎖,那么它必須按照確定的順序獲取鎖。它只有獲得了從順序上排在前面的鎖之后,才能獲取后面的鎖。
例如,線程2和線程3只有在獲取了鎖A之后才能嘗試獲取鎖C(譯者注:獲取鎖A是獲取鎖C的必要條件)。因為線程1已經擁有了鎖A,所以線程2和3需要一直等到鎖A被釋放。然后在它們嘗試對B或C加鎖之前,必須成功地對A加了鎖。
按照順序加鎖是一種有效的死鎖預防機制。但是,這種方式需要你事先知道所有可能會用到的鎖(譯者注:並對這些鎖做適當的排序),但總有些時候是無法預知的。
加鎖時限
另外一個可以避免死鎖的方法是在嘗試獲取鎖的時候加一個超時時間,這也就意味着在嘗試獲取鎖的過程中若超過了這個時限該線程則放棄對該鎖請求。若一個線程沒有在給定的時限內成功獲得所有需要的鎖,則會進行回退並釋放所有已經獲得的鎖,然后等待一段隨機的時間再重試。這段隨機的等待時間讓其它線程有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續運行(譯者注:加鎖超時后可以先繼續運行干點其它事情,再回頭來重復之前加鎖的邏輯)。
以下是一個例子,展示了兩個線程以不同的順序嘗試獲取相同的兩個鎖,在發生超時后回退並重試的場景:
Thread 1 locks A Thread 2 locks B Thread 1 attempts to lock B but is blocked Thread 2 attempts to lock A but is blocked Thread 1's lock attempt on B times out Thread 1 backs up and releases A as well Thread 1 waits randomly (e.g. 257 millis) before retrying. Thread 2's lock attempt on A times out Thread 2 backs up and releases B as well Thread 2 waits randomly (e.g. 43 millis) before retrying.
在上面的例子中,線程2比線程1早200毫秒進行重試加鎖,因此它可以先成功地獲取到兩個鎖。這時,線程1嘗試獲取鎖A並且處於等待狀態。當線程2結束時,線程1也可以順利的獲得這兩個鎖(除非線程2或者其它線程在線程1成功獲得兩個鎖之前又獲得其中的一些鎖)。
需要注意的是,由於存在鎖的超時,所以我們不能認為這種場景就一定是出現了死鎖。也可能是因為獲得了鎖的線程(導致其它線程超時)需要很長的時間去完成它的任務。
此外,如果有非常多的線程同一時間去競爭同一批資源,就算有超時和回退機制,還是可能會導致這些線程重復地嘗試但卻始終得不到鎖。如果只有兩個線程,並且重試的超時時間設定為0到500毫秒之間,這種現象可能不會發生,但是如果是10個或20個線程情況就不同了。因為這些線程等待相等的重試時間的概率就高的多(或者非常接近以至於會出現問題)。
(譯者注:超時和重試機制是為了避免在同一時間出現的競爭,但是當線程很多時,其中兩個或多個線程的超時時間一樣或者接近的可能性就會很大,因此就算出現競爭而導致超時后,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。)
這種機制存在一個問題,在Java中不能對synchronized同步塊設置超時時間。你需要創建一個自定義鎖,或使用Java5中java.util.concurrent包下的工具。寫一個自定義鎖類不復雜,但超出了本文的內容。后續的Java並發系列會涵蓋自定義鎖的內容。
死鎖檢測
死鎖檢測是一個更好的死鎖預防機制,它主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的場景。
每當一個線程獲得了鎖,會在線程和鎖相關的數據結構中(map、graph等等)將其記下。除此之外,每當有線程請求鎖,也需要記錄在這個數據結構中。
當一個線程請求鎖失敗時,這個線程可以遍歷鎖的關系圖看看是否有死鎖發生。例如,線程A請求鎖7,但是鎖7這個時候被線程B持有,這時線程A就可以檢查一下線程B是否已經請求了線程A當前所持有的鎖。如果線程B確實有這樣的請求,那么就是發生了死鎖(線程A擁有鎖1,請求鎖7;線程B擁有鎖7,請求鎖1)。
當然,死鎖一般要比兩個線程互相持有對方的鎖這種情況要復雜的多。線程A等待線程B,線程B等待線程C,線程C等待線程D,線程D又在等待線程A。線程A為了檢測死鎖,它需要遞進地檢測所有被B請求的鎖。從線程B所請求的鎖開始,線程A找到了線程C,然后又找到了線程D,發現線程D請求的鎖被線程A自己持有着。這是它就知道發生了死鎖。
下面是一幅關於四個線程(A,B,C和D)之間鎖占有和請求的關系圖。像這樣的數據結構就可以被用來檢測死鎖。
那么當檢測出死鎖時,這些線程該做些什么呢?
一個可行的做法是釋放所有鎖,回退,並且等待一段隨機的時間后重試。這個和簡單的加鎖超時類似,不一樣的是只有死鎖已經發生了才回退,而不會是因為加鎖的請求超時了。雖然有回退和等待,但是如果有大量的線程競爭同一批鎖,它們還是會重復地死鎖(編者注:原因同超時類似,不能從根本上減輕競爭)。
一個更好的方案是給這些線程設置優先級,讓一個(或幾個)線程回退,剩下的線程就像沒發生死鎖一樣繼續保持着它們需要的鎖。如果賦予這些線程的優先級是固定不變的,同一批線程總是會擁有更高的優先級。為避免這個問題,可以在死鎖發生的時候設置隨機的優先級。