JUC 死锁

180 阅读9分钟

在巨量的次数面前,整个系统发生问题的几率就会被放大,死锁的出现是极有可能的

整体大纲

image.png

1、死锁的概念

1.1 什么是死锁

(1)发生在并发中

死锁一定发生在并发场景中。我们为了保证线程安全,有时会给程序使用各种能保证并发安全的工具,尤其是锁,但是如果在使用过程中处理不得当,就有可能会导致发生死锁的情况。

(2)互不相让

通俗的讲,死锁就是两个或多个线程(或进程)被无限期地阻塞,相互等待对方手中资源的一种状态。

1.2 死锁的影响

死锁的影响在不同系统中是不一样的,影响的大小一部分取决于当前这个系统或者环境对死锁的处理能力。

MySQL 数据库中

一般为了保证事务的并发执行,都会通过锁来保证,MySQL 一般使用粒度最小的行锁来保证。

假如说两个事务,A 和 B。

A 事务先操作了 id=1 这一行,所以会给这 id=1 一行加上行锁,注意,并不是操作完了这一行就会解锁,而是整个事务执行完成后才会解锁。此时 B 事务操作了 id=2 这一行,同理,会给 id=2 这一行加上行锁。紧接着 A 事务想操作 id=2 这一行,因为 B 事务已经给 id=2 这一行上锁了,所以会 A 事务的执行会阻塞。另一边 B 事务想操作 id=1 这一行,同理,因为 id=1 这一行已经被 A 事务锁住了,所以 B 事务的执行会阻塞。这就会造成永远都在等待对方释放锁。

表格会看得清晰点:

时间点A 事务B 事务
更新 id=1(行锁)更新 id=2(行锁)
想更新 id=2(阻塞)想更新 id=1(阻塞)
commitcommit

这就是死锁的例子。

MySQL 是如何解决死锁的呢?两种方式:

(1)超时等待,自动回滚事务

这个超时时间参数为:innodb_lock_wait_timeout,默认为 50 秒;很明显,这个时间太长了,对于我们来说是无法接受的。但是这个值设置为多少合适呢?设置得太小,假如并不是死锁呢,而是简单的锁等待呢?所以一般不采用这种策略。

(2)死锁检测,检测到了死锁就回滚事务(推荐使用)

发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑(默认就是开启的)。

JVM

JVM 中,对于死锁的处理能力就不如 MySQL 数据库那么强大了。如果在 JVM 中发生了死锁,JVM不会自动进行处理,所以一旦死锁发生,就会陷入无穷的等待。

1.3 死锁的例子

先看一段代码:

public static void main(String[] args) {
    Object lock1 = new Object();
    Object lock2 = new Object();

    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock1");
        synchronized (lock1) {
            System.out.println(Thread.currentThread().getName() + "\t已经获得 lock1");
            System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock2");
            try {
                // 线程休眠 1 秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock2) {
                System.out.println(Thread.currentThread().getName() + "\t已经获得 lock2");
            }
        }
    }, "t1").start();

    new Thread(() -> {
        System.out.println("\n" + Thread.currentThread().getName() + "\t尝试获得 lock2");
        synchronized (lock2) {
            System.out.println(Thread.currentThread().getName() + "\t已经获得 lock2");
            System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock1");
            synchronized (lock1) {
                System.out.println(Thread.currentThread().getName() + "\t已经获得 lock1");
            }
        }
    }, "t2").start();
}

运行结果为:

image.png

很明显可以发现已经进入死锁的情况了,都在等待对方释放资源。

如何解决死锁,我们后面会说。

2、死锁产生的条件和定位

2.1 死锁产生的条件

(1)互斥条件

意思就是每个资源在任何时候下都只能被一个线程拥有。如果一个资源可以被多个线程拥有,那么不会产生死锁。

(2)请求与保持条件

意思就是当一个线程因请求资源而阻塞时,则需对已获得的资源保持不放。可以联想 sleep() 和 wait() 的区别。

sleep() 是抱着锁不放,而 wait() 是释放了锁。

(3)不剥夺条件

意思就是线程已获得的资源,在未使用完之前,不会被强行剥夺。当现在的线程获得了某一个资源后,别人就不能来剥夺这个资源,这才有可能形成死锁。

(4)循环等待条件

意思就是只有若干线程之间形成一种头尾相接的循环等待资源关系时,才有可能形成死锁。都在等待对方释放资源。

2.2 分析死锁代码

再贴一下刚刚那段代码:

