阿里二面:如何定位&避免死鎖?連著兩個面試問到了!

码农Academy發表於2024-03-13

在面試過程中,死鎖是必問的知識點,當然死鎖也是我們日常開發中也會遇到的一個問題,同時一些業務場景例如庫存扣減,銀行轉賬等都需要去考慮如何避免死鎖,一旦線上發生了死鎖,那可能年終不保。。。。。下面我們就來聊一聊死鎖如何定位,以及如何避免。

什麼是死鎖

死鎖(Deadlock)是指在作業系統裡,兩個或多個併發執行緒在執行過程中,因爭奪資源而造成的一種互相等待的現象,且無外力干預的情況下,這些執行緒都無法進一步執行下去。每個執行緒至少持有一個資源並等待其他執行緒所持有的資源才能繼續執行,從而形成了一個迴圈等待鏈,導致所有執行緒都被阻塞,無法順利完成。

假設有兩個倉庫A和B,它們之間在進行商品調撥。執行緒T1負責將商品從倉庫A調撥到倉庫B,而執行緒T2負責將商品從倉庫B調撥到倉庫A。每個執行緒在執行調撥操作時,需要先獲取調出倉庫和調入倉庫的鎖,以保證調撥操作的原子性。現在,假設執行緒T1已經獲取了倉庫A的鎖並且正在等待獲取倉庫B的鎖,而執行緒T2已經獲取了倉庫B的鎖並且正在等待獲取倉庫A的鎖。這時,執行緒T1持有倉庫A的鎖並且等待倉庫B的鎖,執行緒T2持有倉庫B的鎖並且等待倉庫A的鎖。由於彼此都在等待對方持有的鎖,因此兩個執行緒都無法繼續執行,導致了死鎖的發生。

死鎖產生的條件

死鎖的產生必須滿足以下四個條件。當這四個條件同時滿足時,就可能發生死鎖。

互斥條件

資源不能同時被多個執行緒佔用。如果一個資源被一個執行緒佔用,其他執行緒必須等待釋放。也就是所謂的互斥鎖。

互斥條件.png

如上圖執行緒T1已經持有了資源,那麼該資源就不能再同時被執行緒T2持有,如果執行緒T2想要獲取資源,就要一直等待(即執行緒T2阻塞),一直到執行緒T1釋放資源。

佔有並且等待條件

當前執行緒已經佔有至少一個資源,此時還想請求其他執行緒佔有的其他資源時就會造成等待,在這個等待過程中對已獲得的資源也不會釋放。

佔有並且等待條件.png

如上圖當執行緒T1已經持有了資源1,又想申請獲取資源2,而資源2已經被執行緒T3持有了,所以執行緒T1就會處於等待狀態,但是執行緒T1在等待資源2的同時並不會釋放自己已經持有的資源1

不可搶佔條件

當前已經被持有的資源只能由持有它的執行緒釋放,其他執行緒不可以強行佔有該資源。

不可搶佔條件.png

如上圖執行緒T1已經持有了資源 ,在自己使用完之前不能被其他執行緒獲取,執行緒T2如果也想使用此資源,則只能線上程T1使用完並釋放後才能獲取。

迴圈等待條件

在發生死鎖時,必然存在一個執行緒-資源的環形鏈,鏈中的每個執行緒正等待下一個執行緒所佔用資源的釋放。

image.png

如上圖執行緒T1等待執行緒T2佔有的資源,而執行緒T2等待執行緒T1佔有的資源,兩個執行緒互相等待,這樣就形成了迴圈等待。

模擬死鎖

以文章解釋死鎖概念的例子為例,我們使用程式碼模擬死鎖。

我們先模擬調撥商品操作庫存的程式碼:

public class SkuStock {  
  
    private String sku;  
  
    private String warehouse;  
  
    private Integer qty;  
  
    public SkuStock(String sku, String warehouse, Integer qty) {  
        this.sku = sku;  
        this.warehouse = warehouse;  
        this.qty = qty;  
    }  
  
	/**
	* 調撥庫存,操作庫存
	*/  
    public void transferTo(SkuStock targetSku, int quantity) {  
        synchronized (this){  
            System.out.println(Thread.currentThread().getName() + "開始操作庫存");  
  
            try {  
                Thread.sleep(2000);  
            }catch (InterruptedException e){  
                e.printStackTrace();  
            }  
  
            synchronized (targetSku){  
                // 扣減調出倉庫的庫存  
                this.qty -= quantity;  
                // 增加目標倉庫的庫存  
                targetSku.qty += quantity;  
                System.out.println(Thread.currentThread().getName() + "操作庫存結束");  
            }  
        }  
    }  
}

