什么是死锁?
我们看看这串代码这样写有没有什么问题
public synchronized void increaes(){
synchronized (this) {
count++;
}
}
调用方法,然后第一个synchronized 先针对this加锁,此时假设加锁成功了
接下来执行到代码块中的synchronized,此时,还是针对this来进行加锁
此时就会产生锁竞争,当前this对象已经处于加锁状态了,此时线程就会阻塞,一直阻塞到锁被释放,才有机会拿到锁
在这个代码中,第一个synchronized 给 this 上的锁,得在 increase 方法执行完毕之后才能释放,但是要想执行完毕,得第二个synchronized 加锁成功获取到锁,方法才能继续执行.
这个时候就出现了矛盾,要想代码继续往下执行,就要把第二次的加锁获取到,也就是把第一次加锁释放,但是我们要想把第一次加锁释放,又需要保证代码先继续执行
此时,由于this 的锁没法释放,这个代码就卡在这里了,因此这个线程就僵住了
这个情况我们就称之为"死锁",这是死锁的第一个体现形式
按理来说,第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候不应该加锁失败,不应该阻塞等待的,但是这里的关键在于,两次加锁都是"同一个线程"
如果是不可重入锁,这把锁不会保存是哪个线程对它加锁的,只要他处于加锁状态之后,收到了"加锁"这样的请求就会拒绝,而不管当下的线程是哪个,就会产生死锁.
可重入锁,则是会让这个锁保存,是哪个线程加上的锁,后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了
实际上synchronized 本身是一个可重入锁,所以这个代码不会出现死锁的情况
现在我们分析一下这个情况
synchronized (this){ //这一行是真正加了锁的
synchronized(this){ //虚晃一枪,只是校验了一下,没有真的加锁
synchronized(this){ 虚晃一枪,只是校验了一下,没有真的加锁
......
} //执行到这个代码,出了这个代码块的时候,刚刚上的锁是否要释放???
} //当然不能释放,如果最里层释放了锁,中间的synchronized和最外层的synchronized中的代 码就没有在锁的保护之中了
} //在哪加锁,就在哪解锁,所以也在最外面这层解锁
死锁的三种典型情况:
1.一个线程,一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
2.两个线程,两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁
举个例子,A,B一起去吃饺子,A拿了酱油,B拿了醋,A说你先把醋给我,我用完再给你, B说凭什么不是你先给我酱油,我用完再给你,如果两个人不互相让,就会产生死锁
接下来我们通过代码感受一下死锁
public class Demo25 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1=new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);//睡一秒是为了确保在第一把锁拿到的情况下,验证第二把锁是否拿到
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1两把锁加锁成功");
}
}
});
Thread t2 =new Thread(()->{
synchronized(locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(locker1){
System.out.println("t2两把锁加锁成功");
}
}
});
t1.start();
t2.start();
}
}
运行结果如图
啥也没运行出来,两个线程都卡在了第二次加锁的地方
如果是一个服务器程序,出现死锁
死锁的线程就僵住了,就无法继续工作了,会对程序造成严重的影响
3.N个线程M把锁
哲学家就餐问题
如果出现极端情况,就会出现死锁,比如,同一时刻,五个哲学家都想吃面,并且同时伸出左手拿起左边的筷子,再伸出右手拿起右边的筷子,显然五个哲学家都拿不到右边的筷子了,场面就僵持住了
五个哲学家就是五个线程,五根筷子就是五把锁
是否有办法避免死锁呢?
先明确死锁产生的原因,产生死锁的必要条件
这四个条件缺一不可,只要能破坏其中一个,就能避免死锁
1.互斥使用.一个线程获取到一把锁之后,别的线程就不能获取到这个锁 实际使用的锁一般都是互斥的(锁的基本特性) => 无法改变
2.不可抢占.锁只能是被持有者主动释放,而不能被其他线程直接抢走 也是锁的基本特性 => 无法改变
3.请求和保持.一个线程去尝试获取多把锁,在获取第二把锁的过程中,保持对第一把锁的获取状态 取决于代码结构 => 如果改变容易影响需求,最好不变
4.循环等待.比如 t1 尝试获取 locker2 ,需要等t2执行完释放locker2;t2 尝试获取locker1,需要等t1执行完释放locker1,他俩都指望对方能执行完,两者逻辑就构成环了 取决于代码结构 ===>所以只能指望改变这个打破死锁了,这个是解决死锁的关键要点
介绍一个简单,也有效的解决死锁的办法
针对锁进行编号,并且规定加锁的顺序
比如,约定每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁
只要所以线程加锁的顺序都严格遵守上述顺序,就一定不会出现循环等待
举个例子
五个人五只筷子,二号小人按规矩拿了编号最小的一号筷子,三号小人拿了二号筷子,四号小人拿了三号筷子,五号小人拿了四号筷子,而一号小人身边只有一号和五号筷子,一号已经被拿走,所以一号小人就只能阻塞等待,所以五号小人就能拿起五号筷子,凑齐一双,吃完以后放下筷子,四号小人就能拿起四号筷子凑齐一双,吃完以后放下筷子,三号小人就能拿起三号筷子,凑齐一双,吃完以后放下筷子,二号小人就能拿起二号筷子,凑齐一双,吃完以后放下筷子,这时候一号小人就能拿起一号和五号筷子,顺利吃面,所有人都成功吃上了面
接下来我们修改一下上述出现死锁的代码,第一个线程先加锁locker1再加锁locker2 这个顺序是没问题的,但是线程2是先加锁locker2再加锁 locker1 这样是不行的,我们修改一下
public class Demo25 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1=new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);//睡一秒是为了确保在第一把锁拿到的情况下,验证第二把锁是否拿到
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1两把锁加锁成功");
}
}
});
Thread t2 =new Thread(()->{
synchronized(locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(locker2){
System.out.println("t2两把锁加锁成功");
}
}
});
t1.start();
t2.start();
}
}
这样两个线程都是先加锁locker1,再加锁locker2,这个顺序就每错,运行结果如下
问题迎刃而解
上述我们针对死锁的讨论非常重要,很经典的面试题,日常开发中也是需要考虑的点
synchronized 内部实现策略(内部原理
代码中写了一个synchronized 之后,这里可能会产生一系列的"自适应的过程",锁升级(锁膨胀)
无锁->偏向锁->轻量级锁->重量级锁
偏向锁不是真的加锁,只是做了一个标记,如果有别的线程来竞争锁了,才会真的加锁,如果没有别的线程竞争锁,就自始至终都不会真的加锁,加锁本身有一定开销,能不加就不加,等有人竞争了才会真的加锁,有点懒汉模式的思维
轻量级锁
synchronized 通过自旋的方式来实现轻量级锁
我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了
但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),就会从轻量级锁升级成重量级锁
轻量级锁是比较消耗CPU资源的,如果能快速拿到锁,多消耗点资源也不亏,但是随着竞争越来越激烈,即使前一个线程释放锁,也不一定能拿到,这个时间可能会比较久
锁消除
编译器会智能判断这个代码是否有必要加锁
如果你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉,但是有些情况编译器不能作出准确判断,就不一定会优化,所以我们不能全指望编译器来提高代码效率,咱自己也要发挥一些作用,判断何时加锁,也是我们非常重要的工作
锁粗化
关于"锁的粒度"
如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大
有的时候,希望锁的粒度小比较好,并发程度更高
有的时候,也希望锁的粒度大比较好,因为加锁解锁本身也有开销
把锁变成粒度大就是锁粗化
上述关于synchronized 内部的优化过程,也是经典的面试题
CAS:全称Compare and Swap,字面意思"比较并交换"
能够比较和交换某个寄存器中的值和内存中的值,看是否相等,如果相等,则把另外一个寄存器中的值和内存进行交换
可以简单理解为赋值操作,把寄存器中的值放进内存,寄存器后面如何就无所谓了
这一段逻辑,是通过一条CPU指令完成的(原子的),这个就给我们编写线程安全代码打开了新世界的大门,基于CAS又能衍生出一套"无锁编程",但是CAS的使用范围具有一定局限性,加锁的适用性范围更广
CAS有哪些应用
1.实现原子类
比如,多线程针对一个count变量进行++
在Java标准库中,已经提供了一组原子类
我们通过代码看看如何使用
import java.util.concurrent.atomic.AtomicInteger;
//使用原子类
public class Demo26 {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i<50000; i++) {
// count++; //java不像c++/Python 能支持运算符重载,这里必须通过调用方法的方式来完成自增
count.getAndIncrement();
//++count
//count.incrementAndGet();
//count--
//count.getAndDecrement();
//--count
//count.decrementAndGet();
}
});
Thread t2 = new Thread(()->{
for (int i = 0;i<50000;i++){
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
运行结果就是100000,不会像count++没加锁一样出现线程安全问题