解决死锁的100种方法

死锁是多线程编程或者说是并发编程中的一个经典问题,也是咱们在实际工做中极可能会碰到的问题。相信大部分读者对“死锁”这个词都是略有耳闻的,但从我对后端开发岗位的面试状况来看不少同窗每每对死锁都尚未系统的了解。虽然“死锁”听起来很高深,可是实际上已经被研究得比较透彻,大部分的解决方法都很是成熟和清晰,因此你们彻底不用担忧这篇文章的难度。java

虽然本文是一篇介绍死锁及其解决方式的文章,可是对于多线程程序中的非死锁问题咱们也应该有所了解,这样才能写出正确且高效的多线程程序。多线程程序中的非死锁问题主要分为两类:面试

  1. 违反原子性问题
    • 一些语句在底层会被分为多个底层指令运行,因此在多个线程之间这些指令就可能会存在穿插,这样程序的行为就可能会与预期不符形成bug。
  2. 违反执行顺序问题
    • 一些程序语句可能会由于子线程当即启动早于父线程中的后续代码,或者是多个线程并发执行等状况,形成程序运行顺序和指望不符致使产生bug。

这两大非死锁多线程问题及其解决方案在以前的文章多线程中那些看不到的陷阱里都有详细的介绍,感兴趣的读者能够了解一下。数据库

接下来就让咱们开始消灭死锁吧!编程

初识死锁

什么是死锁?

死锁,顾名思义就是致使线程卡死的锁冲突,例以下面的这种状况:后端

线程t1 线程t2
获取锁A
获取锁B
获取锁B(等待线程t2释放锁B)
获取锁A(等待线程t1释放锁A)

能够看出,上面的两个线程已经互相卡死了,线程t1在等待线程t2释放锁B,而线程t2在等待线程t1释放锁A。两个线程各执己见也就没有一个线程能够继续往下执行了。这种状况下就发生了死锁数组

死锁的四个必要条件

上面的状况只是死锁的一个例子,咱们能够用更精确的方式描述死锁出现的条件:安全

  1. 互斥。资源被竞争性地访问,这里的资源能够理解为锁;
  2. 持有并等待。线程持有已经分配给他们的资源,同时等待其余的资源;
  3. 不抢占。线程已经获取到的资源不会被其余线程强制抢占;
  4. 环路等待。线程之间存在资源的环形依赖链,每一个线程都依赖于链条中的下一个线程释放必要的资源,而链条的末尾又依赖了链条头部的线程,进入了一个循环等待的状态。

上面这四个都是死锁出现的必要条件,若是其中任何一个条件不知足都不会出现死锁。虽然这四个条件的定义看起来很是的理论和官方,可是在实际的编程实践中,咱们正是在死锁的这四个必要条件基础上构建出解决方案的。因此这里不妨思考一下这四个条件各自的含义,想想若是去掉其中的一个条件死锁是否还能发生,或者为何不能发生。bash

阻止死锁的发生

了解了死锁的概念和四个必要条件以后,咱们下面就正式开始解决死锁问题了。对于死锁问题,咱们最但愿可以达到的固然是彻底不发生死锁问题,也就是在死锁发生以前就阻止它。数据结构

那么想要阻止死锁的发生,咱们天然是要让死锁没法成立,最直接的方法固然是破坏掉死锁出现的必要条件。只要有任何一个必要条件没法成立,那么死锁也就没办法发生了。多线程

破坏环路等待条件

实践中最有效也是最经常使用的一种死锁阻止技术就是锁排序,经过对加锁的操做进行排序咱们就可以破坏环路等待条件。例如当咱们须要获取数组中某一个位置对应的锁来修改这个位置上保存的值时,若是须要同时获取多个位置对应的锁,那么咱们就能够按位置在数组中的排列前后顺序统一从前日后加锁。

试想一下若是程序中全部须要加锁的代码都按照一个统一的固定顺序加锁,那么咱们就能够想象锁被放在了一条不断向前延伸的直线上,而由于加锁的顺序必定是沿着这条线向下走的,因此每条线程都只能向前加锁,而不能再回头获取已经在后面的锁了。这样一来,线程只会向前单向等待锁释放,天然也就没法造成一个环路了。

