Java中各种死锁详细讲述及其解决方案(图文并茂,浅显易懂)

Posted 李子捌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java中各种死锁详细讲述及其解决方案(图文并茂,浅显易懂)相关的知识,希望对你有一定的参考价值。

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

package com.liziba.dl;

import java.math.BigDecimal;

/**
 * <p>
 *      账户类
 * </p>
 *
 * @Author: Liziba
 */
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) {
        this.number = number;
    }

    public void setBalance(BigDecimal balance) {
        this.balance = balance;
    }
}

定义转账类TransferMoney,其中有transferMoney()方法用于accountFrom账户向accountTo转账金额amt:

package com.liziba.dl;

import java.math.BigDecimal;

/**
 * <p>
 *      转账类
 * </p>
 *
 * @Author: Liziba
 */
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中锁顺序死锁问题。比如如下测试:

public static void main(String[] args) {
	// 账户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++) {
        new Thread(() -> {
            try {
                // 转账顺序 A -> B
                transferMoney(accountA, accountB, new BigDecimal(10));
            } catch (Exception e) {
                return;
            }
        }).start();

        new Thread(() -> {
            try {
                // 转账顺序 B -> A
                transferMoney(accountB, accountA, new BigDecimal(10));
            } catch (Exception e) {
                return;
            }
        }).start();
    }

}

程序执行无法正确结束,如下所示:
在这里插入图片描述

依然使用jps+ jstack查看这个java进程的线程信息,发现Thread-89和Thread-90之间产生死锁
在这里插入图片描述


3.2.2 动态锁顺序死锁的解决

解决动态锁顺序死锁的办法,就是通过一定的手段来严格控制加锁的顺序。比如通过对象中某一个唯一的属性值比如id;或者也可以通过对象的散列值+hash冲突解决来控制加锁的顺序。
我们通过对象的散列值+hash冲突解决的方式来优化上面的代码:

package com.liziba.dl;

import java.math.BigDecimal;

/**
 * <p>
 * 转账类优化 -> 通过hash算法
 * </p>
 *
 * @Author: Liziba
 */
public class TransferMoneyOptimize {

    /** hash 冲突时使用第三个锁(优秀的hash算法冲突是很少的!) */
    private static final Object conflictShareLock = new Object();

    /**
     * 转账方法
     *
     * @param accountFrom       转账方
     * @param accountTo         接收方
     * @param amt               转账金额
     * @throws Exception
     */
    public static void transferMoney(Account accountFrom,
                                     Account accountTo,
                                     BigDecimal amt) throws Exception {
		// 计算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 (accountTo) {
                synchronized (accountFrom) {
                    transferMoneyHandler(accountFrom, accountTo, amt);
                }
            }
        } else {
            // 解决hash冲突
            synchronized (conflictShareLock) {
                synchronized (accountFrom) {
                    synchronized (accountTo) {
                        transferMoneyHandler(accountFrom, accountTo, amt);
                    }
                }
            }
        }

    }

    /**
     * 账户金额增加处理
     *
     * @param accountFrom       转账方
     * @param accountTo         接收方
     * @param amt               转账金额
     * @throws Exception
     */
    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.");
        } else {
            accountFrom.setBalance(accountFrom.balance.subtract(amt));
            accountTo.setBalance(accountTo.balance.add(amt));
            System.out.println("Form" + accountFrom.number + ": " + accountFrom.balance.toPlainString()
                    +"\\t" + "To" +  accountTo.number + ": " + accountTo.balance.toPlainString());
        }
    }

}

测试代码与上面错误的示例代码一致,经过数次其输出结果均为如下:

在这里插入图片描述

在上面两种死锁的产生原因都是因为两个线程以不同的顺序获取相同的所导致的,而解决的办法都是通过一定的规范来严格控制加锁的顺序,这样就能正确的规避死锁的风险。

3.3 协作对象之间的死锁

3.3.1 协作对象死锁的产生与示例