public static void main(String[] args) {
    Object lock1 = new Object();
    Object lock2 = new Object();

    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock1");
        synchronized (lock1) {
            System.out.println(Thread.currentThread().getName() + "\t已经获得 lock1");
            System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock2");
            try {
                // 线程休眠 1 秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock2) {
                System.out.println(Thread.currentThread().getName() + "\t已经获得 lock2");
            }
        }
    }, "t1").start();

    new Thread(() -> {
        System.out.println("\n" + Thread.currentThread().getName() + "\t尝试获得 lock2");
        synchronized (lock2) {
            System.out.println(Thread.currentThread().getName() + "\t已经获得 lock2");
            System.out.println(Thread.currentThread().getName() + "\t尝试获得 lock1");
            synchronized (lock1) {
                System.out.println(Thread.currentThread().getName() + "\t已经获得 lock1");
            }
        }
    }, "t2").start();
}

开始分析代码

(1)因为我们使用的是 synchronized 互斥锁,所以它的锁只能被一个线程持有,满足互斥条件。

(2)当线程 t1 获取了 lock1 资源时,想再获取 lock2 资源时失败了,陷入阻塞,同时我们也可以发现,即使它陷入了阻塞状态,已经获取的 lock1 资源它并没有释放。满足请求与保持条件。

(3)JVM 并不会主动把某一个线程所持有的锁剥夺。满足不剥夺条件。

(4)线程 t1 持有 lock1,想获取 lock2;线程 t2 持有 lock2,想获取 lock1。满足循环等待条件。

2.3 定位死锁

第一种方式:

通过命令 jps 定位到程序对应的线程。然后再通过 jstack 定位到对应的代码。

第二种方式:

ThreadMXBean 也可以帮我们找到并定位死锁,如果我们在业务代码中加入这样的检测,那我们就可以在发生死锁的时候及时地定位,同时进行报警等其他处理,也就增强了我们程序的健壮性。

3、解决死锁问题的策略

3.1 线上发生死锁应该怎么办

如果线上环境发生了死锁,那么其实不良后果就已经造成了,修复死锁的最好时机在于“防患于未然”,而不是事后补救。

如果线上发生死锁问题,为了尽快减小损失,最好的办法是保存 JVM 信息、日志等“案发现场”的数据,然后立刻重启服务,来尝试修复死锁。如何保持 JVM 信息呢?

因为死锁的产生是需要很多条件的,重启后再次立刻发生死锁的几率并不是很大,当我们重启服务器之后,就可以暂时保证线上服务的可用,然后利用刚才保存过的案发现场的信息,排查死锁、修改代码,最终重新发布

3.2 避免策略

避免策略最主要的思路就是,优化代码逻辑,从根本上消除发生死锁的可能性

3.3 检测和恢复策略

先允许系统发生死锁,然后再解除

例如系统可以在每次调用锁的时候,都记录下来调用信息,形成一个“锁的调用链路图”,然后隔一段时间就用死锁检测算法来检测一下,搜索这个图中是否存在环路,一旦发生死锁,就可以用死锁恢复机制,比如剥夺某一个资源,来解开死锁,进行恢复。

3.4 鸵鸟策略

鸵鸟有一个特点,就是遇到危险的时候,它会把头埋到沙子里,这样一来它就看不到危险了。

如果我们的系统发生死锁的概率不高,并且一旦发生其后果不是特别严重的话,我们就可以选择先忽略它。直到死锁发生的时候,我们再人工修复,比如重启服务,这并不是不可以的。

4、经典的哲学家就餐问题

4.1 问题描述

image.png

有 5 个哲学家,他们面前都有一双筷子,即左手有一根筷子,右手有一根筷子。也就是说,哲学家左手要拿到一根筷子,右手也要拿到一根筷子,在这种情况下哲学家才能吃饭。

拿筷子的顺序:先拿左手的筷子,再拿右手的筷子。

4.2 死锁问题

每个人都拿着左手的筷子,都缺少右手的筷子,那么就没有人可以开始吃饭了,自然也就没有人会放下手中的筷子。这就陷入了死锁,形成了一个相互等待的情况。

4.3 解决方案

要想解决死锁问题,只要破坏死锁四个必要条件的任何一个都可以。

1. 服务员检查

引入服务员检查机制。

比如我们引入一个服务员,当每次哲学家要吃饭时,他需要先询问服务员:我现在能否去拿筷子吃饭?此时,服务员先判断他拿筷子有没有发生死锁的可能,假如有的话,服务员会说:现在不允许你吃饭。这是一种解决方案。

其实这就是银行家算法。

2. 领导调节

引入一个领导,这个领导进行定期巡视。如果他发现已经发生死锁了,就会剥夺某一个哲学家的筷子,让他放下。这样一来,由于这个人的牺牲,其他的哲学家就都可以吃饭了。这也是一种解决方案。

3. 改变一个哲学家拿筷子的顺序

从逻辑上去避免死锁的发生,比如改变其中一个哲学家拿筷子的顺序。

比如有一名哲学家与他们相反,他是先拿右边的再拿左边的,这样一来就不会出现循环等待同一边筷子的情况,也就不会发生死锁了。