其实大部分死锁解决方法不止能够用于多线程编程领域,还能够扩展到更多的并发场景下。好比在数据库操做中,若是咱们要对某几行数据执行更新操做,那么就会获取这几行数据所对应的锁,咱们一样能够经过对数据库更新语句进行排序来阻止在数据库层面发生的死锁。

可是这种方案也存在它的缺点,好比在大型系统当中,不一样模块直接解耦和隔离得很是完全,不一样模块的研发同窗之间都不清楚具体的实现细节,在这样的状况下就很难作到整个系统层面的全局锁排序了。在这种状况下,咱们能够对方案进行扩充,例如Linux在内存映射代码就使用了一种锁分组排序的方式来解决这个问题。锁分组排序首先按模块将锁分为了避免同的组,每一个组之间定义了严格的加锁顺序,而后再在组内对具体的锁按规则进行排序,这样就保证了全局的加锁顺序一致。在Linux的对应的源码顶部,咱们能够看到有很是详尽的注释定义了明确的锁排序规则。

这种解决方案若是规模过大的话即便能够实现也会很是的脆弱,只要有一个加锁操做没有遵照锁排序规则就有可能会引起死锁。不过在像微服务之类解耦比较充分的场景下,只要架构拆分合理,任务模块尽量小且不会将加锁范围扩大到模块以外,那么锁排序将是一种很是实用和便捷的死锁阻止技术。

破坏持有并等待条件

想要破坏持有并等待条件,咱们能够一次性原子性地获取全部须要的锁,好比经过一个专门的全局锁做为加锁令牌控制加锁操做,只有获取了这个锁才能对其余锁执行加锁操做。这样对于一个线程来讲就至关于一次性获取到了全部须要的锁,且除非等待加锁令牌不然在获取其余锁的过程当中不会发生锁等待。

这样的解决方案虽然简单粗暴,但这种简单粗暴也带来了一些问题:

  1. 这种实现会下降系统的并发性,由于全部须要获取锁的线程都要去竞争同一个加锁令牌锁;
  2. 而且由于要在程序的一开始就获取全部须要的锁,这就致使了线程持有锁的时间超出了实际须要,不少锁资源被长时间的持有所浪费,而其余线程只能等待以前的线程执行结束后统一释放全部锁;
  3. 另外一方面,现代程序设计理念要求咱们提升程序的封装性,不一样模块之间的细节要互相隐藏,这就使得在一个统一的位置一次性获取全部锁变得再也不可能。

破坏不抢占条件

若是一个线程已经获取到了一些锁,那么在这个线程释放锁以前这些锁是不会被强制抢占的。可是为了防止死锁的发生,咱们能够选择让线程在获取后续的锁失败时主动放弃本身已经持有的锁并在以后重试整个任务,这样其余等待这些锁的线程就能够继续执行了。

一样的,这个方案也会有本身的缺陷:

  1. 虽然这种方式能够避免死锁,可是若是几个互相存在竞争的线程不断地放弃、重试、放弃,那么就会致使活锁问题(livelock)。在这种状况下,虽然线程没有由于锁冲突被卡死,可是仍然会被阻塞至关长的时间甚至一直处于重试当中。
    • 这个问题的一种解决方式是给任务重试添加一个随机的延迟时间,这样就能大大下降任务冲突的几率了。在一些接口请求框架中也使用了这种技巧来分散服务高峰期的请求重试操做,防止服务陷入阻塞、崩溃、阻塞的恶性循环。
  2. 仍是由于程序的封装性,在一个模块中难以释放其余模块中已经获取到的锁。

虽然每个方案都有本身的缺陷,可是在适合它们的场景下,它们都能发挥出巨大的做用。

破坏互斥条件

在以前的文章中,咱们已经了解了一种与锁彻底不一样的同步方式CAS。经过CAS提供的原子性支持,咱们能够实现各类无锁数据结构,不只避免了互斥锁所带来的开销和复杂性,也由此避开了咱们一直在讨论的死锁问题。

