死锁

简介

在遇到线程安全问题的时候,我们会使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。或者有的场景我们使用线程池和信号量来限制资源的使用,但这些被限制的行为可能会导致资源死锁(Resource DeadLock)。这是来自Java并发必读佳作 Java Concurrency in Practice 关于活跃性危险中的描述。
我们知道Java应用程序不像数据库服务器,能够检测一组事务中死锁的发生,进而选择一个事务去执行;在Java程序中如果遇到死锁将会是一个非常严重的问题,它轻则导致程序响应时间变长,系统吞吐量变小;重则导致应用中的某一个功能直接失去响应能力无法提供服务,这些后果都是不堪设想的。因此我们应该及时发现和规避这些问题。

死锁产生的条件

死锁的产生有四个必要的条件

  1. 互斥使用,即当资源被一个线程占用时,别的线程不能使用
  2. 不可抢占,资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放
  3. 请求和保持,当资源请求者在请求其他资源的同时保持对原因资源的占有
  4. 循环等待,多个线程存在环路的锁依赖关系而永远等待下去,例如T1占有T2的资源,T2占有T3的资源,T3占有T1的资源,这种情况可能会形成一个等待环路
    对于死锁产生的四个条件只要能破坏其中一条即可让死锁消失,但是条件一是基础,不能被破坏。

各种死锁的介绍

锁顺序死锁

先举一个顺序死锁的例子。
构建一个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查看进程中的线程信息,线程信息比较多,我把重要的复制出来,如下的图中能很明显的看到产生了死锁。

这里省略了很多线程当前状态信息

解决顺序死锁的办法其实就是保证所有线程以相同的顺序获取锁就行。

动态锁顺序死锁

动态锁顺序死锁的产生与示例

动态锁顺序死锁与上面的锁顺序死锁其实最本质的区别,就在于动态锁顺序死锁锁住的资源无法确定或者会发生改变。
比如说银行转账业务中,账户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之间产生死锁

动态锁顺序死锁的解决

解决动态锁顺序死锁的办法,就是通过一定的手段来严格控制加锁的顺序。比如通过对象中某一个唯一的属性值比如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());
        }
    }
}

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

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

协作对象之间的死锁

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

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

  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对象的锁
    如上所示的这两种情况无法避免同时执行的情况,因此存在死锁的可能性,其执行流程如下:

协作对象之间的死锁解决

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();
        for (TaxiOptimize taxi : copy) {
            image.drawMarker(taxi);
        }
        return image;
    }
}

上述的代码虽然在同步语义上有一定的改变,但是符合业务场景的需求。具体在开发中怎么去抉择锁的范围和加锁的顺序,需要各位开发大佬仔细斟酌,毕竟加锁的代码就那么点,用的好名垂千古,用不好遗臭万年,哈哈哈哈。

资源死锁

数据库连接池资源死锁

上面发生的死锁都是两个线程相互持有对方需要获取的锁资源又不释放本身持有的锁;而资源死锁与上面的案例有些相似,只是这里相互持有对方需要的资源(比如数据库连接池中的数据库连接)。现在假设有两个数据库连接池分别用来访问数据库A和数据库B,这时有多个任务需要同时访问数据库A和数据库B,他们都需要从数据库连接池中获取连接才能访问对应的数据库。做个极端的假设,数据库连接池A只有一个连接,数据库连接池B也只有一个连接(这只是为了更好的理解资源死锁的产生!),那么此时可能会出现下面所示的情况:

如上的这种情况在数据库连接池中连接数量较高的时候发生的情况是十分少的,但也并不是完全没有可能。

线程饥饿死锁

如下通过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;
/**
 * <p>
 *      单线程Executor中任务发送死锁
 * </p>
 *
 * @Author: Liziba
 * @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> {
        @Override
        public String call() throws Exception {
            return "Hello Java";
        }
    }
    /**
     * 测试
     * @param args
     */
    public static void main(String[] args) {
        executorService.submit(new Task1());
    }
}

死锁的避免和诊断

关于死锁的避免主要是这几个方面:

  1. 尽可能使用无锁编程,使用开放调用的编码设计
  2. 尽可能的缩小锁的范围,防止锁住的资源过多引发阻塞和饥饿
  3. 如果加锁的时候需要获取多个锁则应该正确的设计锁的顺序
  4. 使用定时锁,比如Lock中的tryLock()
    关于死锁的诊断主要是这几个方面:
  5. 找出代码什么地方会使用多个锁,对这些代码实例进行全局分析
  6. 通过线程转储(Thread Dump)信息来分析死锁

死锁以外的其他活跃性危险

除了死锁以外,并发的程序中可能还会存在以下几种风险

饥饿

线程饥饿在上面的线程池案例中也提到过,它指的是当前线程无法获取到CPU的执行周期(一直被其他线程占用执行),类似发生的还有在ReetranLock中的非公平锁的实现也可能会出现线程饥饿的问题。
关于线程饥饿的解决办法:

  1. 不随意改变线程的优先级,尽量使得线程的优先级一致(这个在大部分场景都是适用的)
  2. 任务的执行尽量保持随机性或者公平性(性能考虑优先)

响应时间长

响应时间长指的是某个线程执行的任务占有较长的CPU执行时间,会导致后续的操作阻塞,导致程序失去响应。比如说浏览某个网页,向服务端发起的某个请求中包含运行时间较长的任务,此时前端程序将会失去响应,使得用户体验极差。
关于响应时间长的解决办法:

  1. 异步执行
  2. 避免代码中锁住的资源过大或者是CPU密集型的资源(尽量优化)
  3. 提升硬件设备
  4. 合理的设计线程执行的优先级

活锁

活锁指的是线程不阻塞,会持续保持运行,但是这里的运行时重复的执行同一个任务。比如消息发送用队列来存储需要发送的消息,某条消息由于某些原因不能发送成功并且没有被丢弃或者做其他处理,而是直接回到队列的头部重新执行,这会导致这条消息一直循环不断的执行下去。
关于活锁的解决办法:

  1. 增加重试的随机性
  2. 增大重试间隔时间
  3. 设置最大重试次数
posted @ 2022-04-18 15:29  养诚  阅读(68)  评论(0编辑  收藏  举报