面試官:請手寫一段必然死鎖的代碼

前言

死鎖(Deadlock),是併發編程中最需要考慮的問題之一,一般來說死鎖發生的概率相對較小,但是危害奇大。本篇主要講解死鎖相關的內容,包括死鎖形成的必要條件、危害、如何避免等等。

死鎖的定義

死鎖(英語:Deadlock),又譯爲死結,計算機科學名詞。當兩個以上的運算單元,雙方都在等待對方停止運行,以獲取系統資源,但是沒有一方提前退出時,就稱爲死鎖。在多任務操作系統中,操作系統爲了協調不同行程,能否獲取系統資源時,爲了讓系統運作,必須要解決這個問題。

具體到線程死鎖,也就是多個(大於等於2個)線程相互持有對方需要的資源,在沒有外界干擾的情況下,會永遠處於等待狀態。

必然死鎖的例子

先來看一個必然發生死鎖的例子,來直觀的感受一下死鎖:

/**
 * 必然死鎖的例子
 * @author sicimike
 */
public class DeadLock {

    final private static Object lock1 = new Object();
    final private static Object lock2 = new Object();

    public static void main(String[] args) {

        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                // 先獲取lock1
                System.out.println("thread-1 get lock1");
                try {
                    // 休眠200毫秒,讓thread-2獲取lock2
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    // 在lock1同步代碼中獲取lock2
                    System.out.println("thread-1 get lock2");
                }
            }
            System.out.println("thread-1 finished");
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                // 先獲取lock2
                System.out.println("thread-1 get lock2");
                synchronized (lock1) {
                    // 在lock2同步代碼中獲取lock1
                    System.out.println("thread-2 get lock1");
                }
            }
            System.out.println("thread-2 finished");
        }, "thread-2");

        thread1.start();
        thread2.start();
    }
}

執行結果:

thread-1 get lock1
thread-2 get lock2

例子共有6行輸出,但是隻輸出了2行。不僅如此,用IDE運行該代碼時,IDE永遠不會執行結束。
對於本次運行結果,thread-1首先獲取CPU時間片,開始執行,獲取鎖lock1,輸出thread-1 get lock1,然後休眠200毫秒。在200毫秒內thread-2獲取CPU時間片,開始執行,獲取lock2,輸出thread-2 get lock2
此時tread-1必須獲取lock2才能繼續執行,執行完成才能釋放自己持有的lock1。而thread-2同理,想要繼續執行,必須先獲取thread-1持有的lock1,執行完成才能釋放lock2。就這樣,兩個線程發生了死鎖。導致後續的代碼都不會執行,之後的語句並不會輸出。

死鎖的危害

  • 首先肯定是程序得不到正確的結果,因爲處於死鎖狀態的線程無法處理原先分配的任務。
  • 死鎖會降低資源利用率,處於死鎖狀態的線程所持有的資源是不會釋放的,更不能被別的線程利用,所以會導致資源的利用率降低
  • 可能導致新的死鎖產生,死鎖的線程持有的資源,可能是系統非常寶貴且有限的資源,其他線程獲取不到,依然可能會被死鎖,產生多米諾骨牌效應

死鎖產生的必要條件

死鎖的危害非常巨大,是併發編程必須要考慮的問題。不過好在死鎖的產生條件比較嚴苛,需要同時滿足四個必要條件:

  • 互斥條件:一個資源同時最多能被一個線程持有
  • 請求與保持條件:一個線程因請求其他資源而被阻塞時,不會釋放已持有的資源
  • 不剝奪條件:線程執行完成之前,其他的線程不能搶佔該線程持有的資源
  • 循環等待條件:多個線程請求的資源形成一個等待環