然後我們在模擬執行緒T1進行倉庫A向倉庫B調撥商品,執行緒t2進行倉庫B向倉庫A調撥商品。

public static void main(String[] args) {  
    SkuStock skuStockA = new SkuStock("SKU", "WA", 100);  
    SkuStock skuStockB = new SkuStock("SKU", "WB", 100);  
  
    Thread thread1 = new Thread(() -> {  
        skuStockA.transferTo(skuStockB, 50);  
    }, "T1");  
  
    Thread thread2 = new Thread(() -> {  
        skuStockB.transferTo(skuStockA, 60);  
    }, "T2");  
  
    thread1.start();  
    thread2.start();  
}

此時我們執行程式碼,就會發現程式碼只列印了開始操作庫存,沒有結束操作的日誌,此時就會發生了死鎖。

image.png

死鎖排查

當我們的程式發生死鎖時,我們需要排查,找出問題所在,關於死鎖的排查工具,我們可以使用JDK自帶的jstack工具,也可以使用一些視覺化工具例如:VisualVMJConsole等。

jstack工具

jstack是JDK自帶的一款強大的故障診斷工具,主要用於獲取Java應用程式的執行緒堆疊資訊,這對於分析Java程式的執行狀態、排查效能瓶頸、定位死鎖、凍結執行緒以及其他多執行緒相關的問題具有非常重要的作用。
對於以上死鎖程式,我們先使用jps工具列出當前系統中所有的Java程序的程序ID(PID)。

image.png

然後針對目標Java程序,使用jstack命令生成執行緒堆疊快照,它將輸出Java程序中所有執行緒的詳細堆疊資訊。

jstack 24749

然後我們可以看到輸出的日誌中,指明瞭應用程式發生死鎖的原因。

image.png

可以看到對於執行緒T1等待著執行緒T2鎖住的0x000000070fd53c38這個資源,同時鎖住了0x000000070fd53bc0這個資源,而對於執行緒T2,它等待著執行緒T1鎖住的0x000000070fd53bc0這個資源,同時鎖住了0x000000070fd53c38這個資源,這樣就發生了死鎖。

jstack輸出中會包含有關執行緒等待鎖的資訊。如果存在死鎖,你會看到執行緒在等待一個它自己或其他執行緒已經持有的鎖,形成一個等待鏈條。死鎖資訊通常會明確指出哪些執行緒參與了死鎖。

VisualVM

VisualVM是一款強大的Java效能分析和故障排除工具,它是Oracle開發並隨JDK一起提供的一個綜合性桌面應用程式。VisualVM整合了多個獨立的JDK命令列工具的功能,如jstatjmapjstackjinfo等,並且提供了豐富的圖形使用者介面,使開發者能夠更容易地監控和分析Java應用程式的效能、記憶體消耗、執行緒行為、垃圾收集等各方面資訊。

image.png

他會提示你發生了死鎖了,進入Thread Dump中檢視具體的資訊。

image.png

效果等同於使用jstack命令輸出的日誌資訊。

如何避免死鎖問題的發生

前面我們提到,產生死鎖的四個必要條件是:互斥條件、佔有並等待條件、不可搶佔條件、迴圈等待條件。那麼避免死鎖問題就只需要破環其中一個條件就可以。

破壞互斥條件

為避免死鎖的發生,我們應該避免使用互斥鎖,我們可以將其中的操作改為原子操作。
比如上述例子中,我們將發生死鎖的庫存操作的程式碼:

synchronized (targetSku){  
    // 扣減調出倉庫的庫存  
    this.qty -= quantity;  
    // 增加目標倉庫的庫存  
    targetSku.qty += quantity;  
    System.out.println(Thread.currentThread().getName() + "操作庫存結束");  
}

這裡我們不再使用synchronized關鍵字,而是透過AtomicIntegercompareAndSet方法(CAS操作)來實現併發下的庫存扣減操作。這樣做的好處是可以避免死鎖,每次操作都是原子性的,不會出現持有鎖的執行緒等待另一個執行緒釋放鎖的情況。

private AtomicInteger qtyAtomic = new AtomicInteger();  
public void transferTo1(SkuStock targetSku, int quantity) {  
    synchronized (this){  
        System.out.println(Thread.currentThread().getName() + "開始操作庫存");  
  
        try {  
            Thread.sleep(2000);  
        }catch (InterruptedException e){  
            e.printStackTrace();  
        }  
        // 扣減調出倉庫的庫存  
        this.qtyAtomic.addAndGet(-quantity);  
        // 增加目標倉庫的庫存  
        targetSku.qtyAtomic.addAndGet(quantity);  
        System.out.println(Thread.currentThread().getName() + "操作庫存結束");  
    }  
}

