天天看點

死鎖

1、簡介

在遇到線程安全問題的時候,我們會使用加鎖機制來確定線程安全,但如果過度地使用加鎖,則可能導緻鎖順序死鎖(Lock-Ordering Deadlock)。或者有的場景我們使用線程池和信号量來限制資源的使用,但這些被限制的行為可能會導緻資源死鎖(Resource DeadLock)。這是來自Java并發必讀佳作 Java Concurrency in Practice 關于活躍性危險中的描述。

我們知道Java應用程式不像資料庫伺服器,能夠檢測一組事務中死鎖的發生,進而選擇一個事務去執行;在Java程式中如果遇到死鎖将會是一個非常嚴重的問題,它輕則導緻程式響應時間變長,系統吞吐量變小;重則導緻應用中的某一個功能直接失去響應能力無法提供服務,這些後果都是不堪設想的。是以我們應該及時發現和規避這些問題。

2、死鎖産生的條件

死鎖的産生有四個必要的條件

  1. 互斥使用,即當資源被一個線程占用時,别的線程不能使用
  2. 不可搶占,資源請求者不能強制從資源占有者手中搶奪資源,資源隻能由占有者主動釋放
  3. 請求和保持,當資源請求者在請求其他資源的同時保持對原因資源的占有
  4. 循環等待,多個線程存在環路的鎖依賴關系而永遠等待下去,例如T1占有T2的資源,T2占有T3的資源,T3占有T1的資源,這種情況可能會形成一個等待環路

對于死鎖産生的四個條件隻要能破壞其中一條即可讓死鎖消失,但是條件一是基礎,不能被破壞。

3、各種死鎖的介紹

3.1 鎖順序死鎖

先舉一個順序死鎖的例子。

建構一個LeftRightDeadLock類,這個類中有兩個共享資源right,left我們通過對這兩個共享資源加鎖的方式來控制程式的執行流程,但是這個示例在高并發的場景下存在順序死鎖的風險。

如下示意圖存在死鎖風險

死鎖

LeftRightDeadLock示例代碼:

package com.liziba.dl;

/**

 * <p>

 *     順序死鎖

 * </p>

 *

 * @Author: Liziba

 */

public class LeftRightDeadLock {

    private final Object right = new Object();

    private final Object left = new Object();

    /**

     * 加鎖順序從left -> right

     */

    public void leftToRight() {

        synchronized (left) {

            synchronized (right) {

                System.out.println(Thread.currentThread().getName() + " left -> right lock.");

            }

        }

    }

     * 加鎖順序right -> left