只要其中一個不滿足就不可能發生死鎖。
再回過頭來看看上文的實例是不是滿足這四個條件,thread-1和thread-2所需的資源是lock1和lock2,都是互斥鎖,滿足互斥條件;thread-1和thread-2被阻塞後不會釋放持有的鎖,滿足請求與保持條件;thread-1和thread-2都不能直接搶佔對方持有的鎖,滿足不剝奪條件;thread-1需要thread-2持有的lock2,而thread-2需要thread-1持有的lock1,滿足循環等待條件
因爲只有2個線程,所以循環等待條件不是很明顯,可以把實例改成三個線程

/**
 * 三個線程-必然死鎖的例子
 * @author sicimike
 */
public class ThreeThreadDeadLock {

    final private static Object lock1 = new Object();
    final private static Object lock2 = new Object();
    final private static Object lock3 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                // 先獲取lock1
                System.out.println("thread-1 get lock1");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    // 在lock1中獲取lock2
                    System.out.println("thread-1 get lock2");
                }
            }
            System.out.println("thread-1 finished");
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                // 先獲取lock2
                System.out.println("thread-2 get lock2");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock3) {
                    // 在lock2中獲取lock3
                    System.out.println("thread-2 get lock3");
                }
            }
            System.out.println("thread-2 finished");
        }, "thread-2");

        Thread thread3 = new Thread(() -> {
            synchronized (lock3) {
                // 先獲取lock3
                System.out.println("thread-3 get lock3");
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    // 在lock3中獲取lock1
                    System.out.println("thread-3 get lock1");
                }
            }
            System.out.println("thread-3 finished");
        }, "thread-3");

        thread1.start();
        thread2.start();
        thread3.start();
    }
}

執行結果

thread-1 get lock1
thread-2 get lock2
thread-3 get lock3

同樣,程序也不會結束。
thread-1獲取lock1後還需要獲取lock2,thread-2獲取lock2後還需要lock3,thread-3獲取lock3後還需要獲取lock1,這樣就是循環等待條件,三個線程所需要的資源形成了一個環。

定位死鎖

