欢迎来到《并发王者课》,本文是该系列文章中的第12篇。
在上篇文章中,我们介绍了死锁的概念及其原因,本文将为你介绍的是几种常见的死锁预防策略。
简单来说,预防死锁主要有三种策略:
- 顺序化加锁;
- 给锁一个超时期限;
- 检测死锁。
一、顺序化加锁
通常,死锁的产生是由于多个线程无序请求资源造成的。资源是有限的,不可能同时满足所有线程的请求。然而,如果能按照一定的顺序分别满足各个线程的请求,那么死锁也就不再存在,也就是所谓的顺序化加锁(Lock Ordering)。
举个通俗点的例子,烦人的路口堵车你一定遇到过。路口之所以堵车,是因为车太多了,大家都争相往自己的方向去,互不相让,不堵才怪。这时候,就需要交警在中间进行协调指挥,疏解拥堵。交警之所以可疏解拥堵,其根本原因在于,交警让原本处于无序竞争的车流变成了井然有序的队列。
车还是那么多的车,路口还是那个路口,可是道路通畅了,这和线程的锁竞争是类似的道理。
在上篇文章的死锁代码中,线程1和线程2分别先占有了A和B,导致了死锁。按照刚才的思路,我们把顺序调整下,线程1和线程2都先占有A,然后再同时争夺B,那么死锁就不会发生。
定义哪吒线程1,先抢占A再争夺B:
private static class NeZha implements Runnable {
public void run() {
synchronized(lockA) {
System.out.println("哪吒: 持有A!");
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {}
System.out.println("哪吒: 等待B...");
synchronized(lockB) {
System.out.println("哪吒: 已经同时持有A和B...");
}
}
}
}
定义兰陵王线程2,也是先抢占A再抢占B,这与此前就不同了:
private static class LanLingWang implements Runnable {
public void run() {
synchronized(lockA) {
System.out.println("兰陵王: 持有A!");
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {}
System.out.println("兰陵王: 等待B...");
synchronized(lockB) {
System.out.println("兰陵王: 已经同时持有A和B...");
}
}
}
}
启动两个线程:
public class DeadLockDemo {
public static final Object lockA = new Object();
public static final Object lockB = new Object();
public static void main(String args[]) {
Thread thread1 = new Thread(new NeZha());
Thread thread2 = new Thread(new LanLingWang());
thread1.start();
thread2.start();
}
}
两个线程的输出结果如下:
哪吒: 持有A!
哪吒: 等待B...
哪吒: 已经同时持有A和B...
兰陵王: 持有A!
兰陵王: 等待B...
兰陵王: 已经同时持有A和B...
从运行的结果中可以看到,两个线程都先后获得了自己所需要的资源,而没有导致死锁。
调整加锁顺序是一种简单但有效的死锁预防策略。但是,这一策略并不是万能的,它仅适用于你在编码时已经知晓加锁的顺序。
二、给锁一个超时期限
在上篇文章中,我们说过死锁的产生有一些必要的条件,其中一个是无限等待。设定锁超时时间正是为了打破这一条件,让无限等待变成有限等待。
仍然以前面的代码为例,哪吒和兰陵王两个线程在争夺资源时,对方都互不相让导致了无限等待的僵局。而此时,如果其中任何一方给等待设定一个期限,那么时间一到,僵局将不攻自破,而线程仍可以再稍等片刻后继续尝试。
需要注意的是,synchronized
代码块不可以指定锁超时。所以,如果需要锁超时,你需要使用自定义锁,或者使用JDK提供的并发工具类。相关工具类的用法,会在后续文章中介绍,本文暂不展开描述。
另外,所谓给锁加一个超时的期限,其实有两层含义。一是在请求锁时需要设定超时时间,二是在获取锁之后对锁的持有也要有个超时时间,总不能到手就不放,那是耍流氓。
三、死锁检测
作为死锁预防的第三种策略,你可以认为死锁检测(Deadlock Detection)是一项较重的被动技能,当我们无法顺序化加锁,也无法设置锁的超时时间,那么就需要进行死锁检测。
死锁检测的核心原理在于对线程和资源进行数据化打标和跟踪。
在线程获取锁时,会将锁和线程的对应关系通过Graph或者Map等数据结构记录下来。这样一来,线程在获取锁被拒绝时,可以通过遍历已经记录的数据分析是否存在死锁。
当线程发现死锁的情况后,可以采取释放锁,稍等片刻后再次尝试。
附、如何可视化查看线程死锁等状态
在你感觉线程可能被阻塞或死锁时,可以通过jstack
命令查看。如果存在死锁,输出的结果中会有明确的死锁提示,如下面所示:
$ jstack -F 8321
Attaching to process ID 8321, please wait...
Debugger attached successfully.
Client compiler detected.
JVM version is 1.6.0-rc-b100
Deadlock Detection:
Found one Java-level deadlock:
=============================
"Thread2":
waiting to lock Monitor@0x000af398 (Object@0xf819aa10, a java/lang/String),
which is held by "Thread1"
"Thread1":
waiting to lock Monitor@0x000af400 (Object@0xf819aa48, a java/lang/String),
which is held by "Thread2"
Found a total of 1 deadlock.
除了jstack
之外,JProfiler也是一款非常强大的线程与堆栈分析工具,并可以和IDEA等IDE完美结合。
借助于JProfiler,我们可以非常直观地看到上述示例代码中的死锁,也可以在Thread Monitor中看到两个线程的状态为blocked.
需要注意的是,JProfiler是一款付费软件,它提供了十天的免费试用时间。如果没有常规的使用需求,而是仅用于学习的话,十天也是够用的。当然,你也可以考虑使用jConsole、jVisualvm等。
小结
以上就是关于死锁预防策略的全部内容。在本文中,我们介绍了三种死锁预发策略。三种策略各有利弊,就实际工作中的应用而言,第二种给锁设定超时期限是更为常用的一种做法,而第一种和第三种具有一定的逻辑难度和技术难度,更侧重于理解而非实际应用。
正文到此结束,恭喜你又上了一颗星✨
夫子的试炼
- 通过
jstack
命令查看死锁并解决。
延伸阅读与参考资料
关于作者
关注公众号【庸人技术笑谈】,获取及时文章更新。记录平凡人的技术故事,分享有品质(尽量)的技术文章,偶尔也聊聊生活和理想。不贩卖焦虑,不做标题党。
如果本文对你有帮助,欢迎点赞、关注、监督,我们一起从青铜到王者。