    public void rightToLeft() {

        synchronized (right) {

            synchronized (left) {

                System.out.println(Thread.currentThread().getName() + " right -> left lock.");

}

測試代碼,通過建立多個線程,并發執行上面的LeftRightDeadLock

public static void main(String[] args) {

    LeftRightDeadLock lrDeadLock = new LeftRightDeadLock();

    for (int i = 0; i < 10; i++) {

        new Thread(() -> {

            // 為了更好的示範死鎖,将兩個方法的調用放置到同一個線程中執行

            lrDeadLock.leftToRight();

            lrDeadLock.rightToLeft();

        }, "ThreadA-"+i).start();

可以看到如下的運作結果,程式并未結束,但是也無法繼續運作。

死鎖

産生這種情況的原因,是不同的線程通過不同順序去擷取相同的鎖;比如線程1擷取鎖的順序是left -> right,而線程2擷取鎖的順序是right -> left,在某種情況下會發生死鎖。拿上面的案例分析,我們通過Java自帶的jps和jstack工具檢視java程序ID和線程相關資訊。

jps檢視LeftRightDeadLock的程序id為17968

死鎖

jstack檢視程序中的線程資訊,線程資訊比較多,我把重要的複制出來,如下的圖中能很明顯的看到産生了死鎖。

死鎖

這裡省略了很多線程目前狀态資訊

死鎖

解決順序死鎖的辦法其實就是保證所有線程以相同的順序擷取鎖就行。

3.2 動态鎖順序死鎖

3.2.1 動态鎖順序死鎖的産生與示例

動态鎖順序死鎖與上面的鎖順序死鎖其實最本質的差別,就在于動态鎖順序死鎖鎖住的資源無法确定或者會發生改變。

比如說銀行轉賬業務中,賬戶A向賬戶B轉賬,賬戶B也可以向賬戶A轉賬,這種情況下如果加鎖的方式不正确就會發生死鎖,比如如下代碼:

定義簡單的賬戶類Account

import java.math.BigDecimal;

 *      賬戶類

public class Account {

    /** 賬戶 */

    public String number;

    /** 餘額 */

    public BigDecimal balance;

    public Account(String number, BigDecimal balance) {

        this.number = number;

        this.balance = balance;

    public void setNumber(String number) {

    public void setBalance(BigDecimal balance) {

定義轉賬類TransferMoney,其中有transferMoney()方法用于accountFrom賬戶向accountTo轉賬金額amt:

 *      轉賬類

public class TransferMoney {

      /**

     * 轉賬方法

     *

     * @param accountFrom       轉賬方

     * @param accountTo         接收方

     * @param amt               轉賬金額

     * @throws Exception

    public static void transferMoney(Account accountFrom,

                                     Account accountTo,

                                     BigDecimal amt) throws Exception {

        synchronized (accountFrom) {

            synchronized (accountTo) {

                BigDecimal formBalance = accountFrom.balance;

                if (formBalance.compareTo(amt) < 0) {

                    throw new Exception(accountFrom.number + " balance is not enough.");

                } else {

                    accountFrom.setBalance(formBalance.subtract(amt));

                    accountTo.setBalance(accountTo.balance.add(amt));

                    System.out.println("Form" + accountFrom.number + ": " + accountFrom.balance.toPlainString()

                    +"\t" + "To" +  accountTo.number + ": " + accountTo.balance.toPlainString());

                }

上面這個類看似規定了鎖的順序由accountFrom到accountTo不會産生死鎖,但是這個accountFrom和accountTo是由調用方來傳入的,當A向B轉賬時accountFrom = A,accountTo = B;當B向A轉賬時accountFrom = B,accountTo = A;假設兩者在同一時刻給對方發起轉賬,則仍然存在3.1中鎖順序死鎖問題。比如如下測試:

 // 賬戶A && 賬戶B

    Account accountA = new Account("111111", new BigDecimal(10000));

    Account accountB = new Account("2222222", new BigDecimal(10000));

 // 循環建立線程 A -> B ; B -> A 各一百個線程

    for (int i = 0; i < 100; i++) {

            try {

                // 轉賬順序 A -> B

                transferMoney(accountA, accountB, new BigDecimal(10));

            } catch (Exception e) {

                return;

        }).start();

                // 轉賬順序 B -> A

                transferMoney(accountB, accountA, new BigDecimal(10));

程式執行無法正确結束,如下所示:

死鎖

依然使用jps+ jstack檢視這個java程序的線程資訊,發現Thread-89和Thread-90之間産生死鎖

死鎖
3.2.2 動态鎖順序死鎖的解決

解決動态鎖順序死鎖的辦法,就是通過一定的手段來嚴格控制加鎖的順序。比如通過對象中某一個唯一的屬性值比如id;或者也可以通過對象的散列值+hash沖突解決來控制加鎖的順序。

我們通過對象的散列值+hash沖突解決的方式來優化上面的代碼:

 * 轉賬類優化 -> 通過hash算法

public class TransferMoneyOptimize {

    /** hash 沖突時使用第三個鎖(優秀的hash算法沖突是很少的!) */

    private static final Object conflictShareLock = new Object();

  // 計算hash值

        int accountFromHash = System.identityHashCode(accountFrom);

        int accountToHash = System.identityHashCode(accountTo);

  // 如下三個分支能一定控制賬戶之間的轉是不會産生死鎖的

        if (accountFromHash > accountToHash) {

            synchronized (accountFrom) {

                synchronized (accountTo) {

                    transferMoneyHandler(accountFrom, accountTo, amt);

        } else if (accountToHash > accountFromHash) {

                synchronized (accountFrom) {

        } else {

            // 解決hash沖突

            synchronized (conflictShareLock) {

                    synchronized (accountTo) {

                        transferMoneyHandler(accountFrom, accountTo, amt);

                    }

     * 賬戶金額增加處理

    private static void transferMoneyHandler(Account accountFrom,

                                             Account accountTo,

                                             BigDecimal amt) throws Exception {

        if (accountFrom.balance.compareTo(amt) < 0) {

            throw new Exception(accountFrom.number + " balance is not enough.");

            accountFrom.setBalance(accountFrom.balance.subtract(amt));

            accountTo.setBalance(accountTo.balance.add(amt));

            System.out.println("Form" + accountFrom.number + ": " + accountFrom.balance.toPlainString()

測試代碼與上面錯誤的示例代碼一緻,經過數次其輸出結果均為如下:

死鎖

在上面兩種死鎖的産生原因都是因為兩個線程以不同的順序擷取相同的所導緻的,而解決的辦法都是通過一定的規範來嚴格控制加鎖的順序,這樣就能正确的規避死鎖的風險。

3.3 協作對象之間的死鎖

3.3.1 協作對象死鎖的産生與示例

死鎖的産生往往沒有上述兩種死鎖産生的那麼明顯,就算其存在死鎖風險也隻有在高并發的場景下才會暴露出來(這并不意味着沒得高并發的應用就不用考慮死鎖問題了啊,弟兄們!)。如下介紹一種隐藏的比較深的死鎖,這種死鎖産生在多個協作對象的函數調用不透明。

如下以計程車為例介紹協作對象之間死鎖的産生,其主要涉及到以下幾個類(省略了很多代碼,自行腦補哈!):

  1. Coordinate -> 坐标類,計程車經緯度資訊類
  2. Taxi -> 計程車類,計程車所屬于某個計程車車隊Fleet,此外包含目前坐标location和目的地坐标destination,計程車在更新目的地資訊的時候會判斷目前坐标與目的地坐标是否相等,相等則會通知所屬車隊車輛空閑,可以接收下一個目的地
  3. Fleet -> 計程車車隊類,計程車類包含兩個集合taxis和available,分别用來儲存車隊中所有車輛資訊和車隊中目前空閑的計程車資訊,此外提供擷取車隊中所有計程車目前位址資訊的快照方法getImage()
  4. Image -> 車輛位址資訊快照類,用于擷取計程車的位址資訊

Coordinate(坐标類) 代碼示例:

 *      坐标類

public class Coordinate {

    /** 經度 */

    private Double longitude;

    /** 緯度 */

    private Double latitude;

    // 省略 getXxx,setXxx等方法

Taxi(計程車類)代碼示例;

import java.util.Objects;

 *      計程車類

public class Taxi {

    /** 計程車唯一标志 */

    private String id;

    /** 目前坐标 */

    private Coordinate location;

    /** 目的地坐标 */

    private Coordinate destination;

    /** 所屬車隊 */

    private final Fleet fleet;

     * 擷取目前位址資訊

     * @return

    public synchronized Coordinate getLocation() {

        return location;

     * 更新目前位址資訊

     * 如果目前位址與目的地位址一緻,則表名到達目的地需要通知車隊,目前計程車空閑可用前往下一個目的地

     * 

     * @param location

    public synchronized void setLocation(Coordinate location) {

        this.location = location;

        if (location.equals(destination)) {

            fleet.free(this);

    public Coordinate getDestination() {

        return destination;

     * 設定目的地

     * @param destination

    public synchronized void setDestination(Coordinate destination) {

        this.destination = destination;

    public Taxi(Fleet fleet) {

        this.fleet = fleet;

    public String getId() {

        return id;

    public void setId(String id) {

        this.id = id;

    @Override

    public boolean equals(Object o) {

        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) return false;

        Taxi taxi = (Taxi) o;

        return Objects.equals(location, taxi.location) &&

                Objects.equals(destination, taxi.destination);

    public int hashCode() {

        return Objects.hash(location, destination);

Fleet(計程車車隊類)示例代碼:

import java.util.Set;

 *      車隊類 -> 排程管理計程車

public class Fleet {

    /** 車隊中所有計程車 */

    private final Set<Taxi> taxis;

    /** 車隊中目前空閑的計程車 */

    private final Set<Taxi> available;

    public Fleet(Set<Taxi> taxis) {

        this.taxis = this.available = taxis;

     * 計程車到達目的地後調用該方法,向車隊發出目前計程車空閑資訊

     * @param taxi

    public synchronized void free(Taxi taxi) {

        available.add(taxi);

     * 擷取所有計程車在不同時刻的位址快照

    public synchronized Image getImage() {

        Image image = new Image();

        for (Taxi taxi : taxis) {

            image.drawMarker(taxi);

        return image;

Image(車輛位址資訊快照類)示例代碼:

import java.util.HashMap;

import java.util.Map;

 *  擷取所有計程車在某一時刻的位置快照

public class Image {

    Map<String, Coordinate> locationSnapshot = new HashMap<>();

    public void drawMarker(Taxi taxi) {

        locationSnapshot.put(taxi.getId(), taxi.getLocation());

在上述代碼中,看不到一個方法中有對多個資源直接加鎖,但仔細分析卻能發現在方法的調用之間是存在對多個資源“隐式”加鎖的,比如Taxi中的setLocation(Coordinate location)與Fleet中的Image getImage()。

  • setLocation(Coordinate location)方法需要擷取目前計程車Taxi對象的鎖以及計程車所屬車隊Fleet的鎖
  • getImage()方法需要擷取目前車隊Fleet的鎖,以及在周遊計程車擷取其位址資訊時需要擷取每個計程車Taxi對象的鎖

如上所示的這兩種情況無法避免同時執行的情況,是以存在死鎖的可能性,其執行流程如下:

死鎖
3.3.2 協作對象之間的死鎖解決

Taxi中的setLocation(Coordinate location)方法與getImage()方法中包含其他方法的調用,方法的調用應該是透明的也就是說,調用方無需知道方法内部的執行邏輯,這是正确的。但是方法中調用的其他方法可能是同步方法或者方法中會發生較長時間的阻塞,這會導緻死鎖或者線程長時間等待等問題。基于此類問題,可以采用縮小同步代碼的通路(鎖盡可能少的代碼)和開放調用(不加鎖)來解決(Open Call)。

上述代碼我們基于上面提的兩種方式來優化:

Taxi -> TaxiOptimize(優化計程車類):

 *      計程車類優化

public class TaxiOptimize {

     // 省略相同的屬性和函數

     * 優化内容

     * setLocation(Coordinate location)方法不在加鎖

     * 将同步範圍(鎖住的代碼)縮小

     * this的鎖與fleet順序擷取 ,鎖内沒有嵌套,不會死鎖

    public void setLocation(Coordinate location) {

        boolean release = false;

        synchronized (this) {

            if (location.equals(destination)) {

                release = true;

        if (release) {

Fleet -> FleetOptimize(優化計程車車隊類):

import java.util.HashSet;

 *      計程車車隊類優化

public class FleetOptimize {

 // 省略相同的屬性和函數

     * 優化内容

     *  getImage()不再加鎖

     * 将同步範圍(鎖住的代碼)縮小

     *  this(計程車車隊對象)與drawMarker()方法中擷取taxi對象的鎖不再嵌套不會死鎖

    public Image getImage() {

        Set<TaxiOptimize> copy ;

            copy = new HashSet<TaxiOptimize>(taxis);

        for (TaxiOptimize taxi : copy) {

上述的代碼雖然在同步語義上有一定的改變,但是符合業務場景的需求。具體在開發中怎麼去抉擇鎖的範圍和加鎖的順序,需要各位開發大佬仔細斟酌,畢竟加鎖的代碼就那麼點,用的好名垂千古,用不好遺臭萬年,哈哈哈哈。

3.4 資源死鎖

3.4.1 資料庫連接配接池資源死鎖

上面發生的死鎖都是兩個線程互相持有對方需要擷取的鎖資源又不釋放本身持有的鎖;而資源死鎖與上面的案例有些相似,隻是這裡互相持有對方需要的資源(比如資料庫連接配接池中的資料庫連接配接)。現在假設有兩個資料庫連接配接池分别用來通路資料庫A和資料庫B,這時有多個任務需要同時通路資料庫A和資料庫B,他們都需要從資料庫連接配接池中擷取連接配接才能通路對應的資料庫。做個極端的假設,資料庫連接配接池A隻有一個連接配接,資料庫連接配接池B也隻有一個連接配接(這隻是為了更好的了解資源死鎖的産生!),那麼此時可能會出現下面所示的情況:

死鎖

如上的這種情況在資料庫連接配接池中連接配接數量較高的時候發生的情況是十分少的,但也并不是完全沒有可能。

3.4.2 線程饑餓死鎖

如下通過Executors.newSingleThreadExecutor()建構一個隻有一個線程的線程池,送出的主任務會再次送出兩個任務到這個線程池中去執行,在主任務中等待兩個子任務的結果,而子任務又必須等到主任務執行結束後才能執行,這種情況就會産生線程饑餓死鎖。

package com.lizba.currency.deadlock;

import java.util.concurrent.Callable;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

import java.util.concurrent.Future;

 *      單線程Executor中任務發送死鎖

 * @Date: 2021/7/1 21:25

public class ThreadDeadLock {

    /** 單個線程的線程池 */

    static ExecutorService executorService = Executors.newSingleThreadExecutor();

    public static class Task1 implements Callable<String> {

        @Override

        public String call() throws Exception {

            Future<String> first = executorService.submit(new Task2());

            Future<String> second = executorService.submit(new Task2());

            // 目前任務等待子任務的結果,但是兩個子任務在等待主任務完成,導緻死鎖

            return first.get() + second.get();

    public static class Task2 implements Callable<String> {

            return "Hello Java";

     * 測試

     * @param args

    public static void main(String[] args) {

        executorService.submit(new Task1());

4、死鎖的避免和診斷

關于死鎖的避免主要是這幾個方面:

  1. 盡可能使用無鎖程式設計,使用開放調用的編碼設計
  2. 盡可能的縮小鎖的範圍,防止鎖住的資源過多引發阻塞和饑餓
  3. 如果加鎖的時候需要擷取多個鎖則應該正确的設計鎖的順序
  4. 使用定時鎖,比如Lock中的tryLock()

關于死鎖的診斷主要是這幾個方面:

  1. 找出代碼什麼地方會使用多個鎖,對這些代碼執行個體進行全局分析
  2. 通過線程轉儲(Thread Dump)資訊來分析死鎖

5、死鎖以外的其他活躍性危險

除了死鎖以外,并發的程式中可能還會存在以下幾種風險

5.1 饑餓

線程饑餓在上面的線程池案例中也提到過,它指的是目前線程無法擷取到CPU的執行周期(一直被其他線程占用執行),類似發生的還有在ReetranLock中的非公平鎖的實作也可能會出現線程饑餓的問題。

關于線程饑餓的解決辦法:

  1. 不随意改變線程的優先級,盡量使得線程的優先級一緻(這個在大部分場景都是适用的)
  2. 任務的執行盡量保持随機性或者公平性(性能考慮優先)

5.2 響應時間長

響應時間長指的是某個線程執行的任務占有較長的CPU執行時間,會導緻後續的操作阻塞,導緻程式失去響應。比如說浏覽某個網頁,向服務端發起的某個請求中包含運作時間較長的任務,此時前端程式将會失去響應,使得使用者體驗極差。

關于響應時間長的解決辦法:

  1. 異步執行
  2. 避免代碼中鎖住的資源過大或者是CPU密集型的資源(盡量優化)
  3. 提升硬體裝置
  4. 合理的設計線程執行的優先級

5.3 活鎖

活鎖指的是線程不阻塞,會持續保持運作,但是這裡的運作時重複的執行同一個任務。比如消息發送用隊列來存儲需要發送的消息,某條消息由于某些原因不能發送成功并且沒有被丢棄或者做其他處理,而是直接回到隊列的頭部重新執行,這會導緻這條消息一直循環不斷的執行下去。

關于活鎖的解決辦法:

  1. 增加重試的随機性
  2. 增大重試間隔時間
  3. 設定最大重試次數