这里是Themberfue
· 在上一节中,我们介绍了线程安全问题,对锁的概念以及使用
· 在本节中,进入 "死锁" 的概念以及如何产生 "死锁"
死锁
一个线程,一把锁,同时加两把锁
· 要想进入死锁的介绍和概念,我们先来看这段代码
public class Demo18 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {synchronized (locker) {count++;}}}});t1.start();t1.join();System.out.println("count = " + count);}
}
· 上述代码对 count++ 加了两次锁,看起来这样写没必要,也感觉根本写不出这样的代码,但实际在开发过程还是非常容易写到的
class Counter {int count;synchronized public void addCount() {synchronized (this) {this.count++;}} }Counter count = new Counter();synchronized (locker) {count.addCount(); }
· 万一是在这种情况,addCount方法被 synchronized 修饰,但是 addCount 方法可能被封装了,你并不清楚其被 synchronized 修饰
· 我们想来分析上述代码,为什么会出现问题:
· 首先,进入第一个 synchronized 代码块,也就是进入第一把锁
· 随后,进入第二把锁,但是根据锁的互斥,由于该锁已被加上,要想继续加锁,就得等第一把锁解开后
· 但是,想要解开第一把锁,就得执行完此处逻辑,导致代码在这卡住不能继续执行下去
· 没错,这就是这里的问题
· 上述类似这样的问题,就称为 "死锁(dead lock)"
· 但是,运行代码后,并没有出现代码卡住的问题,而是继续往下运行并且正常结束了
· 为了解决这个问题,Java 的 synchronized 引入了 "可重入" 的概念,对于这种二次加同样锁的情况,JVM会自动消除,使得代码正常执行
· 具体过程就是,JVM会判断当前加的锁是否在当前线程中已经加过,如果已经加过了,此时不会阻塞等待,而是直接往下运行
· 这也是 Java 的好处之一,例如 C++ 的锁并没有这样的功能,需要程序员额外小心这样的情况发生
PS:JVM是怎么实现这一功能的?用个计数器,计下 ' { ' 的数量,随后想要了解更多,网上有很多答案哦~~~🫡
两个线程,两把锁,两个线程获取到锁后,尝试获取对方的锁
· 先举个例子:你和朋友去吃火锅,你这边拿着麻辣酱,他那边拿着酱油,你此时想要想要蘸那边的酱油,但与此同时,他又想要蘸你这边的麻辣酱,但他不肯放下酱油,你也不肯放下麻辣酱,此时你俩就僵住了,谁都吃不到了,卡住了属于是
· 再举个例子:家钥匙锁车里了,车钥匙锁家里了
public class Demo19 {public static void main(String[] args) throws InterruptedException {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// t1获取t2的锁synchronized (locker2) {System.out.println("t1获取到t2的锁");}}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}// t2获取t1的锁synchronized (locker1) {System.out.println("t2获取到t1的锁");}}});t1.start();t2.start();t1.join();t2.join();}
}
· 来看上述代码:
由于线程是并发执行的,t1 想要获取到 t2 的锁,此时进入阻塞状态
t2 想要获取到 t1 的锁,此时也进入阻塞状态
在这种情况,t1 不让 t2,t2 不让 t1,那么就进入了僵持状态,也就是造成了 "死锁"
· 但如果给某个线程 sleep 一会儿,那么此时可能会 死锁,也可能不会 死锁
· 运行代码,我们打开 jconsole 查看其状态
· 可以看到,两个线程的状态都是 BIOCKED 状态,也就是死等状态,死锁属于了
· 如何解决呢?如果必须要互相拿到对方的锁的话,那么就不要嵌套解锁,而是并列加锁
public class Demo20 {public static void main(String[] args) throws InterruptedException {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}// t1获取t2的锁synchronized (locker2) {System.out.println("t1获取到t2的锁");}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}// t2获取t1的锁synchronized (locker1) {System.out.println("t2获取到t1的锁");}});// 把嵌套的加锁操作改为并列加锁,可以有效解决死锁的问题// 请求和保持t1.start();t2.start();t1.join();t2.join();} }
N个线程,M把锁(哲学家就餐问题)
· 现有五个哲学家,五根筷子,一张桌子,五碗面条,此时,五位哲学家都想吃面,他们同时拿起筷子,此时一人分得一把筷子,但是一根筷子怎么吃面条啊?所以,至少需要两根筷子才可以夹起面条
· 但是,哲学家是固执的,此时,他们谁也不让谁,谁也别想拿到第二根筷子,那么就又僵住了
· 如果将五个哲学家类比为五个线程,五跟筷子类比为五个锁,只有拿到两把锁,该线程才可以继续工作,但问题就在于,每个线程都拿不到第二把锁了,那么此时程序就又双 "死锁" 了
· 此时,有人会说:啊,这个问题几率那么小,根本没必要在意啊。确实,这个问题产生的概率确实不大,但如果用户基数大了,那么发生的概率也会变大,那么发生的概率是 1%,基数大了,发生的概率也就大了,这种小概率事件也必须得到有效解决
此图转载自 Wiki
· 那么应该如何解决呢?
1. 我们给五根筷子按顺序命名
2. 五位哲学家按照约定拿起筷子:永远拿起序号更小的筷子,随后再获取序号更大的筷子
那么就是:从第二位哲学家开始,他先拿起序号更小的筷子,也就是 1 号筷子;第三位哲学家拿起 2 号筷子;第四位哲学家拿起 3 号筷子;第五位哲学家拿起 4 号筷子。
3. 此时,第一位哲学家想要拿起筷子,但是按照约定,必须拿起序号更小的筷子,所以,他不能拿起 5 号筷子,那么他现在就啥也不能干了
4. 现在 5 号筷子处于空闲状态,那么第五位哲学家就可以拿起这 5 号筷子,那么第五位哲学家就拿起了两把筷子,就可以吃面条了
5. 第五位哲学家吃完面条了,满足了,放下了 4 号和 5 号筷子,此时,第四位哲学家看到了 4 号筷子空闲,那么,他拿起 4 号筷子,也可以吃面条了
6. 我四位哲学家吃完面条,吃饱了哦,那么他也放下了 3 号和 4 号筷子,同时,第三位哲学家也看到了 3 号筷子空闲了,他就拿起了 3 号筷子,可以干饭了
7.以此类推,三号哲学家干饱了,二号哲学家干饱了,放下了 1 号筷子
8. 此时,第二位哲学家,第三位哲学家,第四位哲学家,第五位哲学家都吃饱了,全部筷子也处于空闲状态了。也就轮到第一位哲学家了,按照约定,先拿起序号小的筷子后拿起序号大的筷子,所以他就拿起 1 号筷子和 5 号筷子了
9. 这样,五位哲学家都吃饱了,问题也就随之解决了~~~
构成死锁的条件
1. 锁是互斥的
一个线程拿到锁后,另一个线程想要尝试拿到这个锁,此时会进入阻塞等待状态
2. 锁是不可抢占(不可剥夺)的
线程1 拿到锁后,线程2 再次尝试拿到这个锁,必须会进入阻塞等待,而不是直接抢走线程1 的锁
3. 请求和保持
线程 1 在拿到锁后,接着尝试拿到线程 2拿到的锁,同时,线程 2在拿到锁时,接着尝试拿到线程 1拿到的锁。应该在线程 1放下拿到的这个锁后,在尝试拿起线程 2的锁,同理线程 2也是如此,也就是避免 锁嵌套 ,但是在某些场景下不可能避免地嵌套锁
4. 循环等待
线程 A 等待 线程 B ,线程 B 等待 线程 C ,线程 C 等待 线程 D,线程 D 等待 线程 A
约定好加锁的顺序就可以解决循环等待
· 上述 1 2 情况是 锁 自带的性质和特性,是不可避免的,但是 3 4 情况是可以尽量避免的
死锁总结
构成死锁的三个场景
1. 一个线程同时加两次相同的锁(可重入锁)
2. 两个线程两把锁(代码如何编写)
3. N 个线程 M 把锁(哲学家就餐问题)
构成死锁的条件
1. 互斥
2. 不可抢占
3. 请求和保持
4. 循环等待
避免死锁
3. --> 避免嵌套锁
4. --> 按照顺序加锁
Java标准库的线程安全类
· 类似于:ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder类都是线程不安全
这些类没有任何有关加锁限制的代码
· 但是,类似于 Vector,HashTable,ConcurrentHashMap,StringBuffer类都是线程安全的
通过查看源代码可以知道,他们的提供的方法都是有 synchronized 修饰的
· 众所周知,有失必有得,我们加了锁也不一定就是好的 --> 加了锁意味着可能会出现锁竞争,出现锁竞争就意味着出现阻塞等待,这程序的执行效率是大大折扣的
· 所以,加锁这件事,并不是一定要加,根据场景合理加锁才能写出好的多线程代码
· String 类是比较特殊的类,因为其被 final 修饰,所以它是不可更改的,固然其线程天然安全
· 下一节我们将了解到内存可见性以及指令重排序等一系列多线程更多的知识,敬请期待哦~
· 毕竟不知后事如何,且听下回分解~~