併發王者課-黃金2:行穩致遠-如何讓你的執行緒免於死鎖

秦二爺發表於2021-06-12

歡迎來到《併發王者課》,本文是該系列文章中的第12篇

在上篇文章中,我們介紹了死鎖的概念及其原因,本文將為你介紹的是幾種常見的死鎖預防策略

簡單來說,預防死鎖主要有三種策略:

  • 順序化加鎖;
  • 給鎖一個超時期限;
  • 檢測死鎖。

一、順序化加鎖

通常,死鎖的產生是由於多個執行緒無序請求資源造成的。資源是有限的,不可能同時滿足所有執行緒的請求。然而,如果能按照一定的順序分別滿足各個執行緒的請求,那麼死鎖也就不再存在,也就是所謂的順序化加鎖(Lock Ordering)

舉個通俗點的例子,煩人的路口堵車你一定遇到過。路口之所以堵車,是因為車太多了,大家都爭相往自己的方向去,互不相讓,不堵才怪。這時候,就需要交警在中間進行協調指揮,疏解擁堵。交警之所以可疏解擁堵,其根本原因在於,交警讓原本處於無序競爭的車流變成了井然有序的佇列。

車還是那麼多的車,路口還是那個路口,可是道路通暢了,這和執行緒的鎖競爭是類似的道理。

在上篇文章的死鎖程式碼中,執行緒1和執行緒2分別先佔有了A和B,導致了死鎖。按照剛才的思路,我們把順序調整下,執行緒1和執行緒2都先佔有A,然後再同時爭奪B,那麼死鎖就不會發生。

定義哪吒執行緒1,先搶佔A再爭奪B

private static class NeZha implements Runnable {
  public void run() {
    synchronized(lockA) {
      System.out.println("哪吒: 持有A!");

      try {
        Thread.sleep(10);
      } catch (InterruptedException ignored) {}
      System.out.println("哪吒: 等待B...");

      synchronized(lockB) {
        System.out.println("哪吒: 已經同時持有A和B...");
      }
    }
  }
}

定義蘭陵王執行緒2,也是先搶佔A再搶佔B這與此前就不同了

private static class LanLingWang implements Runnable {
  public void run() {
    synchronized(lockA) {
      System.out.println("蘭陵王: 持有A!");

      try {
        Thread.sleep(10);
      } catch (InterruptedException ignored) {}
      System.out.println("蘭陵王: 等待B...");

      synchronized(lockB) {
        System.out.println("蘭陵王: 已經同時持有A和B...");
      }
    }
  }
}

啟動兩個執行緒:

public class DeadLockDemo {
    public static final Object lockA = new Object();
    public static final Object lockB = new Object();

    public static void main(String args[]) {
        Thread thread1 = new Thread(new NeZha());
        Thread thread2 = new Thread(new LanLingWang());
        thread1.start();
        thread2.start();
    }
}

兩個執行緒的輸出結果如下:

哪吒: 持有A!
哪吒: 等待B...
哪吒: 已經同時持有A和B...
蘭陵王: 持有A!
蘭陵王: 等待B...
蘭陵王: 已經同時持有A和B...

從執行的結果中可以看到,兩個執行緒都先後獲得了自己所需要的資源,而沒有導致死鎖

調整加鎖順序是一種簡單但有效的死鎖預防策略。但是,這一策略並不是萬能的,它僅適用於你在編碼時已經知曉加鎖的順序。

二、給鎖一個超時期限

在上篇文章中,我們說過死鎖的產生有一些必要的條件,其中一個是無限等待。設定鎖超時時間正是為了打破這一條件,讓無限等待變成有限等待

仍然以前面的程式碼為例,哪吒和蘭陵王兩個執行緒在爭奪資源時,對方都互不相讓導致了無限等待的僵局。而此時,如果其中任何一方給等待設定一個期限,那麼時間一到,僵局將不攻自破,而執行緒仍可以再稍等片刻後繼續嘗試。

需要注意的是,synchronized程式碼塊不可以指定鎖超時。所以,如果需要鎖超時,你需要使用自定義鎖,或者使用JDK提供的併發工具類。相關工具類的用法,會在後續文章中介紹,本文暫不展開描述。

另外,所謂給鎖加一個超時的期限,其實有兩層含義。一是在請求鎖時需要設定超時時間,二是在獲取鎖之後對鎖的持有也要有個超時時間,總不能到手就不放,那是耍流氓

三、死鎖檢測

作為死鎖預防的第三種策略,你可以認為死鎖檢測(Deadlock Detection)是一項較重的被動技能,當我們無法順序化加鎖,也無法設定鎖的超時時間,那麼就需要進行死鎖檢測。

死鎖檢測的核心原理在於對執行緒和資源進行資料化打標和跟蹤。

線上程獲取鎖時,會將鎖和執行緒的對應關係通過Graph或者Map等資料結構記錄下來。這樣一來,執行緒在獲取鎖被拒絕時,可以通過遍歷已經記錄的資料分析是否存在死鎖。

當執行緒發現死鎖的情況後,可以採取釋放鎖,稍等片刻後再次嘗試。

附、如何視覺化檢視執行緒死鎖等狀態

在你感覺執行緒可能被阻塞或死鎖時,可以通過jstack命令檢視。如果存在死鎖,輸出的結果中會有明確的死鎖提示,如下面所示:

$ jstack -F 8321
Attaching to process ID 8321, please wait...
Debugger attached successfully.
Client compiler detected.
JVM version is 1.6.0-rc-b100
Deadlock Detection:

Found one Java-level deadlock:
=============================

"Thread2":
  waiting to lock Monitor@0x000af398 (Object@0xf819aa10, a java/lang/String),
  which is held by "Thread1"
"Thread1":
  waiting to lock Monitor@0x000af400 (Object@0xf819aa48, a java/lang/String),
  which is held by "Thread2"

Found a total of 1 deadlock.

除了jstack之外,JProfiler也是一款非常強大的執行緒與堆疊分析工具,並可以和IDEA等IDE完美結合。

藉助於JProfiler,我們可以非常直觀地看到上述示例程式碼中的死鎖,也可以在Thread Monitor中看到兩個執行緒的狀態為blocked.


需要注意的是,JProfiler是一款付費軟體,它提供了十天的免費試用時間。如果沒有常規的使用需求,而是僅用於學習的話,十天也是夠用的。當然,你也可以考慮使用jConsole、jVisualvm等。

小結

以上就是關於死鎖預防策略的全部內容。在本文中,我們介紹了三種死鎖預發策略。三種策略各有利弊,就實際工作中的應用而言,第二種給鎖設定超時期限是更為常用的一種做法,而第一種和第三種具有一定的邏輯難度和技術難度,更側重於理解而非實際應用。

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

  • 通過jstack命令檢視死鎖並解決。

延伸閱讀與參考資料

關於作者

關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不做標題黨。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章