死锁的产生往往没有上述两种死锁产生的那么明显,就算其存在死锁风险也只有在高并发的场景下才会暴露出来(这并不意味着没得高并发的应用就不用考虑死锁问题了啊,弟兄们!)。如下介绍一种隐藏的比较深的死锁,这种死锁产生在多个协作对象的函数调用不透明。
如下以出租车为例介绍协作对象之间死锁的产生,其主要涉及到以下几个类(省略了很多代码,自行脑补哈!):

  1. Coordinate -> 坐标类,出租车经纬度信息类
  2. Taxi -> 出租车类,出租车所属于某个出租车车队Fleet,此外包含当前坐标location和目的地坐标destination,出租车在更新目的地信息的时候会判断当前坐标与目的地坐标是否相等,相等则会通知所属车队车辆空闲,可以接收下一个目的地
  3. Fleet -> 出租车车队类,出租车类包含两个集合taxis和available,分别用来保存车队中所有车辆信息和车队中当前空闲的出租车信息,此外提供获取车队中所有出租车当前地址信息的快照方法getImage()
  4. Image -> 车辆地址信息快照类,用于获取出租车的地址信息

Coordinate(坐标类) 代码示例:

package com.liziba.dl;

/**
 * <p>
 *      坐标类
 * </p>
 *
 * @Author: Liziba
 */
public class Coordinate {

    /** 经度 */
    private Double longitude;

    /** 纬度 */
    private Double latitude;

    // 省略 getXxx,setXxx等方法
}

Taxi(出租车类)代码示例;

package com.liziba.dl;

import java.util.Objects;

/**
 * <p>
 *      出租车类
 * </p>
 *
 * @Author: Liziba
 */
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);
    }

    @Override
    public int hashCode() {
        return Objects.hash(location, destination);
    }
}

Fleet(出租车车队类)示例代码:

package com.liziba.dl;

import java.util.Set;

/**
 * <p>
 *      车队类 -> 调度管理出租车
 * </p>
 *
 * @Author: Liziba
 */
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);
    }

    /**
     * 获取所有出租车在不同时刻的地址快照
     * @return
     */
    public synchronized Image getImage() {
        Image image = new Image();
        for (Taxi taxi : taxis) {
            image.drawMarker(taxi);
        }
        return image;
    }
    
}

Image(车辆地址信息快照类)示例代码:

package com.liziba.dl;

import java.util.HashMap;
import java.util.Map;

/**
 * <p>
 *  获取所有出租车在某一时刻的位置快照
 * </p>
 *
 * @Author: Liziba
 */
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(优化出租车类):

package com.liziba.dl;

import java.util.Objects;

/**
 * <p>
 *      出租车类优化
 * </p>
 *
 * @Author: Liziba
 */
public class TaxiOptimize {
	
     // 省略相同的属性和函数
    
    /**
     * 优化内容
     * setLocation(Coordinate location)方法不在加锁
     * 将同步范围(锁住的代码)缩小
     * this的锁与fleet顺序获取 ,锁内没有嵌套,不会死锁
     *
     * @param location
     */
    public void setLocation(Coordinate location) {
        this.location = location;
        boolean release = false;
        synchronized (this) {
            if (location.equals(destination)) {
                release = true;
            }
        }
        if (release) {
            fleet.free(this);
        }
    }

}

Fleet -> FleetOptimize(优化出租车车队类):

package com.liziba.dl;

import java.util.HashSet;
import java.util.Set;

/**
 * <p>
 *      出租车车队类优化
 * </p>
 *
 * @Author: Liziba
 */
public class FleetOptimize {

	// 省略相同的属性和函数
    
    /**
     *	优化内容
     *  getImage()不再加锁
     *	将同步范围(锁住的代码)缩小
     *  this(出租车车队对象)与drawMarker()方法中获取taxi对象的锁不再嵌套不会死锁
     *
     * @return
     */
    public Image getImage() {
        Set<TaxiOptimize> copy ;
        synchronized (this) {
            copy = new HashSet<TaxiOptimize>(taxis);
        }
        Image image = new Image();

以上是关于Java中各种死锁详细讲述及其解决方案(图文并茂,浅显易懂)的主要内容,如果未能解决你的问题,请参考以下文章

java死锁示例及其发现方法

Java中死锁的简单例子及其避免

图文并茂,详细讲解UML类图符号各种关系说明以及举例

超强图文|并发编程等待/通知机制就是这个feel~

手把手教你分析Mysql死锁问题

死锁及其应对方案