一、簡介
在之前的文章中,我們介紹了synchronized
同步鎖關鍵字的作用以及相關的用法,它能夠保證同一時刻最多隻有一個線程執行修飾的代碼段,以實現線程安全執行的效果。
但是如果過度的使用synchronized
等方式進行加鎖,程序可能會出現死鎖現象。
那什麼是死鎖呢?它有什麼危害?
我們知道被synchronized
修飾的代碼,當一個線程持有一個鎖,其它線程嘗試去獲取這個鎖未獲取到時,那麼其它線程會進入阻塞狀態,直到線程釋放鎖才能再次擁有獲取鎖的條件。假如線程 A 持有鎖 L 並且想獲取鎖 R,線程 B 持有鎖 R 並且想獲取鎖 L,那麼這兩個線程將會永久等待下去,這種情況就是最簡單的死鎖現象。
如果程序出現了死鎖,會給系統功能帶來非常嚴重的問題,輕則導致程序響應時間變長,系統吞吐量變小;重則導致應用中的某一個功能直接失去響應能力無法提供服務,因此我們應該及時發現並避規這些問題。
當然發生死鎖的軟件應用,不僅限於 Java 程序,還有數據庫等,不同的是:數據庫系統中設計了死鎖的檢測以及從死鎖中恢復的機制,數據庫如果檢測到一組事務中發生了死鎖,將選擇一個犧牲者並放棄這個事務。
而 Java 虛擬機解決死鎖問題並沒有數據庫那麼強大,在 Java 程序中,採用synchronized
加鎖的代碼如果發生死鎖,兩個線程就不能再使用了,並且這兩個線程所在的同步代碼/代碼塊也無法再運行了,除非殺掉服務,然後重啓服務!
在實際的軟件項目開發過程中,死鎖其實是編程設計上的 bug,問題也比較隱晦,即使通過壓力測試也不一定能找到程序上的死鎖問題。死鎖的出現,往往是在高負載的情況下產生,這種場景下比較難定位。
二、死鎖復現
下面我們先來看一個比較經典的產生死鎖示例代碼。
public class DeadLock {
private final Object right = new Object();
private final Object left = new Object();
/**
* 加鎖順序從left -> right
*/
public void leftRight() throws Exception {
synchronized (left) {
// 模擬某個業務操作耗時
Thread.sleep(1000);
synchronized (right) {
System.out.println(Thread.currentThread().getName() + " left -> right lock.");
}
}
}
/**
* 加鎖順序right -> left
*/
public void rightLeft() throws Exception {
synchronized (right) {
// 模擬某個業務操作耗時
Thread.sleep(1000);
synchronized (left) {
System.out.println(Thread.currentThread().getName() + " right -> left lock.");
}
}
}
}
public class MyThreadA extends Thread {
private DeadLock deadLock;
public MyThreadA(DeadLock deadLock) {
this.deadLock = deadLock;
}
@Override
public void run() {
try {
deadLock.leftRight();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class MyThreadB extends Thread {
private DeadLock deadLock;
public MyThreadB(DeadLock deadLock) {
this.deadLock = deadLock;
}
@Override
public void run() {
try {
deadLock.rightLeft();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class MyThreadTest {
public static void main(String[] args) {
DeadLock deadLock = new DeadLock();
MyThreadA threadA = new MyThreadA(deadLock);
MyThreadB threadB = new MyThreadB(deadLock);
threadA.start();
threadB.start();
}
}
運行測試類觀察控制檯,你會發現服務一直在運行,什麼都沒有輸出,並且無法關閉,因爲程序已經死鎖了!
發生這個現象的原因,其實也很簡單。
- 1.線程 A 啓動之後,先獲取了
left
對象鎖,然後緊接着嘗試獲取right
對象鎖,因爲right
對象鎖被其它線程佔有,只能進入阻塞狀態 - 2.線程 B 啓動之後,先獲取了
right
對象鎖,然後緊接着嘗試獲取left
對象鎖,因爲left
對象鎖被其它線程佔有,只能進入阻塞狀態 - 3.兩個線程互相等待對方釋放鎖,程序進入永久等待狀態,因此都無法進入打印方法體
如何定位死鎖問題呢?
我們可以通過 Java 自帶的 jps 和 jstack 工具,查看 java 進程 id 和相關的線程堆棧信息。
定位過程如下!
2.1、通過 jps 獲得當前 Java 虛擬機進程的 pid
左邊的是當前 Java 虛擬機進程 ID,後邊是進程名稱,其中MyThreadTest
就是我們當前運行的測試類服務。
2.2、通過 jstack 查看進程中的線程信息
在 jstack 後面輸入對應的 java 進程 ID,然後回車即可查詢到進程中的線程情況,前面的部分,可以很清晰的看到,兩個線程都處於阻塞狀態,等待獲取對應的鎖。
因爲線程的信息比較多,直接滑倒最底部,可以看到 JVM 給出的死鎖報告信息。
遇到這種情況,只能強制終止服務才能解除死鎖!
三、避免死鎖的方式
上面我們復現了死鎖的發生,總結下來你會發現死鎖的產生,總共有四個共同特點:
- 1.互斥使用,即當資源被一個線程佔用時,別的線程不能使用
- 2.不可搶佔,資源請求者不能強制從資源佔有者手中搶奪資源,資源只能由佔有者主動釋放
- 3.請求和保持,當資源請求者在請求其他資源的同時保持對原有資源的佔有
- 4.循環等待,多個線程存在環路的鎖依賴關係而永遠等待下去,例如 T1 佔有 T2 的資源,T2 佔有 T3 的資源,T3 佔有 T1 的資源,這種情況可能會形成一個等待環路
這四個特點是死鎖產生的必要條件,只要系統發生死鎖,這些條件必然成立,只要能破壞其中一條即可讓死鎖消失,當然條件一是基礎,不能被破壞。
理解了死鎖的原因,尤其是產生死鎖的四個必要條件,就可以最大可能地避免產生死鎖和解除死鎖。
在軟件編程中,我們如何避免死鎖呢?
關於死鎖的避免,主要有以下幾種方式:
- 1.儘可能使用無鎖編程,使用開放調用的編碼設計
- 2.設計時考慮清楚鎖的順序,儘量減少嵌在的加鎖交互數量
- 2.儘可能的縮小鎖的範圍,防止鎖住的資源過多引發阻塞
- 4.使用定時鎖,比如
Lock
類中的tryLock
方法去嘗試獲取鎖,這個方法支持在指定時間內獲取鎖,如果等待超時會返回一個失敗信息,死鎖會自動解除。
對於死鎖的診斷,主要有以下幾種方式:
- 1.對代碼進行全局分析,找出代碼中什麼地方會出現死鎖
- 2.通過線程轉儲(Thread Dump)信息來分析死鎖,比如 jstack、jvisualvm、jconsole 等工具
至於死鎖的解除,主要有以下幾種方式:
- 1.直接強制終止並重啓服務,如果代碼上的風險沒有消除,可能還會再次出現
- 2.採用定時鎖方案,雖然
synchronized
不具備這個功能,但是Lock
類中的tryLock
方法具備,實際編程中採用Lock
中的超時機制進行加鎖,應用的比較多
四、小結
本文主要圍繞多線程編程中常見的死鎖問題,從現象復現到方案解決進行了一次知識總結,內容難免有所遺漏,歡迎網友留言指出!