目录
一. 什么是“死锁”
并非是 synchronized 就一定线程安全,还要看代码具体咋写。到底是否加 synchronized ,和具体场景直接相关。“无脑加锁”的做法不推荐,锁需要的时候才使用,不需要的时候不要使用,会付出代价(性能)。使用锁,就可能触发阻塞,一旦某个线程阻塞,啥时候能恢复阻塞,继续执行,是不可预期了...(可能需要非常多时间)
因此,synchronized 如果使用不当,就会出现“死锁”。
死锁:发生在多个进程或线程在执行过程中,因为竞争资源而造成的一种互相等待的僵持状态,没有任何一个进程或线程可以继续执行下去。简单来说,死锁是指两个或多个进程无限期地等待永远不会发生的条件,导致它们无法继续执行。
二. 产生死锁的场景
场景1:一个线程连续加锁
public class DeadLock2 {
private static int count = 0;
public void add(){
synchronized (this){
synchronized (this){
count++;
}
}
}
}
运行结果:
代码分析:
- 里面的synchronized要想拿到锁,就需要外面的synchronized释放锁
- 外面的synchronized要释放锁,就需要执行到 }
- 要想执行到 } 就需要执行完 count++
- 但是 count++ 阻塞着呢~
这样,就会一直阻塞等待,造成“死锁”.....
但是,实际运行并没有出现“死锁”现象,这是为什么呢?
因为 synchronized 针对这种情况做了特殊处理~
synchronized 是 “可重入锁”,针对上述一个线程对同一把锁连续加锁做了特殊处理,是Java为了减少程序员写出死锁的概率,引入的特殊机制。同样的代码,换成C++ / Python 就会出现“死锁”。
“可重入锁” 的一个主要特性是它允许同一个线程多次获取同一把锁。如果线程已经持有了这把锁,那么它可以再次进入由这把锁保护的代码块,而不会阻塞产生“死锁”。这是通过记录锁的持有者,并引入一个计数器来实现的:
初始情况下,计数器是0 --> 执行到 { 计数器 +1 ,执行到 } 计数器 -1。如果某次 -1 之后,计数器为0了,说明这次就要真正释放锁了。
此处涉及到了 “引入计数” 的思想,后面讲到 JVM 中的垃圾回收机制也会有
场景2:两个线程两把锁
public class DeadLock {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
System.out.println("t1 加锁locker1 完成");
//这里的sleep是为了让t1和t2都先拿到自己锁,然后再拿对方的锁
//如果没有sleep执行顺序就不可控
//可能出现某个线程一口气拿到两把锁,另一个线程还没执行,无法构造出死锁.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1 加锁locker2 完成");
}
}
});
Thread t2 = new Thread(()->{
synchronized(locker2){
System.out.println("t2 加锁locker2 完成");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(locker1){
System.out.println("t2 加锁locker1 完成");
}
}
});
t1.start();
t2.start();
}
}
运行结果:
“死锁”场景描述:
- t1 线程先对 locker1 加锁,t2 线程先对 locker2 加锁
- t1 线程在不释放 locker1 的情况下,对 locker2 加锁;同时,t2 线程在不释放 locker2 的情况下,对 locker1 加锁
这样,就会造成“循环依赖”的效果,产生“死锁”。
形象的比喻:疫情期间,一码通又寄了.......程序员来到公司楼下,被保安拦住了。
保安:请出示一码通
程序员:我得上楼修了bug,才能出示一码通
保安:你得出示一码通,才能上楼
通过上述例子,“死锁”往往会出现“依赖循环”。针对这种“死锁”情况,“可重入锁”机制就无能为力了....
通过观察 jconsole,我们发现此时 t1 和 t2 线程都处于 BLOCKED 阻塞状态。
解决办法:t1 线程可以先释放 locker1,再请求 locker2
场景3:N个线程M把锁
经典模型:哲学家就餐问题
现在共有 5个 哲学家,桌子上有 5根筷子。5个哲学家要吃桌子上的面条。
此时,每根筷子都被哲学家左手拿起来了,他们的右手都拿不到筷子了.....由于哲学家 非常固执,当他吃不到面条的时候,也绝对不会放下左手的筷子.....这样,每一个人都吃不到面条了,只能循环等待....
“哲学家”相当于“线程”,“筷子”相当于“锁”。如果线程1拿A锁,线程2拿B锁,线程3拿C锁,线程4拿D锁,线程5拿E锁。这时候,5个线程再同时请求等待旁边的锁,就会造成“死锁”的局面....
但是,上述情况是由代码结构造成的,可以通过一些方法来避免。
比如, 必须先针对编号小的锁加锁,后针对编号大的锁加锁。每个哲学家必须先拿起编号小的筷子 ,后拿起编号大的筷子。同一时刻,拿起第一根筷子
在编写代码时,可以给锁编号:1,2,3.....N。规定所有线程在加锁的时候,都必须按照一定的顺序来加锁(比如,必须先针对编号小的锁加锁,后针对编号大的锁加锁)
三. 产生死锁的四个必要条件(缺一不可)
a. 互斥条件(锁的基本特性):同一把锁 锁住的代码块 一次只能由一个线程执行(基本特性无能为力)
b. 不可被抢占条件(锁的基本特性):线程1 拿到了锁A,如果线程1 不主动释放锁A,线程2就不能把锁A抢过来(基本特性无能为力)
c. 持有和等待条件(代码结构):线程1 在持有 A锁 的情况下(持有),去拿B锁(等待);线程2 持有 B锁 的情况下(持有),又去拿A锁(等待)。(解决办法:线程1 可以先释放A锁,再请求B锁)
d. 循环等待 / 循环依赖 / 环路等待 条件(代码结构):每个线程至少持有一个把锁,并等待获取下一个线程所持有的锁,构成一个循环等待链。(解决办法:给锁编号,并约定要按照一定的顺序加锁)
四. Java 标准库中的线程安全类
Java标准库中很多都是线程不安全的。这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施,因此,可以避免很多隐形加锁情况,防止产生“死锁”
ArrayList,Queue,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder......
但是还有一些是线程安全的,使用了一些锁机制来控制,内置了synchronized,这些类是不推荐使用的,甚至 jdk 未来版本,会把这几个东西删掉....
Vector,HashTable,Stack,StringBuffer....
例如:StringBuffer中
标签:初阶,synchronized,locker2,t1,死锁,加锁,线程,多线程 From: https://blog.csdn.net/2301_80243321/article/details/140673484