常见锁策略
1.0 什么是锁
这里的锁不是某个具体的锁,是个抽象的概念,描述的是锁的特性,描述的是"一类锁"
2.0 悲观锁 VS 乐观锁
概念
悲观锁:
- 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改/发生锁冲突,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁 (悲观锁后续做的工作往往更多)
乐观锁
- 假设数据一般情况下不会产生并发冲突/锁冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则返回错误信息给用户,让用户决定如何去做。(乐观锁后续做的工作往往更少)
3.0 重量级锁 VS 轻量级锁
概念
重量级锁:
加锁的开销是比较大的(花的时间多,占用系统资源多)
悲观锁往往开销大而导致重量级锁,但不绝对
轻量级锁
加锁的开销是比较小的(花的时间少,占用系统资源少)
乐观锁往往开销少而导致轻量级锁,但不绝对
4.0 自旋锁 VS 挂起等待锁
概念
自旋锁:
- 自旋锁是轻量级锁的一种典型实现
- 在用户态下通过自旋的方式(比如while循环),实现类似于加锁的效果的
- 消耗cpu资源大,但是能在第一时间抓到拿锁的机会
挂起等待锁:
- 挂起等待所,是重量级锁的一种典型实现
- 通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对线程的调度.使冲突的线程出现挂起(阻塞等待)
- 消耗的cpu资源少,时不时查看当前锁的状态,不能第一时间知晓锁的状况,更依赖操作系统的调度
5.0 读写锁 VS 互斥锁
概念
读写锁:
-
把读操作加锁和写操作加锁分开
-
设计这个锁的原因:多线程同时去读一个变量,不涉及到线程安全问题,提高并发能力
-
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写 锁. ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁. ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁.
互斥锁:
- 保证线程安全,读写锁一起的操作
6.0 公平锁 vs 非公平锁
概念
公平锁:
- 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁
- 不遵守 “先来后到”.B 比 C 先来的. 当 A 释放锁的之后, B 和 C 都有可能获取到锁.
- 系统原生锁(pthread_mutex) 属于非公平的锁
7.0可重入锁 vs 不可重入锁
概念
概念:
- 如果一个线程,争对一把锁,连续加锁两次会出现死锁,就是不可重入锁,不会出现死锁就是可重入锁
- 可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
- 可重入锁的字面意思是 “可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
- Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
- 而 Linux 系统提供的 mutex 是不可重入锁.
不可重入锁
如果是个不可重入锁,这把锁不会保存是哪个线程对它家的锁,只要他当前处于加锁状态后,收到"加锁"这样的请求,就会拒绝但当前的加锁,而不管当下线程是哪个,就会产生死锁
可重入锁
会让这个锁保存是哪个线程加的锁,后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前自己持有这把锁的线程,这个时候就能灵活判断了
死锁
理解 “把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁.
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
死锁的三种典型情况
- 一个线程,一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
- 两个线程,两把锁,这两个线程分别获取到一把锁,然后再同时获取对方的锁(互不相让)
- N个线程M把锁,某个程序需要这5个线程分别跑起来,5个线程要5把锁,但是只有四把锁,且由于程序不结束,每个线程锁不释放,即每个线程一直拥有锁但不释放,而最后一个线程无锁,进程跑不了,而造成死循环
死锁产生的原因:
- 互斥使用,一个线程获取到一把锁之后,别的线程不能获取到这把锁(但这个是锁的基本特性)
- **不可抢占,**锁只能是被持有者主动释放,而不能是被其他线程直接抢走 (但这个是锁的基本特性)
- 请求和保持,这个一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态(取决于代码结构,这里的调整会影响需求,不推荐)
- 循环等待, t1 尝试获取locker2, 需要t2执行完, 释放locker2, t2 尝试获取locker1,需要t1执行完,释放locker1,你等我我也等你,(取决于代码结构,可以打破循环等待而做到解除死锁)
解决死锁的方法:
- 银行家算法(少用,但是学校学,容易写bug)
- 好的方法:争对锁进行编号,并且规定锁的顺序, 比如,约定每个线程如果要获取多把锁,必须先获需要锁中编号最小的锁,后获取编号大的锁,只要所有线程加锁的顺序都严格遵循上述的顺序,就一定不会出现循环等待
synchronized 是可重入锁
比如在对象方法前加synchronized,又在方法内部加synchronized(this), 再在某个线程中调用这个方法,如果synchronized不是可重入锁就会导致死锁
class A{
public synchronized void func(){
synchronized(this){
System.out.println("hehe");
}
}
}
public class test1 {
public static void main(String[] args) {
Thread t1 = new Thread(()->{
A a = new A();
a.func();
});
}
}
//synchronized是可重入锁,因此他知道你两次加锁都是同一把锁,如果加了就会死锁,因此当多次锁的时候,这个锁的内部属性有个记录器,锁一次,自增一次,释放时候同理,释放一次,记录器自减,只有当减到0,这个锁才会真正释放锁,这也对应了可重入锁的特性
8.0 不同锁概念的对比
- 乐观悲观是在加锁之前,对锁冲突的预测,决定工作的多少,而重量轻量,是在加锁之后,考虑实际的锁开销
- 正是应为这样的概念存在重合,争对一个具体的锁,可以把它叫做乐观锁,也可叫成轻量级锁(看待角度不同)
9.0 synchronized具体采用的锁策略
-
Synchronized即是悲观锁,又是乐观锁(自适应),Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
-
Synchronized 不是读写锁
-
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁. 重量级锁是基于系统的互斥锁实现的 ,轻量级锁是基于自旋锁实现的
-
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
-
synchronized 是非公平锁. (不会遵循先来后到,释放之后,哪个线程拿到锁,各凭本事)
-
synchronized 是可重入锁 (内部会记录哪些线程拿到了锁,记录引用计数)
10.0 synchronized 的原理
基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
加锁工作过程
基本过程
- JVM 将 synchronized 锁分为 无锁–>偏向锁–>轻量级锁–>重量级锁状态。会根据情况,进行依次升级。
偏向锁
- 不是真的加锁,而只是做了一个"标记",如果有别的线程来竞争了,才会真的加锁,如果没有别的线程竞争,就自始至终都不会真的加锁了(加锁有一定开销),这样做开销小,轻量级
轻量级锁
- synchronized 通过自旋锁的方式来实现轻量级锁,我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是否被释放(while循环查看)(轻量级锁消耗cpu资源大,但也能快速拿到锁)
重量级锁
- 但是后续如果锁竞争这把锁的线程越来越多(锁冲突),synchronized就会重轻量级锁进化成重量级锁(变成挂起等待,不会不断扫描了,等到相关信息才行动,原因是:随着线程变多而对锁的争取变强,即使前一个线程释放锁,也不应保证轻量级锁就能立马拿到锁,所以不如重量级锁挂起等待)
其他优化要点
锁消除
- 编译器 + JVM 能判定这个代码是否有必要加锁.如果你写了加锁,但是实际上没必要加锁,就会把加锁的操作自动删除掉
- 比如我们再单个线程中使用了StringBuffer(有锁的),此时编译器会分析出当前情况从而解除加锁
- 但是编译器进行优化,是要保证优化之后的逻辑和之前的逻辑要一致的,这就会让有的优化变得保守起来
锁粗化
- 即锁的粒度:粗和细 (如果加锁操作里面包含的实际要执行的代码越多,就会认为锁的粒度越大)
- 一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
for(...){
sychronized(this){
cnt++;
}
}//锁的粒度小(频繁加锁)
sychronized(this){
for(...){
cnt++;
}
}//锁的粒度大,(多次for)
- 有的时候,希望锁的粒度小比较好,原因是:并发程度高
- 有的时候,也希望锁的粒度较大比较好(原因是加锁本身也有开销)
相较于cpp
- jvm对锁做了相当多工作,cpp没有,赢