使用transferTo1方法重新執行程式,正常實現庫存操作。

image.png

破壞佔有且等待條件

對於佔有且等待條件,執行緒持有資源我們是無法破壞的,既然無法破壞佔有,那我們就破壞等待,我們不等待資源了。破壞佔有且等待條件,可以採取的方法之一就是一次性獲取所有需要的資源,而不是持有部分資源後再等待其他資源。在Java中,確實沒有一種直接的方式允許一個執行緒一次性獲取多個資源。但是,你可以使用一種類似資源管理器的方式來模擬一次性獲取多個資源的情況。例如,你可以建立一個資源管理器物件,該物件負責管理所有需要的資源,並在需要時為執行緒提供這些資源。其他執行緒可以向資源管理器請求資源,如果資源可用,則立即返回,如果資源不可用,則進入等待狀態。

針對上述示例,我們定義一個庫存資源管理器:

public class SkuAllocator{  
  
    private static SkuAllocator skuAllocator = new SkuAllocator();  
  
    private SkuAllocator(){}  
  
    public static SkuAllocator getSkuAllocator(){  
        return skuAllocator;  
    }  
  
    private List<Object> list = Lists.newArrayList();  
  
    /**  
     *、一次性獲取多個資源  
     * @param objs 資源  
     * @return 是否申請資源成功  
     */  
    synchronized boolean apply(Object...objs){  
        List<Object> containsList = Stream.of(objs)  
                .filter(e -> list.contains(e)).collect(Collectors.toList());  
        if (!containsList.isEmpty()){  
            return false;  
        }  
        list.addAll(Lists.newArrayList(objs));  
        return true;  
    }  
  
    /**  
     * 釋放資源  
     * @param objs 資源  
     */  
    synchronized void free(Object...objs){  
        Stream.of(objs).forEach(e -> list.remove(e));  
    }  
}

在這個資源管理器中,我們提供了兩個方法apply以及free,其中apply用於將所有的資源放獲取到,而free用於釋放所有的資源。

然後我們改造操作庫存時,執行緒執行操作庫存,需要呼叫apply將所有的資源都拿到,然後執行後面的庫存扣減,而其他執行緒在執行apply時,因為已經有現成獲取到了資源,即資源管理器中list已存在資源,所以會返回false,這樣其他的執行緒會一直等待下去,知道當前執行緒釋放資源。

private SkuAllocator skuAllocator = SkuAllocator.getSkuAllocator();  
public void transferTo2(SkuStock targetSku, int quantity) {  
    // 一次性申請庫存增加以及扣減資源,如果執行緒可以拿到資源,即管理器中存在資源,  
    // while條件不成立就往下繼續執行扣減庫存,如果沒有拿到資源,則while中是true,則while就一直自迴圈  
    while (!skuAllocator.apply(this, targetSku)){;}  
  
    try {  
        synchronized (this){  
            System.out.println(Thread.currentThread().getName() + "開始操作庫存");  
  
            try {  
                Thread.sleep(2000);  
            }catch (InterruptedException e){  
                e.printStackTrace();  
            }  
            synchronized (targetSku){  
                // 扣減調出倉庫的庫存  
                this.qty -= quantity;  
                // 增加目標倉庫的庫存  
                targetSku.qty += quantity;  
                System.out.println(Thread.currentThread().getName() + "操作庫存結束");  
            }  
        }  
    }finally {  
        // 用完,則釋放資源,讓其他執行緒使用  
        skuAllocator.free(this, targetSku);  
        System.out.println(Thread.currentThread().getName() + "釋放資源...");  
    }  
}

呼叫該方法,也會讓庫存扣減成功。

image.png

破壞不可搶佔條件

對於不可搶佔條件,我們無法搶佔或者釋放其他執行緒持有的資源,但是我們可以給執行緒設定資源持有的超時時間,如果超過這個時間還沒有釋放資源,則自動釋放資源。這樣其他的執行緒就有就會獲取資源了。

