目录
- Day 7:多线程(5)
- 1. 死锁
- 2. 死锁场景
- 3. 场景二:两个线程,两把锁
- 4. 场景三:N个线程,M把锁
- 5. 避免死锁问题
- 6. 内存可见性问题
Day 7:多线程(5)
回顾synchronized
- synchronized带有(),填写锁对象,锁对象存在的意义,只是起到“身份标识”效果
- 两个线程是否是针对同一个对象加锁,如果是,就可能产生阻塞/锁竞争/锁冲突
- synchronized{},进入代码块,就相当于加锁操作,出了代码块,就相当于解锁操作
- 修饰普通方法,相当于针对this加锁,修饰静态方法,相当于针对类对象加锁
1. 死锁
package thread;
class Counter2 {
private int count = 0;
void add() {
synchronized (this) {
count++;
}
}
int get() {
return count;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Counter2 counter2 = new Counter2();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter2.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (counter2) {
counter2.add();
}
}
});
t2.start();
t1.start();
t2.join();
t1.join();
System.out.println("count = " + counter2.get());
}
}
上述线程t2的代码相当于
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (counter2) {
synchronized (counter2){
count++;
}
}
}
});
假设t2先启动(t1先不考虑),t2第一次加锁,肯定能加锁成功,当t2尝试第二次加锁的时候,此时counter2变量,属于已经被锁定的状态了,针对一个已经被锁定的对象加锁,就会出现阻塞等待,阻塞到对象被解锁为止
- 要想获得到第二层的锁,就要执行完第一层的代码块
- 要想执行完第一层代码块,就需要先获取到第二层的锁
这种情况下,就叫“死锁”
但是在实际上述过程中,对于synchronized是不适用的,synchronized上述代码是不会出现死锁的,但是如果是C++/Pyhton的锁就会出现死锁
- synchronized在内部进行了特殊处理(JVM)
- 每个锁对象里,会记录当前是哪个线程持有了这个锁,当针对这个对象加锁操作时,就会先判定一下,当前尝试加锁的线程,是否是持有同一锁的线程,如果不是,就阻塞,如果是,直接放行
- 这种机制称为**“可重入锁”**,目的是为了避免程序员粗心大意,搞出死锁
注意:当加了多层锁的时候,代码执行到哪里要真正进行解锁呢
一定是在遇到最外层的
}
,那么,如何确定遇到的}
是最外层的,运行时,给锁对象里也维护一个计数器(int n),每次遇到{
,n++(只有第一次才真正加锁),当遇到}
就n–,当n减到0了,才真正解锁
2. 死锁场景
死锁有三种比较典型的场景
(1)场景一:锁是不可重入锁,并且一个线程针对一个锁对象,连续加锁两次,通过引入可重入锁,可以解决上述问题
(2)场景二:两个线程,两把锁
(3)场景三:N个线程,M把锁
3. 场景二:两个线程,两把锁
有线程1和线程2,以及锁A和锁B,现在线程1和2都需要获取到锁A和锁B(拿到锁A之后,不释放A,继续获取锁B),即先让两个线程分别拿到一把锁,然后去尝试获取对方的锁
举个例子:健康码崩了,程序员回到公司修复bug,被保安拦住了
- 保安:出示健康码,才能进公司
- 程序员:我得进公司修复bug,才能出示健康码
类似于:家钥匙锁车里了,车钥匙锁家里了
package thread;
public class Demo22 {
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);
}
synchronized (locker2){
System.out.println("t1 获取了两把锁");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t2 获取了两把锁");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
上述代码:
- t1尝试针对locker2加锁,就会阻塞等待,等待t2释放locker2
- t2尝试针对locker1加锁,也会阻塞等待,等待t1释放locker1
当遇到死锁问题,可以通过上述调用栈+状态进行定位
4. 场景三:N个线程,M把锁
随着线程数目/锁的个数增加,此时情况就更复杂了,更容易出现死锁
哲学家就餐问题
现在桌子上均匀摆放有5根筷子,总共有5位哲学家,也就是说每位哲学家左右两边各一双筷子,每一位哲学家要做的事情就是放下筷子或者拿起左右两根筷子,但是每个哲学家什么时候放下筷子,什么时候拿起左右两根筷子是不确定的(抢占式执行)
如果出现下列极端情况,就相当于死锁了
- 同一时刻,所有的哲学家都拿起左边的筷子,那么此时所有的哲学家都无法拿起右手的筷子
- 假如哲学家都是比较固执的人,不能拿起两双筷子,就绝对不会放下手里的筷子
上述就是非常典型的死锁情况
死锁是非常严重的问题:死锁会使线程被卡住,没办法继续工作了,而且死锁这种bug,往往都是概率性出现
5. 避免死锁问题
死锁的四个必要条件
- 锁具有互斥特性:这个是锁的基本特性,一个线程拿到锁之后,其他线程就得阻塞等待
- 锁不可抢占(不可被剥夺):锁的基本特点,一个线程拿到锁之后,除非自己主动释放锁,否则别人抢不走
- 请求和保持:属于代码结构层面,一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁
- 循环等待:属于代码结构层面,多个线程获取多个锁的过程中,出现了循环等待,A等待B,B又等待A
必要条件缺一不可,任何一个死锁的场景,都必须同时具备上述四点
当代码中,确实需要用到多个线程获取多把锁,一定要记得约定好加锁的顺序,就可以有效避免死锁了
6. 内存可见性问题
package thread;
import java.util.Scanner;
public class Demo23 {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (count==0){
}
System.out.println("t1执行结束");
});
Thread t2 =new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码,当t2线程读到一个不为0的整数的时候,预期t1就会结束循环,但是结果并非如此