AtomicInteger类中就大量使用了CAS操做来实现并发安全,例如incrementAndGet()方法就是用Unsafe类中基于CAS的原子累加方法getAndAddInt来实现的。下面是Unsafe类的getAndAddInt方法实现:

/**
 * 增长指定字段值并返回原值
 * 
 * @param obj           目标对象
 * @param valueOffset   目标字段的内存偏移量
 * @param increment     增长值
 * @return  字段原值
 */
public final int getAndAddInt(Object obj, long valueOffset, int increment) {
    // 保存字段原值的变量
    int oldValue;
    do {
        // 获取字段原值
        oldValue = this.getIntVolatile(obj, valueOffset);

        // obj和valueOffset惟一指定了目标字段所对应的内存区域
        // while条件中不断调用CAS方法来对目标字段值进行增长,并保证字段的值没有被其余线程修改
        // 若是在修改过程当中其余线程修改了这个字段的值,那么CAS操做失败,循环语句会重试操做
    } while(!this.compareAndSwapInt(obj, valueOffset, oldValue, oldValue + increment));

    // 返回字段的原值
    return oldValue;
}
复制代码

上面代码中的compareAndSwapInt方法就是咱们说的CAS操做(Compare And Swap),咱们能够看到,CAS在每次执行时不必定会成功。若是执行CAS操做时目标字段的值已经被别的线程修改了,那么此次CAS操做就会失败,循环语句将会在CAS操做失败的状况下不断重试一样的操做。这种不断重试的方式就被称为自旋,在jvm当中对互斥锁的等待也会经过少许的自旋操做来进行优化。

不过若是一个变量同时被多个线程以CAS方式修改,那么就有可能致使出现活锁,多个线程将会一直不断重试CAS操做。因此CAS操做的成本和数据竞争的激烈程度密切相关,在一些竞争很是激烈的状况下,CAS操做的成本甚至会超过互斥锁。

除了累加整型值这样的简单场景以外,还有更多更复杂的无锁(lock-free)数据结构,例如java.util.concurrent包中的ConcurrentLinkedDeque双端队列类就是一个无锁的并发安全链表实现,有兴趣的读者能够了解一下。

这种方法一样能够用在数据库操做上,当咱们执行update语句时能够在where子句中添加上一些字段的旧值做为条件,好比update t_xxxx set value = <newValue>, version = version + 1 where id = xxx and version = 10,这样咱们就能够经过update语句返回的影响行数是否是0来判断更新操做有没有成功了,这是否是和CAS很类似?

其余解决死锁的方法 —— 探测并恢复

有时,咱们并不须要彻底阻止死锁的发生,而是能够经过其余的手段来控制死锁的影响。就像若是新的治疗手段可使癌症病人继续活七八十年,那么癌症也就没有那么可怕了。

还有一种解决死锁的方法就是让死锁发生,以后再解决它,就像电脑死机之后直接重启同样。使用这种方法咱们能够这么作:若是多个线程出现了死锁的状况,那么咱们就杀死足够多的线程使系统恢复到可运行状态。在咱们经常使用的关系型数据库中使用的就是这种方法,数据库会周期性地使用探测器建立资源图,而后检查其中是否存在循环。若是探测到了循环(死锁),那么数据库就会根据估算的执行成本高低杀死能够解决死锁问题的尽量成本最小的线程。

数据库在被外部应用调用的过程当中是没办法获知外部应用的逻辑细节的,因此天然也就没办法用以前说的种种方法来解决死锁问题,只能经过过后检测并恢复来对死锁问题作最低限度的保障。可是咱们能够在咱们的应用程序中应用更多的解决方案,从更上层解决死锁问题。

总结

在这篇文章中,咱们从死锁的概念出发,首先介绍了死锁是什么和死锁发生的四个必要条件。而后经过破坏任意一个必要条件产生了四种不一样的阻止死锁的解决方案,最后介绍了另一种死锁解决方法——在死锁发生后再探测并恢复系统运行。相信你们能够在不一样的场景中都能找到适合该场景的解决方案,可是锁本质上是容易引入问题的,因此若是不是确有必要,最好不要贸然用锁来进行处理。

相关文章
相关标签/搜索