204 lines
11 KiB
Markdown
204 lines
11 KiB
Markdown
|
||
|
||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||
|
||
|
||
67 如何写一个必然死锁的例子?
|
||
本课时我们会首先介绍什么是死锁,死锁有什么危害和特点,然后通过代码分析一个“必然死锁的例子”。
|
||
|
||
死锁是什么?有什么危害?
|
||
|
||
什么是死锁
|
||
|
||
发生在并发中
|
||
|
||
首先你要知道,死锁一定发生在并发场景中。我们为了保证线程安全,有时会给程序使用各种能保证并发安全的工具,尤其是锁,但是如果在使用过程中处理不得当,就有可能会导致发生死锁的情况。
|
||
|
||
互不相让
|
||
|
||
死锁是一种状态,当两个(或多个)线程(或进程)相互持有对方所需要的资源,却又都不主动释放自己手中所持有的资源,导致大家都获取不到自己想要的资源,所有相关的线程(或进程)都无法继续往下执行,在未改变这种状态之前都不能向前推进,我们就把这种状态称为死锁状态,认为它们发生了死锁。通俗的讲,死锁就是两个或多个线程(或进程)被无限期地阻塞,相互等待对方手中资源的一种状态。
|
||
|
||
生活中的例子
|
||
|
||
下面我们用图示的方法来展示一种生活中发生死锁的情况,如下图所示:
|
||
|
||
|
||
|
||
可以看到这张漫画展示了两个绅士分别向对方鞠躬的场景,为了表示礼貌,他们弯下腰之后谁也不愿意先起身,都希望对方起身之后我再起身。可是这样一来,就没有任何人可以先起身,起身这个动作就一直无法继续执行,两人形成了相互等待的状态,所以这就是一种典型的死锁!
|
||
|
||
两个线程的例子
|
||
|
||
下面我们用动画的形式来看一下两个线程发生死锁的情况,如下图所示:
|
||
|
||
|
||
|
||
此时我们有两个线程,分别是线程 A 和线程 B,假设线程 A 现在持有了锁 A,线程 B 持有了锁 B,然后线程 A 尝试去获取锁 B,当然它获取不到,因为线程 B 还没有释放锁 B。然后线程 B 又来尝试获取锁 A,同样线程 B 也获取不到锁 A,因为锁 A 已经被线程 A 持有了。这样一来,线程 A 和线程 B 就发生了死锁,因为它们都相互持有对方想要的资源,却又不释放自己手中的资源,形成相互等待,而且会一直等待下去。
|
||
|
||
多个线程造成死锁的情况
|
||
|
||
死锁不仅仅存在于两个线程的场景,在多个线程中也同样存在。如果多个线程之间的依赖关系是环形,存在环路的依赖关系,那么也可能会发生死锁,如下图所示:
|
||
|
||
|
||
|
||
我们看到在这个例子中,首先线程 1 持有了锁 A,然后线程 2 持有了锁 B,然后线程 3 持有了锁 C,现在每个线程都分别持有一把锁。接下来线程 1 想要去持有锁 B,可是它获取不到,因为现在锁 B 正在线程 2 的手里;接下来线程 2 又去尝试获取锁 C, 它同样也获取不到,因为现在锁 C 在线程 3 的手里;然后线程 3 去尝试获取锁 A ,当然它也获取不到,因为锁 A 现在在线程 1 的手里,这样一来线程 1、线程 2 和线程 3 相互之间就形成了一个环,这就是在多线程中发生死锁的情况。所以不仅是两个线程,多个线程同样也有可能会发生死锁的情况。
|
||
|
||
死锁的影响
|
||
|
||
死锁的影响在不同系统中是不一样的,影响的大小一部分取决于当前这个系统或者环境对死锁的处理能力。
|
||
|
||
数据库中
|
||
|
||
例如,在数据库系统软件的设计中,考虑了监测死锁以及从死锁中恢复的情况。在执行一个事务的时候可能需要获取多把锁,并一直持有这些锁直到事务完成。在某个事务中持有的锁可能在其他事务中也需要,因此在两个事务之间有可能发生死锁的情况,一旦发生了死锁,如果没有外部干涉,那么两个事务就会永远的等待下去。但数据库系统不会放任这种情况发生,当数据库检测到这一组事务发生了死锁时,根据策略的不同,可能会选择放弃某一个事务,被放弃的事务就会释放掉它所持有的锁,从而使其他的事务继续顺利进行。此时程序可以重新执行被强行终止的事务,而这个事务现在就可以顺利执行了,因为所有跟它竞争资源的事务都已经在刚才执行完毕,并且释放资源了。
|
||
|
||
JVM 中
|
||
|
||
在 JVM 中,对于死锁的处理能力就不如数据库那么强大了。如果在 JVM 中发生了死锁,JVM 并不会自动进行处理,所以一旦死锁发生,就会陷入无穷的等待。
|
||
|
||
几率不高但危害大
|
||
|
||
死锁的问题和其他的并发安全问题一样,是概率性的,也就是说,即使存在发生死锁的可能性,也并不是 100% 会发生的。如果每个锁的持有时间很短,那么发生冲突的概率就很低,所以死锁发生的概率也很低。但是在线上系统里,可能每天有几千万次的“获取锁”、“释放锁”操作,在巨量的次数面前,整个系统发生问题的几率就会被放大,只要有某几次操作是有风险的,就可能会导致死锁的发生。
|
||
|
||
也正是因为死锁“不一定会发生”的特点,导致提前找出死锁成为了一个难题。压力测试虽然可以检测出一部分可能发生死锁的情况,但是并不足以完全模拟真实、长期运行的场景,因此没有办法把所有潜在可能发生死锁的代码都找出来。
|
||
|
||
一旦发生了死锁,根据发生死锁的线程的职责不同,就可能会造成子系统崩溃、性能降低甚至整个系统崩溃等各种不良后果。而且死锁往往发生在高并发、高负载的情况下,因为可能会直接影响到很多用户,造成一系列的问题。以上就是死锁发生几率不高但是危害大的特点。
|
||
|
||
发生死锁的例子
|
||
|
||
下面我们举一个必然会发生死锁的例子,代码如下所示:
|
||
|
||
/**
|
||
|
||
* 描述: 必定死锁的情况
|
||
|
||
*/
|
||
|
||
public class MustDeadLock implements Runnable {
|
||
|
||
public int flag;
|
||
|
||
static Object o1 = new Object();
|
||
|
||
static Object o2 = new Object();
|
||
|
||
public void run() {
|
||
|
||
System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
|
||
|
||
if (flag == 1) {
|
||
|
||
synchronized (o1) {
|
||
|
||
try {
|
||
|
||
Thread.sleep(500);
|
||
|
||
} catch (Exception e) {
|
||
|
||
e.printStackTrace();
|
||
|
||
}
|
||
|
||
synchronized (o2) {
|
||
|
||
System.out.println("线程1获得了两把锁");
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
if (flag == 2) {
|
||
|
||
synchronized (o2) {
|
||
|
||
try {
|
||
|
||
Thread.sleep(500);
|
||
|
||
} catch (Exception e) {
|
||
|
||
e.printStackTrace();
|
||
|
||
}
|
||
|
||
synchronized (o1) {
|
||
|
||
System.out.println("线程2获得了两把锁");
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
public static void main(String[] argv) {
|
||
|
||
MustDeadLock r1 = new MustDeadLock();
|
||
|
||
MustDeadLock r2 = new MustDeadLock();
|
||
|
||
r1.flag = 1;
|
||
|
||
r2.flag = 2;
|
||
|
||
Thread t1 = new Thread(r1, "t1");
|
||
|
||
Thread t2 = new Thread(r2, "t2");
|
||
|
||
t1.start();
|
||
|
||
t2.start();
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
可以看到,在这段代码中有一个 int 类型的 flag,它是一个标记位,然后我们新建了 o1 和 o2、作为 synchronized 的锁对象。
|
||
|
||
下面我们来看看 run 方法。在 run 方法里面,它会首先打印出当前线程的名字,然后打印出当前线程 flag 的值是多少。
|
||
|
||
如果 flag 等于 1,就会先获取 o1 这把锁,然后休眠 500 毫秒,再去尝试获取 o2 这把锁并且打印出”线程1获得了两把锁”。
|
||
|
||
如果 flag 等于 2,那么情况恰恰相反,线程会先获取 o2 这把锁,然后休眠 500 毫秒,再去获取 o1 这把锁,并且打印出”线程2获得了两把锁”。
|
||
|
||
最后我们来看一下 main 方法,在 main 方法中新建了两个本类的实例,也就是两个 Runnable 对象,并且把它们的 flag 分别改为 1 和 2:r1 的 flag 设置为 1,r2 的 flag 设置为 2。然后新建两个线程,分别去执行这两个 Runnable 对象,执行 r1 和 r2 这两个线程的名字分别叫做 t1 和 t2,最后我们把两个线程给启动起来。
|
||
|
||
程序的一种执行结果:
|
||
|
||
线程t1的flag为1
|
||
|
||
线程t2的flag为2
|
||
|
||
|
||
这里的重点就在于程序执行到此时还在继续执行,并没停止,并且它永远不会打印出“线程 1 获得了两把锁”或“线程 2 获得了两把锁”这样的语句,此时这里就发生了死锁。
|
||
|
||
对发生死锁这个过程进行分析
|
||
|
||
下面我们对上面发生死锁的过程进行分析:
|
||
|
||
|
||
当第 1 个线程运行的时候,它会发现自己的 flag 是 1 ,所以它会尝试先获得 o1 这把锁,然后休眠 500 毫秒。
|
||
|
||
在线程 1 启动并休眠的期间,线程 2 同样会启动起来。由于线程 2 的 flag 是 2,所以它会进入到下面 的 if (flag == 2) 对应的代码块中,然后线程 2 首先会去获取 o2 这把锁。也就是说在线程 1 启动并获取到 o1 这把锁之后进行休眠的期间,线程 2 获取到了 o2 这把锁,然后线程 2 也开始 500 毫秒的休眠。
|
||
|
||
当线程 1 的 500 毫秒休眠时间结束后,它将尝试去获取 o2 这把锁,此时 o2 这个锁正被线程 2 持有,所以线程 1 无法获取到的 o2。
|
||
|
||
紧接着线程 2 也会苏醒过来,它将尝试获取 o1 这把锁,此时 o1 已被线程 1 持有。
|
||
|
||
|
||
|
||
所以现在的状态是,线程 1 卡在获取 o2 这把锁的位置,而线程 2 卡在获取 o1 这把锁的位置,这样一来线程 1 和线程 2 就形成了相互等待,需要对方持有的资源才能继续执行,从而形成了死锁。在这个例子里,如果线程 2 比线程 1 先启动,情况也是类似的,最终也会形成死锁。这就是一个“必然发生死锁的例子”。
|
||
|
||
|
||
|
||
总结
|
||
|
||
在本课时中,我们首先介绍了什么是死锁,接着介绍了死锁在生活中、两个线程中以及多个线程中的例子。然后我们分析了死锁的影响,在 JVM 中如果发生死锁,可能会导致程序部分甚至全部无法继续向下执行的情况,所以死锁在 JVM 中所带来的危害和影响是比较大的,我们需要尽量避免。最后举了一个必然会发生死锁的例子代码,并且对此代码进行了详细的分析。
|
||
|
||
|
||
|
||
|