可重入锁VS不可重入锁
有一个线程,针对同一把锁,连续加锁两次,如果产生了死锁,那就是不可重入锁,如果没有产生死锁,那就是可重入锁.
死锁
我们之前引入多线程的时候不是讲了一个加数字的案例么,我们今天以它来举例
当我们这样写的时候会出现什么问题?
分析:第一个synchronized的加锁对象是 this ,当我们继续执行代码,就会发现第二个synchronized的加锁对象还是 this ,此时就需要注意,当一个对象已经被加锁了,此时尝试对这个已经加锁的对象再一次的进行加锁,就会出现"锁竞争",我们要想使得第二个synchronized实现对 this 的加锁,就要让increase执行完毕,但是要想让increase执行完毕,就需要第二个synchronized加锁成功,此时就陷入了循环,就出现了矛盾.此时这个代码就卡在这里了,因此这个线程就僵住了.这是死锁的第一个体现形式.
这里的关键在于,两次加锁,都是"同一个线程",第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候,不应该加锁失败的,不应该阻塞等待的.
如果是一个不可重入锁,这把锁不会保存,是哪个线程对它加的锁,只要它当前处于加锁状态之后,收到了"加锁"这样的请求,就会拒绝当前加锁,而不管当下的线程是哪个,就会产生死锁.
如果是一个可重入锁,则是会让这个锁保存,是哪个线程加上的锁,后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前自己持有这把锁的线程,这个时候就可以灵活判定了.
但庆幸的是,synchronized本身就是一个可重入锁,实际上我们上述的那个举例子的代码并不会出现死锁的情况.
首先,答案是肯定的,不能释放,如果在这里释放锁了,那么中间的synchronized以及中间的代码就不会受到锁的保护了.
那么我们想一下,可重入锁,需要比不可重入锁额外多出哪些功能
1.判断当前加锁的线程是不是同一个线程
2.判断当前代码执行到的是第几层的锁,那么这个功能是怎么实现的呢?
其实很简单,持有一个"计数器"就可以了,让锁对象不光要记录是哪个线程持有锁,同时再通过一个整形变量记录当前这个线程加了几次锁,每遇到一个加锁操作,就计数器+1,每遇到一个解锁操作就-1,当计数器减为0的时候,才真正执行释放锁的操作,其他时候不释放锁.而类似于这个操作的操作,我们称它为"引用计数"
死锁的三种典型情况
1.一个线程,一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
2.两个线程,两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁
下面我们敲代码来理解一下死锁
首先,这是一个错误的代码
执行结果如下
实际上由刚才的分析可以知道,这里会出现死锁的情况,那么为什么这里和理论值不一样呢?
因为没有加Sleep
代码如上
执行结果如下
那么,为什么?为什么加了Sleep之后会不一样,究竟是Sleep改变了线程原有的样子,还是说Sleep恢复了线程原有的样子?
答案是后者
我们在使用多线程的时候会发现,程序运行的时间特别长了会经常出现一些问题,或者说当我们来气了多个线程他们分别执行几个任务,但是因为执行的任务的时间非常短,有时候CPU切换的时候会出现一系列的问题.
原因是当我们设置Sleep是,就等于告诉CPU,当前的线程不再运行,持有当前对象的锁.那么这个时候CPU就会切换到另外的线程了,这种操作在有些时候是非常好的.
3.N个线程M把锁
哲学家就餐问题
每个哲学家,主要做两件事
1.思考人生,会放下筷子
2.吃面.会拿起左手和右手的筷子
3.每个哲学家,什么时候思考人生,什么时候吃面条,都不好说
2.每个哲学家一旦想吃面条了,就会非常固执的完成吃面条的这个操作,如果此时,他的筷子被别人使用了,就会阻塞等待,而且等待的过程中不会放下手中已经拿着的的筷子
是否有办法去避免死锁呢?
先明确产生死锁的原因,死锁的必要条件
四个必要条件(缺一不可,只要破坏其中任意一个条件,就可以避免死锁)
1.互斥使用,一个线程获取到一把锁之后,别的线程不能获取到这个锁(我们实际使用的锁,一般都是互斥的(锁的基本特性))
2.不可抢占,锁只能是被持有者主动释放,而不是被其他线程直接抢走(锁的基本特性)
3.请求和保持,这一个线程尝试去获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态(取决于代码结构)(很可能会影响到需求)
在获取第二把锁的同时会保持对第一把锁的状态,这里由于获取第二把锁的时候,并没有去释放第一把锁,所以就会出现阻塞等待
当我们将代码改成这样,即获取完第一把锁之后,并且将第一把锁释放掉,此时再去请求获取第二把锁,这样做是不会出现死锁的
4.循环等待.t1尝试获取 locker2,需要t2执行完,释放locker2;t2尝试获取locker1,需要t1执行完,释放locker1 (取决于代码结构)(解决死锁问题的最关键要点)
如果具体解决死锁问题,实际的方法有很多种(例:银行家算法(但不推荐,因为不接地气))
介绍一个更简单,也非常有效的解决死锁的办法
针对锁进行编号,并且规定加锁的顺序
比如,约定,每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁.只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现循环等待.
像这样,我们规定先让locker1加锁,然后让locker2加锁,按照指定顺序加锁,也就可以避免死锁的问题了
synchronized具体是采用了哪些锁策略
1.synchronized即是悲观锁,也是乐观锁.
2.synchronized即是重量级锁,也是轻量级锁.
3.synchronized重量级锁部分是基于系统的互斥锁实现的,轻量级锁部分是基于自旋锁实现的.
4.synchronized是非公平锁(不会遵守先来后到,锁释放了之后,哪个线程拿到锁,各凭本事).
5.synchronized是可重入锁(内部会记录哪个线程拿到了锁,记录引用次数).
6.synchronized不是读写锁.
synchronized内部实现策略(内部原理)
代码中写了一个synchronized之后,这里可能会产生一系列的"自适应的过程",锁升级(锁膨胀)
无锁->偏向锁->轻量级锁->重量级锁
偏向锁(懒汉模式思想的延伸)
不是真的加锁,而只是做了一个"标记".如果有别的线程来竞争锁了,才会真的加锁,如果没有别的线程竞争,就自始至终都不会真的加锁了.(加锁本身,有一定的开销,能不加就不加,非得是有人来竞争了,才会真的加锁)
偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量).一旦有别的线程尝试加锁,就会立刻把偏向锁升级为一个真正的加锁状态,让其他线程只能阻塞等待
轻量级锁
synchronized通过自旋的方式来实现轻量级锁,我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了.但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),就会从轻量级锁,升级成重量级锁
锁消除
编译器,会智能的判定,当前的这个代码,是否有必要加锁,如果你写了加锁,但实际上没有必要加锁,就会把加锁操作自动优化掉。
比如在单个线程中使用StringBuffer.
编译器进行优化,是要保证优化之后的逻辑和之前的逻辑是一致的
锁粗化
关于"锁的粒度"如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大.
//以下是一些伪代码
//锁的粒度小
for(.....){
synchronized(this){
count++;
}
}
//锁的粒度大
synchronized(this){
for(.....){
count++;
}
}