Java并发编程丨死锁

首页 / 默认分类 / 正文

前言

Java 线程死锁是一个经典的多线程问题。为了保证线程安全,通常会在程序中使用各种能保证并发安全的工具,锁就是其中之一,但如果使用不恰当,就有可能会造成死锁的发生。

死锁

定义

死锁(DeadLock)是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。例如,有一座跨河的独木桥,桥面不宽仅仅可通过一人,有两人甲与乙相对而行至桥中,同时占用了公共资源(桥),甲如果想通过独木桥的话,乙必须退出桥面让出桥的资源,让甲通过。但是乙不爽了:为什么要我退出去,甲就不能退出去让我先过吗?于是两人就僵持不下,导致谁也过不了桥,这就是死锁。

产生原因和必要条件

由以上过桥的例子可知产生死锁现象的原因:一是系统提供的资源不能满足每个进程的使用需求(桥面不够宽,不足以容纳两人通过);二是在多道程序运行时,进程推进的顺序不合法(如果规定了过桥的顺序,无论是先甲后乙,还是先乙后甲,都正常通过)。
产生死锁有 4 个必要条件:

  • 互斥条件:对于独占资源,每个资源每次只能给一个进程使用,进程一旦申请到了资源后占为己有,则排斥其他进程共享该资源。
  • 不抢占条件:正在使用的资源不可抢占,进程获得的资源尚未使用完毕之前,只能由占有者自己释放,不能被其他进程强行占用。
  • 占有且等待条件:进程因未分配到新的资源而受阻,但又不放弃已占用的资源。
  • 循环等待条件:存在进程的循环等待链,前一个进程占有的资源正是后一个进程需求的资源,结果就形成了循环等待的僵持局面。

    必然死锁的案例

    面试官:手写一个必然死锁的案例?

    创建一个名称为 DealThread 的线程类:

    public class DealThread implements Runnable {
    
      public String name;
      // 两把不同的锁
      public Object lock1 = new Object();
      public Object lock2 = new Object();
    
      public String getName() {
          return name;
      }
    
      public void setName(String name) {
          this.name = name;
      }
    
      @Override
      public void run() {
          if ("ijk".equals(this.name)) {
              synchronized (lock1) {
                  try {
                      System.out.println(Thread.currentThread().getName() + "获取到了 lock1,name = " + name);
                      TimeUnit.SECONDS.sleep(3);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  synchronized (lock2) {
                      System.out.println("获取到 lock1 后 --> 也获取到了 lock2");
                  }
              }
          }
          if ("xyz".equals(this.name)) {
              synchronized (lock2) {
                  try {
                      System.out.println(Thread.currentThread().getName() + "获取到了 lock2,name = " + name);
                      TimeUnit.SECONDS.sleep(3);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  synchronized (lock1) {
                      System.out.println("获取到 lock2 后 --> 也获取到了 lock1");
                  }
              }
          }
      }
    }

    运行类 DeadLockDemo 代码如下:

    public class DeadLockDemo {
      public static void main(String[] args) {
          DealThread dt = new DealThread();
          dt.setName("ijk");
    
          new Thread(dt, "线程A").start();
          try {
              TimeUnit.SECONDS.sleep(1);
          }catch (InterruptedException e) {
              e.printStackTrace();
          }
    
          dt.setName("xyz");
          new Thread(dt, "线程B").start();
      }
    }

    程序的运行结果:
    处于阻塞状态,无法继续执行

    分析

    程序无法继续向下执行了,直觉告诉我们,很可能发生了死锁。那么如何验证自己的判断呢?可以使用 JDK 自带的工具来检查是否有死锁的现象。进入 CMD 工具,进入 JDK 安装文件夹中的 bin 目录,执行 jps 命令,查看当前系统的 Java 进程以及其进程 id:
    jps命令
    如图所示,得知了运行类 DeadLockDemo 的 id 值后,再执行 jstack 命令来分析线程的情况,查看进程内的堆栈信息;附加选项:-l(小写字母 l),可以观察锁的持有情况。
    分析结果
    根据图中的信息,确实存在死锁,这就验证了我们的判断。那这个死锁是如何产生的?双方互相持有对方的锁,互相等待对方释放锁。这种情况下,解决顺序死锁的办法就是保证所有线程以相同的顺序获取锁。
    获取锁的顺序不同

    预防死锁

    死锁可能会造成整个系统的崩溃,降低了系统的性能......死锁的危害就不过多说明了。那如何避免死锁呢?根据上述的 4 个必要条件,只要我们破坏其中一个,就可以成功避免死锁的发生。其中,互斥条件我们没有办法破坏,因为我们用锁为的就是互斥

    破坏不抢占条件

    对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
    这需要使用到 JUC 下的 Lock 的 API:

    // 支持中断的 API
    void lockInterruptibly() throws InterruptedException;
    // 支持超时的 API
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    // 支持非阻塞获取锁的 API
    boolean tryLock();

    破坏占有且等待条件

    对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
    以转账为例,业务员类 Allocator 它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。账户类 Account 里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

    class Allocator {
    private List<Object> als = new ArrayList<>();
    // 一次性申请所有资源
    synchronized boolean apply(Object from, Object to){
      if(als.contains(from) || als.contains(to)){
        return false;  
      } else {
        als.add(from);
        als.add(to);  
      }
      return true;
    }
    // 归还资源
    synchronized void free(Object from, Object to){
      als.remove(from);
      als.remove(to);
    }
    }
    
    class Account {
    // actr 应该为单例
    private Allocator actr;
    private int balance;
    // 转账
    void transfer(Account target, int amt){
      // 一次性申请转出账户和转入账户,直到成功
      while(!actr.apply(this, target))
        ;
      /**
       * 申请完两个账户的资源后还需要再分别锁定 this 和 target 账户
       * 因为还存在其他业务,比如客户取款。
       * 这个时候也是对全局变量 balance 做操作
       * 如果不加锁,并发情况下会出问题
       */
      try{
        // 锁定转出账户
        synchronized(this){              
          // 锁定转入账户
          synchronized(target){           
            if (this.balance > amt){
              this.balance -= amt;
              target.balance += amt;
            }
          }
        }
      } finally {
        actr.free(this, target)
      }
    } 
    }

    破坏循环等待条件

    对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
    破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有一个不同值的属性 id(类似于银行卡号),这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。
    比如下面代码中,从 ① 到 ⑥ 处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

    class Account {
      // 唯一值 id
      private int id;
      // 账户余额
      private int balance;
      // 转账方法
      void transfer(Account target, int amt){
          Account left = this;       ①
          Account right = target;    ②
          if (this.id > target.id) { ③
            left = target;           ④
            right = this;            ⑤
          }                          ⑥
          // 锁定序号小的账户
          synchronized(left){
            // 锁定序号大的账户
            synchronized(right){ 
              if (this.balance > amt){
                this.balance -= amt;
                target.balance += amt;
              }
            }
          }
      } 
    }

    排查工具

    死锁的排查工具除了上文用到的 jpsjstack 命令,还有一些可视化的界面工具(虽说有亿点点丑)。

    jconsole

    jconsole.exe 也位于 JDK 安装目录中 的 bin 目录,双击打开即可。然后在启动界面选择需要测试的程序:
    操作简单方便
    点击连接进入,点击”不安全的连接“按钮,进入监控主界面:
    不使用SSL
    在监控界面的上方工具栏,点击切换到”线程“模块,然后点击下方的”检测死锁“按钮:
    死锁信息

    jvisualvm

    注意:在 JDK 8 以上的高版本已经没有了。

    这个 jvisualvm.exe 也位于 JDK 安装目录中 的 bin 目录,双击打开即可。如果你的 JDK 版本不是 8,那么需要安装独立版了。地址:VisualVM
    使用步骤和 jconsole 的大同小异,左侧选择调试的进程,双击即可进入:
    无连接按钮
    然后点击”Thread“模块,上方区域有红色字体显示检测到死锁,下方区域可以看到各个线程的信息,不同颜色对应的状态在右下方有说明。
    红色字体
    选择”线程A“,点击”threaddump“按钮就会生成死锁的详细信息:
    详细信息

    参考

    知乎专栏
    极客时间 - Java 并发编程实战
    CSDN
打赏
评论区
头像
文章目录