主要講解三種方式來定位死鎖:jstack命令、jconsole工具、ThreadMXBean類,以上面兩個線程死鎖的實例演示。

  • jstack命令
    先查看系統進程,jps(Java Virtual Machine Process Status Tool)是JDK提供的一個顯示當前所有java進程pid的命令,位於...\jdk1.8.0_101\bin目錄

    C:\Users\Atao>jps
    2272
    10180 Jps
    13956 RemoteMavenServer36
    11032 Launcher
    8488 DeadLock
    

    可以很清楚的看到運行DeadLock.java類的進程pid(8488),再運行jstack命令,jstack是JDK提供的線程堆棧分析工具,使用該命令可以查看Java程序線程堆棧信息,位於...\jdk1.8.0_101\bin目錄

    C:\Users\Atao>jstack -F 8488
    Attaching to process ID 8488, please wait...
    Debugger attached successfully.
    Server compiler detected.
    JVM version is 25.101-b13
    Deadlock Detection:
    
    Found one Java-level deadlock:
    =============================
    
    "thread-1":
      waiting to lock Monitor@0x00000000173a0628 (Object@0x00000000d64dc960, a java/lang/Object),
      which is held by "thread-2"
    "thread-2":
      waiting to lock Monitor@0x000000001739f188 (Object@0x00000000d64dc950, a java/lang/Object),
      which is held by "thread-1"
    
    Found a total of 1 deadlock.
    
    Thread 1: (state = BLOCKED)
    ......
    

    很清楚的就能看到哪幾個線程發生了死鎖(現在應該知道爲什麼要給每個線程取一個有意義的名字了)

  • jconsole工具,位於...\jdk1.8.0_101\bin目錄
    啓動jconsole
    打開JConsole
    檢測死鎖
    檢測死鎖
    查看檢測結果
    查看檢測結果

  • ThreadMXBean類
    ThreadMXBean是JDK自帶的類,位於java.lang.management包中。是Java虛擬機線程的系統管理接口。以上文舉出的必然死鎖的例子爲例:

    public class DeadLock {
    
        final private static Object lock1 = new Object();
        final private static Object lock2 = new Object();
    
        public static void main(String[] args) {
    
            Thread thread1 = new Thread(() -> {
                synchronized (lock1) {
                    // 先獲取lock1
                    System.out.println("thread-1 get lock1");
                    try {
                        // 休眠200毫秒,讓thread-2獲取lock2
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                        // 在lock1同步代碼中獲取lock2
                        System.out.println("thread-1 get lock2");
                    }
                }
                System.out.println("thread-1 finished");
            }, "thread-1");
    
            Thread thread2 = new Thread(() -> {
                synchronized (lock2) {
                    // 先獲取lock2
                    System.out.println("thread-2 get lock2");
                    synchronized (lock1) {
                        // 在lock2同步代碼中獲取lock1
                        System.out.println("thread-2 get lock1");
                    }
                }
                System.out.println("thread-2 finished");
            }, "thread-2");
    
            // 檢測死鎖的線程
            Thread monitorThread = new Thread(() -> {
                while (true) {
                    try {
                        // 每隔2秒檢測一次
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("死鎖線程信息:");
                    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
                    long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
                    for (long thread : deadlockedThreads) {
                        System.out.println(threadMXBean.getThreadInfo(thread));
                    }
                }
            }, "monitor-thread");
    
            thread1.start();
            thread2.start();
            monitorThread.start();
        }
    }
    

    執行結果:

    thread-1 get lock1
    thread-2 get lock2
    死鎖線程信息:
    "thread-2" Id=12 BLOCKED on java.lang.Object@793190d2 owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on java.lang.Object@77f67184 owned by "thread-2" Id=12
    死鎖線程信息:
    "thread-2" Id=12 BLOCKED on java.lang.Object@793190d2 owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on java.lang.Object@77f67184 owned by "thread-2" Id=12
    ......
    

死鎖的處理

既然知道了死鎖產生的四個必要條件,所以只需要破壞其中一個或者多個即可。

  • 對於互斥條件,想要破壞它,就是能加共享鎖的就不要加獨佔鎖
  • 對於請求與保持條件,可以設置超時時間,阻塞一段時間後,如果還未獲取到鎖,就釋放自己持有的鎖;或者死鎖發生時,調度者強行中斷某個死鎖的狀態,並釋放持有的資源
  • 對於不剝奪條件,請求資源(鎖)時,使用可以響應中斷的鎖,例如Lock.lockInterruptibly()
  • 對於循環等待條件,這個條件相對來說是最好破壞的。只需要打破等待環即可。線程請求多把鎖的時候,做到按順序請求鎖(每個線程),這樣就不會形成等待環。

可以動手改造下上文中三個線程死鎖的例子,使三個線程均按照lock1->lock2->lock3的順序請求鎖。

最佳實踐

死鎖的處理策略總的來說有三種方式:

  • 避免策略
  • 檢測與修復
  • 不處理(鴕鳥策略)

對於生產環境而言,死鎖的大於,也就是說應該將重點放在死鎖的避免上。在實際工作中養成良好的習慣,可以大大減少死鎖發生的概率,好的習慣總結如下:

  • 爭搶鎖時設置超時,比如Lock的tryLock(long, unit)方法
  • 多使用併發工具類,而不是自己設計鎖
  • 儘量降低鎖的粒度
  • 如果能使用同步代碼塊,就不使用同步方法:鎖的粒度更小;還可以自己指定鎖對象
  • 給線程起有意義的名字:方便debug、日誌記錄
  • 避免鎖的嵌套
  • 儘量不要多個功能用同一把鎖:專鎖專用
  • 分配資源前先看能不能收回來:銀行家算法

總結

死鎖像火災一樣:不可預測、蔓延迅速、危害大。編寫併發程序的時候,一定要特別關注。

擴展閱讀

  • https://zh.wikipedia.org/zh-hans/銀行家算法
  • https://zh.wikipedia.org/wiki/哲學家就餐問題
發佈了55 篇原創文章 · 獲贊 107 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章