private final Lock lock = new ReentrantLock();  
public void transferTo3(SkuStock targetSku, int quantity) throws InterruptedException {  
    while (true){  
        if (lock.tryLock(2, TimeUnit.SECONDS)) {  
            try {  
                System.out.println(String.format("當前執行緒 %s 獲得物件鎖 %s", Thread.currentThread().getName(), lock));  
                if (targetSku.lock.tryLock()) {  
                    try {  
                        System.out.println(String.format("當前執行緒 %s 獲得物件鎖 %s", Thread.currentThread().getName(), targetSku.lock));  
                        // 扣減調出倉庫的庫存  
                        this.qty -= quantity;  
                        // 增加目標倉庫的庫存  
                        targetSku.qty += quantity;  
                        System.out.println(Thread.currentThread().getName() + " 操作庫存結束");  
                        break;  
                    } finally {  
                        targetSku.lock.unlock();  
                    }  
                }  
            } finally {  
                lock.unlock();  
            }  
        }   
    }  
}

執行結果如下:
image.png

破壞迴圈等待條件

對於迴圈等待條件,他因為交叉獲取資源,導致形成了一個環形等待。破壞這個條件,我們可以採取順序獲取資源。確保所有的執行緒都按照相同的順序獲取資源。這樣如果執行緒T1獲取資源1,同時執行緒T2也來獲取資源1時,會等待,知道執行緒T1釋放之後再去獲取資源1,同樣然後獲取資源2。

針對上述示例,我們對庫存增加id或者庫存操作建立時間,這樣我們使用這個ID,對庫存資源進行排序,然後按照這個順序去佔用資源。

public void transferTo4(SkuStock targetSku, int quantity) throws InterruptedException {  
    SkuStock firstSku = this.id < targetSku.id ? this : targetSku;  
    SkuStock secondSku = this != firstSku ? this : targetSku;  
  
    synchronized (firstSku){  
        System.out.println(Thread.currentThread().getName() + "開始操作庫存");  
        try {  
            Thread.sleep(2000);  
        }catch (InterruptedException e){  
            e.printStackTrace();  
        }  
  
        synchronized (secondSku){  
            // 扣減調出倉庫的庫存  
            this.qty -= quantity;  
            // 增加目標倉庫的庫存  
            targetSku.qty += quantity;  
            System.out.println(Thread.currentThread().getName() + " 操作庫存結束");  
        }  
    }  
}

執行結果如下:

image.png

在上述4種破壞死鎖條件中,我們可以觀察到,在為避免死鎖時,除了第一種方案——使用原子操作代替互斥鎖外,其餘三種方案都會導致併發操作變為序列執行,在一定程度上會犧牲效能。因此,在某些情況下,我們不應過分追求破壞死鎖的四個必要條件,因為即使這些條件被滿足,死鎖仍然有一定的機率發生。我們應該關注的是如何有效地避免死鎖的發生,而不是完全消除死鎖的可能性。因此,設計時應該考慮採取合適的措施來降低死鎖的機率,並在發生死鎖時能夠及時恢復系統的正常執行狀態。

結論

死鎖問題的產生是由兩個或者以上執行緒並行執行的時候,爭奪資源而互相等待造成的。他必須同時滿足互斥條件,佔用且等待條件,不可搶佔條件,迴圈等待條件這四個條件,才可能發生。在日常系統開發中,我們要避免死鎖。避免死鎖的方式通常有:

  1. 按順序獲取資源: 給資源編號,所有執行緒按照編號遞增的順序請求資源,釋放資源時按照相反的順序釋放。這樣可以避免迴圈等待條件的發生。

  2. 加鎖順序統一: 確定所有執行緒加鎖的順序,要求所有執行緒都按照相同的順序獲取鎖,這樣可以避免佔有且等待條件的發生。

  3. 超時放棄: 當嘗試獲取資源失敗時,設定超時時間,超過一定時間後放棄獲取資源,並釋放已佔有的資源,以避免持續等待而導致的死鎖。

  4. 死鎖檢測和恢復: 定期檢測系統中的死鎖情況,一旦檢測到死鎖,採取相應的措施進行恢復,例如中斷某些執行緒、回滾事務等。

  5. 資源分配策略: 使用資源分配策略,確保資源的合理分配和使用,避免資源過度競爭和浪費,從而降低死鎖的發生機率。

  6. 避免巢狀鎖: 儘量避免在持有一個鎖的情況下去請求另一個鎖,以減少死鎖的可能性。

  7. 使用併發庫和工具: Java中可以使用java.util.concurrent包中的高階同步工具,如SemaphoreReentrantLock(支援嘗試獲取鎖及超時機制)、StampedLock(支援樂觀讀寫)等,它們提供了比synchronized關鍵字更靈活的控制方式,有助於預防死鎖。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章