写点什么

我来告诉你解决死锁的 100 种方法

作者:Java高工P7
  • 2021 年 11 月 12 日
  • 本文字数:2735 字

    阅读完需:约 9 分钟

  • 一些语句在底层会被分为多个底层指令运行,所以在多个线程之间这些指令就可能会存在穿插,这样程序的行为就可能会与预期不符造成 bug。

  • 违反执行顺序问题

  • 一些程序语句可能会因为子线程立即启动早于父线程中的后续代码,或者是多个线程并发执行等情况,造成程序运行顺序和期望不符导致产生 bug。


接下来就让我们开始消灭死锁吧!


初识死锁


====


什么是死锁?




死锁,顾名思义就是导致线程卡死的锁冲突,例如下面的这种情况:



可以看出,上面的两个线程已经互相卡死了,线程 t1 在等待线程 t2 释放锁 B,而线程 t2 在等待线程 t1 释放锁 A。两个线程互不相让也就没有一个线程可以继续往下执行了。这种情况下就发生了死锁。


死锁的四个必要条件




上面的情况只是死锁的一个例子,我们可以用更精确的方式描述死锁出现的条件:


  • 互斥。资源被竞争性地访问,这里的资源可以理解为锁;

  • 持有并等待。线程持有已经分配给他们的资源,同时等待其他的资源;

  • 不抢占。线程已经获取到的资源不会被其他线程强制抢占;

  • 环路等待。线程之间存在资源的环形依赖链,每个线程都依赖于链条中的下一个线程释放必要的资源,而链条的末尾又依赖了链条头部的线程,进入了一个循环等待的状态。


上面这四个都是死锁出现的必要条件,如果其中任何一个条件不满足都不会出现死锁。虽然这四个条件的定义看起来非常的理论和官方,但是在实际的编程实践中,我们正是在死锁的这四个必要条件基础上构建出解决方案的。所以这里不妨思考一下这四个条件各自的含义,想一想如果去掉其中的一个条件死锁是否还能发生,或者为什么不能发生。


阻止死锁的发生


=======


了解了死锁的概念和四个必要条件之后,我们下面就正式开始解决死锁问题了。对于死锁问题,我们最希望能够达到的当然是完全不发生死锁问题,也就是在死锁发生之前就阻止它。


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


破坏环路等待条件




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


试想一下如果程序中所有需要加锁的代码都按照一个统一的固定顺序加锁,那么我们就可以想象锁被放在了一条不断向前延伸的直线上,而因为加锁的顺序一定是沿着这条线向下走的,所以每条线程都只能向前加锁,而不能再回头获取已经在后面的锁了。这样一来,线程只会向前单向等待锁释放,自然也就无法形成一个环路了。


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


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


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


破坏持有并等待条件




想要破坏持有并等待条件,我们可以一次性原子性地获取所有需要的锁,比如通过一个专门的全局锁作为加锁令牌控制加锁操作,只有获取了这个锁才能对其他锁执行加锁操作。这样对于一个线程来说就相当于一次性获取到了所有需要的锁,且除非等待加锁令牌否则在获取其他锁的过程中不会发生锁等待。


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


  • 这种实现会降低系统的并发性,因为所有需要获取锁的线程都要去竞争同一个加锁令牌锁;

  • 并且因为要在程序的一开始就获取所有需要的锁,这就导致了线程持有锁的时间超出了实际需要,很多锁资源被长时间的持有所浪费,而其他线程只能等待之前的线程执行结束后统一释放所有锁;

  • 另一方面,现代程序设计理念要求我们提高程序的封装性,不同模块之间的细节要互相隐藏,这就使得在一个统一的位置一次性获取所有锁变得不再可能。


破坏不抢占条件




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


同样的,这个方案也会有自己的缺陷:


  • 虽然这种方式可以避免死锁,但是如果几个互相存在竞争的线程不断地放弃、重试、放弃,那么就会导致活锁问题(livelock)。在这种情况下,虽然线程没有因为锁冲突被卡死,但是仍然


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


会被阻塞相当长的时间甚至一直处于重试当中。


  • 这个问题的一种解决方式是给任务重试添加一个随机的延迟时间,这样就能大大降低任务冲突的概率了。在一些接口请求框架中也使用了这种技巧来分散服务高峰期的请求重试操作,防止服务陷入阻塞、崩溃、阻塞的恶性循环。

  • 还是因为程序的封装性,在一个模块中难以释放其他模块中已经获取到的锁。


虽然每一个方案都有自己的缺陷,但是在适合它们的场景下,它们都能发挥出巨大的作用。


破坏互斥条件




在之前的文章中,我们已经了解了一种与锁完全不同的同步方式 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) {

用户头像

Java高工P7

关注

还未添加个人签名 2021.11.08 加入

还未添加个人简介

评论

发布
暂无评论
我来告诉你解决死锁的100种方法