作者 | 敖丙
来源 | 敖丙
之前刚学习多线程时,由于各种锁的操作不当,经常不经意间程序写了代码就发生了死锁,不是在灰度测试的时候被测出来,就是在代码review的时候被提前发现。
这种死锁的经历不知道大家有没有,不过怎么说都是一个面试高频题目,面试官是肯定希望你经历过的,没经历过那也得看看某八股文职业选手的文章装作经历过。
那么什么是死锁呢?为什么会产生死锁呢?
什么是死锁
敖丙和小美是公司同事,今天他们参加了两个不同主题的会议。但是只有一台笔记本电脑,一个投影仪。敖丙拿了笔记本,小美拿了投影仪。
那么会议开了一半,我发现:不行啊!开会除了笔记本电脑还需要投影给别的同事看啊,而小美在另一个会议室也发现了,自己只拿个投影仪没啥用啊,这里连电脑都没有。
于是,我需要小美的投影仪,小美需要敖丙的电脑,他们都需要对方手里的资源,但是又不能放弃自己所持有的。
所以两个会议都开不下去了。
就是因为这个原因,让会议进程耽搁了两个小时。两边的老板都炸了:“ 开会前怎么连这些都没准备好,还想不想干了?!”
于是老板让敖丙写个检讨好好复盘整个事情,以及产生问题的原因。
细心的傻瓜一定发现了,为什么小美不用写呢?
当然因为小美跟老板是......亲戚呀~
上面的问题其实就是死锁,我就想着能不能用代码描述整个过程。
于是在检讨上写了以下这段代码:
public class DeadLockDemo {public static Object lock1 = new Object(); //获取笔记本电脑public static Object lock2 = new Object(); //获取投影仪public static void main(String[] args) {new Aobing().start();new Xiaomei().start();}private static class Aobing extends Thread {@Overridepublic void run() {synchronized (lock1) {System.out.println("Aobing获取到笔记本电脑");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Aobing被中断了!");}System.out.println("Aobing正在等待投影仪");synchronized (lock2) {System.out.println("Aobing获取到投影仪");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Aobing被中断了");}}System.out.println("Aobing释放投影仪");}System.out.println("Aobing释放笔记本电脑");}}private static class Xiaomei extends Thread {@Overridepublic void run() {synchronized (lock2) {System.out.println("Xiaomei获取到投影仪");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Xiaomei被中断了!");}System.out.println("Xiaomei正在等待笔记本电脑");synchronized (lock1) {System.out.println("Xiaomei获取到笔记本电脑");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Xiaomei被中断了!");}}System.out.println("Xiaomei释放笔记本电脑");}System.out.println("Xiaomei释放投影仪");}}
}
从上面程序看出来了,Aobing和Xiaomei两个线程都需要获取锁去访问各自的临界区 ,但是它们又分别依赖对方的资源。
于是两个线程就同时进入了等待对方资源释放的情况,但是谁都无法释放。
这就造成了死锁的状况。
死锁排查
但是这仅仅只是一个大概率的猜测,已经知道程序出现了异常,又如何第一时间排查是不是死锁呢?我继续研究了起来。
他通过Java提供的一些检测方式,进行了快速的定位。
Jps & Jstack
Jps是Jdk自带的一个工具,可以查看到正在运行的Java进程:
ok,可以看到。DeadLockDemo的进程ID是1884,拿到这个进程ID,再使用jstack命令。
jstack是Java性能排查的利器,主要用来实时跟踪进程里对应线程的堆栈信息,可以将Jvm进程内的所有线程的调用栈都打印出来。
所以,直接跟踪1884这个进程ID就行。
果然,可以看到,jstack已经检测到了死锁。并且Aobing和xiaomei两个线程都在互相等待对方的锁释放,也就是阻塞状态。
从这里,我确认程序发生了死锁**。
马上跑过去对正在和小美喝咖啡的老说说:“ 老板,你看这真的不是我的错啊,是咱公司资源不够,发生了死锁!我写个程序都跑不出结果!”
老板道貌俨然地点了点头。“嗯,那你还是得想想怎么解决,一个问题不能连续犯两次!”
于是在当天深夜11点,敖丙进行了深刻的自我反思,默默的写下这篇文章:「一个关于死锁的故事」。
死锁的类型
OK,看完了上面的故事,我们回过头来,继续来讲关于死锁的知识。
关于死锁有几种类型呢?主要有三种:
一般性死锁:这是最经典的死锁方式。指的是多线程环境下每个线程都需要多个资源去执行,但是这些资源又分别被不同的线程占有着,这就造成了一种僵持的状态。
嵌套性死锁:指的就是锁的互相嵌套使用。我们上面故事的死锁类型,其实就属于嵌套性死锁。
重入性死锁:指的是多线程环境下,若当前线程重复调用一个方法则可能因为代码逻辑里的边界情况从而导致死锁。
所以后来Java中无论是Synchronized还是Lock在可重入方面都会维护一个计数器来记录当前线程的重入次数,从而进入不同的代码逻辑,就是为了避免死锁的发生。
死锁原理
那么有的小伙伴就会担心了:“听你这么分析,我以后都不敢随意用它们了,这要是背锅了可怎么办!”。
别担心,死锁哪有那么容易发生呢。
你应该问一个问题:程序为什么会出现死锁,或者说在什么情况下,程序才会出现死锁。
要产生死锁,必须保证你的资源要能够满足以下条件,并且缺一不可:
互斥条件
某资源一次只能一个线程访问,该资源只要分配给某个线程,其它线程就无法再访问,直到该线程访问结束。
请求与保持条件
线程在已经占有至少一个资源的情况下还可以继续请求占有资源。
不可抢占条件
资源若已被其它线程占有,那么想要获取它就只能等待,不能因为你需要该资源就将其抢占。
循环等待条件
在竞争环境中存在一个线程等待链,使得每个线程都占有上一个线程所需的至少一种资源。
也就是说只有以上四个条件同时满足,线程才会因为资源分配产生矛盾,死锁才有可能发生。
大家可以类比一下,敖丙和小美是不是就处于以上四个条件中呢。
所以说,不要担心,想要发生死锁还是非常不容易滴。
死锁解除
那当你确定了程序发生了死锁,怎么办呢?
当然是不要慌,先给文章点个赞,收藏一下先,确保以后能找到。
我们刚刚说了,死锁发生的情况是要同时满足互斥、请求与保持、不可剥夺、循环等待这四个条件,缺一不可。那么我们如果想要解除死锁,是不是只要将这四个条件的任意一个破坏掉就好了呢?
破坏请求与保持条件
请求与保持指线程请求资源的同时必须始终持有资源,所以我们可以在线程开始运行之前,一次性地申请其在整个运行过程中所需的全部资源。直至使用完再释放。
破坏不可抢占条件
想要达到这个目的代表着你要去抢占别的线程已经或正在持有的资源,这对于Synchronized是无能为力的。但是我们可以使用Lock呀!在JDK层面,juc包(java.util.concurrent)提供的Lock可以轻轻松松做到。
破坏循环等待条件
若是每个线程都依赖上一线程所持有的资源,那么整个线程链就会像闭环的贪吃蛇一样,导致资源无法被释放。因此就需要某一个线程释放资源,从而打破循环。
所以,我们平时的代码要如何设计才能尽量避免死锁的发生呢?
尽量将程序设置为可中断的
将程序设置为可中断的,这样在死锁环境下如果某个线程收到中断请求之后就可以主动地释放掉手中的资源。
Java多线程中有一个重要的方法interrupt(),这个方法可以请求调用此方法的线程触发中断机制,该线程可以自身决定是否释放资源。若是已经发生了死锁,只要它放弃资源便可打破。
为锁添加时限
除此之外还可以为尝试获取锁的线程添加一个超时等待时间。若线程在规定时间内获取不到锁则放弃,这样就可以避免线程无脑请求,同时也会释放该线程已有的资源,让其它线程有机会获取到锁,可以开放化一个相对封闭的资源环境。
保持加锁顺序
对于多个线程如果需要对方所持有的锁,那么就要尽量按照相同的顺序加锁,这样就能够避免因为各个线程获取锁的顺序混乱导致死锁。
我们再回过头来看看那个关于死锁的故事。
经过昨天加班的深刻反思,我重新编写了这段代码:
public class DeadLockDemo {public static Object lock1 = new Object(); //获取笔记本电脑public static Object lock2 = new Object(); //获取投影仪public static void main(String[] args) {new Thread1().start();new Thread2().start();}private static class Thread1 extends Thread {@Overridepublic void run() {synchronized (lock1) {System.out.println("Aobing获取到笔记本电脑");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Aobing被中断了!");}System.out.println("Aobing正在等待投影仪");synchronized (lock2) {System.out.println("Aobing获取到投影仪");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Aobing被中断了");}}System.out.println("Aobing释放投影仪");}System.out.println("Aobing释放笔记本电脑");}}private static class Thread2 extends Thread {@Overridepublic void run() {synchronized (lock1) {System.out.println("Xiaomei获取到笔记本电脑");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Xiaomei被中断了!");}System.out.println("Xiaomei正在等待投影仪");synchronized (lock2){System.out.println("Xiaomei获取到了投影仪");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("Xiaomei被中断了!");}}System.out.println("Xiaomei释放投影仪");}System.out.println("Xiaomei释放笔记本电脑");}}
}
这段代码和一开始的有什么区别呢?这次它们获取锁的顺序是相同的。
Aobing和Xiaomei两个线程都是先获取lock1再获取lock2,这样子两个线程谁先获取到资源,谁就一次性持有资源,直到资源都是释放完毕再让下一个线程获取,避免相互争夺导致资源混乱,破坏了请求与保持条件。
程序也成功运行结束:
所以我决定在下次开会的时候和小美的会议时间分开。由我先一次性获取所有资源开启他的会议,结束后资源再还给小美。
我抱着电脑高兴地将这个方案告诉了老板。
第二天,由于和小美的友好配合,两个会议都愉快的开完了,会议过程非常流畅。老板很开心,决定让我担任会议编排委员,并且以后会议室不再购入新设备!
我也高兴坏了,这下不仅升职加薪不再是梦,老板和小美的关系也更融洽了呢。
总结
其实生活中死锁的场景有很多,就像鸡生蛋蛋生鸡一样,就是一个典型的死锁Bug。都说艺术来源于生活,看来Bug也来源于生活,等量代换一下,Bug 不 就 等 于 艺 术?
往期推荐
Kubernetes 上调试 distroless 容器
「云」上玩法虽多,小心水土不服!
Redis 内存满了怎么办?这样置才正确!
Redis 为何使用近似 LRU 算法淘汰数据,而不是真实 LRU?
点分享
点收藏
点点赞
点在看