今天,我會介紹一些日常開發中類似線程死鎖等問題的排查經驗,并選擇一兩個我自己修復過或者診斷過的核心類庫死鎖問題作為例子,希望不僅能在面試時,包括在日常工作中也能對你有所幫助。
今天我要問你的問題是,什么情況下 Java 程序會產生死鎖?如何定位、修復?
## 典型回答
死鎖是一種特定的程序狀態,在實體之間,由于循環依賴導致彼此一直處于等待之中,沒有任何個體可以繼續前進。死鎖不僅僅是在線程之間會發生,存在資源獨占的進程之間同樣也可能出現死鎖。通常來說,我們大多是聚焦在多線程場景中的死鎖,指兩個或多個線程之間,由于互相持有對方需要的鎖,而永久處于阻塞的狀態。
你可以利用下面的示例圖理解基本的死鎖問題:

定位死鎖最常見的方式就是利用 jstack 等工具獲取線程棧,然后定位互相之間的依賴關系,進而找到死鎖。如果是比較明顯的死鎖,往往 jstack 等就能直接定位,類似 JConsole 甚至可以在圖形界面進行有限的死鎖檢測。
如果程序運行時發生了死鎖,絕大多數情況下都是無法在線解決的,只能重啟、修正程序本身問題。所以,代碼開發階段互相審查,或者利用工具進行預防性排查,往往也是很重要的。
## 考點分析
今天的問題偏向于實用場景,大部分死鎖本身并不難定位,掌握基本思路和工具使用,理解線程相關的基本概念,比如各種線程狀態和同步、鎖、Latch 等并發工具,就已經足夠解決大多數問題了。
針對死鎖,面試官可以深入考察:
* 拋開字面上的概念,讓面試者寫一個可能死鎖的程序,順便也考察下基本的線程編程。
* 診斷死鎖有哪些工具,如果是分布式環境,可能更關心能否用 API 實現嗎?
* 后期診斷死鎖還是挺痛苦的,經常加班,如何在編程中盡量避免一些典型場景的死鎖,有其他工具輔助嗎?
## 知識擴展
在分析開始之前,先以一個基本的死鎖程序為例,我在這里只用了兩個嵌套的 synchronized 去獲取鎖,具體如下:
~~~
public class DeadLockSample extends Thread {
private String first;
private String second;
public DeadLockSample(String name, String first, String second) {
super(name);
this.first = first;
this.second = second;
}
public void run() {
synchronized (first) {
System.out.println(this.getName() + " obtained: " + first);
try {
Thread.sleep(1000L);
synchronized (second) {
System.out.println(this.getName() + " obtained: " + second);
}
} catch (InterruptedException e) {
// Do nothing
}
}
}
public static void main(String[] args) throws InterruptedException {
String lockA = "lockA";
String lockB = "lockB";
DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
~~~
這個程序編譯執行后,幾乎每次都可以重現死鎖,請看下面截取的輸出。另外,這里有個比較有意思的地方,為什么我先調用 Thread1 的 start,但是 Thread2 卻先打印出來了呢?這就是因為線程調度依賴于(操作系統)調度器,雖然你可以通過優先級之類進行影響,但是具體情況是不確定的。

下面來模擬問題定位,我就選取最常見的 jstack,其他一些類似 JConsole 等圖形化的工具,請自行查找。
首先,可以使用 jps 或者系統的 ps 命令、任務管理器等工具,確定進程 ID。
其次,調用 jstack 獲取線程棧:
~~~
${JAVA_HOME}\bin\jstack your_pid
~~~
然后,分析得到的輸出,具體片段如下:

最后,結合代碼分析線程棧信息。上面這個輸出非常明顯,找到處于 BLOCKED 狀態的線程,按照試圖獲取(waiting)的鎖 ID(請看我標記為相同顏色的數字)查找,很快就定位問題。 jstack 本身也會把類似的簡單死鎖抽取出來,直接打印出來。
在實際應用中,類死鎖情況未必有如此清晰的輸出,但是總體上可以理解為:
**區分線程狀態 -> 查看等待目標 -> 對比 Monitor 等持有狀態**
所以,理解線程基本狀態和并發相關元素是定位問題的關鍵,然后配合程序調用棧結構,基本就可以定位到具體的問題代碼。
如果我們是開發自己的管理工具,需要用更加程序化的方式掃描服務進程、定位死鎖,可以考慮使用 Java 提供的標準管理 API,[ThreadMXBean](https://docs.oracle.com/javase/9/docs/api/java/lang/management/ThreadMXBean.html#findDeadlockedThreads--),其直接就提供了 findDeadlockedThreads?() 方法用于定位。為方便說明,我修改了 DeadLockSample,請看下面的代碼片段。
~~~
public static void main(String[] args) throws InterruptedException {
ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
Runnable dlCheck = new Runnable() {
@Override
public void run() {
long[] threadIds = mbean.findDeadlockedThreads();
if (threadIds != null) {
ThreadInfo[] threadInfos = mbean.getThreadInfo(threadIds);
System.out.println("Detected deadlock threads:");
for (ThreadInfo threadInfo : threadInfos) {
System.out.println(threadInfo.getThreadName());
}
}
}
};
ScheduledExecutorService scheduler =Executors.newScheduledThreadPool(1);
// 稍等 5 秒,然后每 10 秒進行一次死鎖掃描
scheduler.scheduleAtFixedRate(dlCheck, 5L, 10L, TimeUnit.SECONDS);
// 死鎖樣例代碼…
}
~~~
重新編譯執行,你就能看到死鎖被定位到的輸出。在實際應用中,就可以據此收集進一步的信息,然后進行預警等后續處理。但是要注意的是,對線程進行快照本身是一個相對重量級的操作,還是要慎重選擇頻度和時機。
**如何在編程中盡量預防死鎖呢?**
首先,我們來總結一下前面例子中死鎖的產生包含哪些基本元素。基本上死鎖的發生是因為:
* 互斥條件,類似 Java 中 Monitor 都是獨占的,要么是我用,要么是你用。
* 互斥條件是長期持有的,在使用結束之前,自己不會釋放,也不能被其他線程搶占。
* 循環依賴關系,兩個或者多個個體之間出現了鎖的鏈條環。
所以,我們可以據此分析可能的避免死鎖的思路和方法。
**第一種方法**
如果可能的話,盡量避免使用多個鎖,并且只有需要時才持有鎖。否則,即使是非常精通并發編程的工程師,也難免會掉進坑里,嵌套的 synchronized 或者 lock 非常容易出問題。
我舉個[例子](https://bugs.openjdk.java.net/browse/JDK-8198928), Java NIO 的實現代碼向來以鎖多著稱,一個原因是,其本身模型就非常復雜,某種程度上是不得不如此;另外是在設計時,考慮到既要支持阻塞模式,又要支持非阻塞模式。直接結果就是,一些基本操作如 connect,需要操作三個鎖以上,在最近的一個 JDK 改進中,就發生了死鎖現象。
我將其簡化為下面的偽代碼,問題是暴露在 HTTP/2 客戶端中,這是個非常現代的反應式風格的 API,非常推薦學習使用。
~~~
/// Thread HttpClient-6-SelectorManager:
readLock.lock();
writeLock.lock();
// 持有 readLock/writeLock,調用 close()需要獲得 closeLock
close();
// Thread HttpClient-6-Worker-2 持有 closeLock
implCloseSelectableChannel (); // 想獲得 readLock
~~~
在 close 發生時, HttpClient-6-SelectorManager 線程持有 readLock/writeLock,試圖獲得 closeLock;與此同時,另一個 HttpClient-6-Worker-2 線程,持有 closeLock,試圖獲得 readLock,這就不可避免地進入了死鎖。
這里比較難懂的地方在于,closeLock 的持有狀態(就是我標記為綠色的部分)**并沒有在線程棧中顯示出來**,請參考我在下圖中標記的部分。

更加具體來說,請查看[SocketChannelImpl](http://hg.openjdk.java.net/jdk/jdk/file/ce06058197a4/src/java.base/share/classes/sun/nio/ch/SocketChannelImpl.java)的 663 行,對比 implCloseSelectableChannel() 方法實現和[AbstractInterruptibleChannel.close()](http://hg.openjdk.java.net/jdk/jdk/file/ce06058197a4/src/java.base/share/classes/java/nio/channels/spi/AbstractInterruptibleChannel.java)在 109 行的代碼,這里就不展示代碼了。
所以,從程序設計的角度反思,如果我們賦予一段程序太多的職責,出現“既要…又要…”的情況時,可能就需要我們審視下設計思路或目的是否合理了。對于類庫,因為其基礎、共享的定位,比應用開發往往更加令人苦惱,需要仔細斟酌之間的平衡。
**第二種方法**
如果必須使用多個鎖,盡量設計好鎖的獲取順序,這個說起來簡單,做起來可不容易,你可以參看著名的[銀行家算法](https://en.wikipedia.org/wiki/Banker%27s_algorithm)。
一般的情況,我建議可以采取些簡單的輔助手段,比如:
* 將對象(方法)和鎖之間的關系,用圖形化的方式表示分別抽取出來,以今天最初講的死鎖為例,因為是調用了同一個線程所以更加簡單。

* 然后根據對象之間組合、調用的關系對比和組合,考慮可能調用時序。

* 按照可能時序合并,發現可能死鎖的場景。

**第三種方法**
使用帶超時的方法,為程序帶來更多可控性。
類似 Object.wait(…) 或者 CountDownLatch.await(…),都支持所謂的 timed\_wait,我們完全可以就不假定該鎖一定會獲得,指定超時時間,并為無法得到鎖時準備退出邏輯。
并發 Lock 實現,如 ReentrantLock 還支持非阻塞式的獲取鎖操作 tryLock(),這是一個插隊行為(barging),并不在乎等待的公平性,如果執行時對象恰好沒有被獨占,則直接獲取鎖。有時,我們希望條件允許就嘗試插隊,不然就按照現有公平性規則等待,一般采用下面的方法:
~~~
if (lock.tryLock() || lock.tryLock(timeout, unit)) {
// ...
}
~~~
**第四種方法**
業界也有一些其他方面的嘗試,比如通過靜態代碼分析(如 FindBugs)去查找固定的模式,進而定位可能的死鎖或者競爭情況。實踐證明這種方法也有一定作用,請參考[相關文檔](https://plugins.jetbrains.com/plugin/3847-findbugs-idea)。
除了典型應用中的死鎖場景,其實還有一些更令人頭疼的死鎖,比如類加載過程發生的死鎖,尤其是在框架大量使用自定義類加載時,因為往往不是在應用本身的代碼庫中,jstack 等工具也不見得能夠顯示全部鎖信息,所以處理起來比較棘手。對此,Java 有[官方文檔](https://docs.oracle.com/javase/7/docs/technotes/guides/lang/cl-mt.html)進行了詳細解釋,并針對特定情況提供了相應 JVM 參數和基本原則。
今天,我從樣例程序出發,介紹了死鎖產生原因,并幫你熟悉了排查死鎖基本工具的使用和典型思路,最后結合實例介紹了實際場景中的死鎖分析方法與預防措施,希望對你有所幫助。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?今天的思考題是,有時候并不是阻塞導致的死鎖,只是某個線程進入了死循環,導致其他線程一直等待,這種問題如何診斷呢?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?