文章目录
锁策略
锁策略,即在加锁/解锁/锁冲突的时候会怎么做
注意: 接下来讲解的锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容.
乐观锁 vs 悲观锁
- 加锁的时候,预测当前锁冲突的概率小,后续要做的工作往往会更少,加锁开销就更小 => 乐观锁
- 加锁的时候,预测当前锁冲突的概率大,后续要做的工作往往会更多,加锁开销就更大 => 悲观锁
Java的synchronized支持自适应,一开始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
C++的std::mutex 属于悲观锁
乐观锁做的工作少,消耗资源多(频繁尝试获取锁)但效率高,悲观锁反之
重量级锁 vs 轻量级锁
乐观锁往往就是轻量级锁(加锁过程做的事少)
悲观锁往往就是重量级锁(加锁过程做的事多)
这两组概念可能会被混着用
自旋锁 vs 挂起等待锁
自旋锁是轻量级锁的一种典型实现方式,基于CAS机制实现
伪代码:
void lock() {
while (true) {
if(锁被占用) continue;
获取到锁
break;
}
}
如果获取锁失败, 就会立即再次尝试获取锁, 无限循环, 直到获取到锁为止。⼀旦锁被其他线程释放, 就能第⼀时间获取到锁(synchronized是自适应的自旋锁,自旋不会⼀直持续, 而是达到⼀定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”)
一直自旋就会导致CPU在空转,忙等,消耗了更多的CPU资源,但锁一旦被释放就能第一时间拿到锁,拿锁的速度更快。
• 优点: 不涉及线程阻塞和调度, ⼀旦锁被释放, 就能第⼀时间获取到锁.
• 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续地消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
挂起等待锁是重量级锁的一种典型实现方式,通过内核实现,调用系统API
借助系统中的线程调度机制,当一个线程尝试加锁但锁被占用了的时候,就会让当前这个尝试加锁的线程阻塞,此时这个线程就不会参与调度了。直到锁对象被释放才唤醒这个线程,重新尝试获取锁。拿锁的速度更慢,但节省CPU资源
synchronized 轻量级锁的部分基于自旋锁实现; 重量级锁的部分,基于挂起等待锁实现
可重入锁 vs 不可重入锁
- synchronized就是可重入锁。允许同⼀个线程多次获取同⼀把锁
⼀个递归函数里有加锁操作,递归过程中这个锁不会自己阻塞自己,那么这个锁就是可重人锁(因为这个原因可重入锁也叫做递归锁)。
- C++的 std::mutex就是不可重入锁。一个线程针对同一把锁连续加锁两次,会死锁
公平锁 vs 非公平锁
- 公平锁:严格按照先来后到的顺序来获取锁,也就是哪个线程等待的时间长,哪个线程就先拿到锁
- 非公平锁:各凭本事,随机获取到锁,和线程的等待时间无关了
注意:
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized属于非公平锁
互斥锁 vs 读写锁
synchronized是互斥锁,有加锁和解锁两个操作。而读写锁有加读锁,加写锁,解锁三个操作。
Java的读写锁是这样设定的:
- 读锁和读锁之间不会产生互斥
- 写锁和写锁之间会产生互斥
- 读锁和写锁之间会产生互斥
读写锁有利于降低锁冲突的概率,提高并发能力
和MySQL中的事务不一样
- 给读操作加锁:读的时候不能写
- 给写操作加锁:写的时候不能读
降低了并发能力
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)
相关面试题
你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
- 悲观锁认为多个线程访问同⼀个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
- 乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据.在访问的同时识别当前的数据是否出现访问冲突.
- 悲观锁的实现就是加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
- 乐观锁的实现是通过CAS的方式,还可以引入⼀个版本号. 借助版本号识别出当前的数据访问是否冲突
介绍下读写锁
读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 写锁和写锁之间互斥.
- 写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 就会立即再尝试获取锁, 无限循环, 直到获取到锁为止. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁.
优点: ⼀旦锁被释放就能第⼀时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.
synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次针对同一个对象加锁不会导致死锁.
可重入锁实现的方式是在锁中记录该锁持有的线程身份, 以及⼀个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增
synchronized的优化策略
基本特点
结合上面的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):
-
开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
-
开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
-
是⼀种不公平锁
-
是⼀种可重入锁
-
是互斥锁,不是读写锁
锁升级的过程:
未加锁的状态(无锁) => 偏向锁 => 轻量级锁 => 重量级锁
上述synchronized的升级过程,针对一个锁对象来说是不可逆的(只能升级不能降级),一旦升级到了重量级锁,不会回退到轻量级锁(目前是这样的)
偏向锁
首次使用synchronized对对象进行加锁的时候不是真的加锁,而只是做一个"标记",记录这个锁属于哪个线程(非常轻量非常快,几乎没有开销),如果没有别的线程尝试对这个对象加锁,就只用一直保持这个状态直到解锁
偏向锁的解锁也就是修改一下"标记",也几乎没有开销
如果在偏向锁的状态下,别的线程尝试获取锁对象,立刻把偏向锁升级成轻量级锁,会产生互斥了
锁消除
代码里有加锁操作,编译器就会对当前的代码进行判定,看这个地方是不是真的需要加锁,如果不需要加锁,就会把加锁操作给优化掉
最典型的是只在一个线程里面使用synchronized
由于编译器优化需要保证优化前后的逻辑等价,所以起到的作用有限
锁粗化
加锁范围内包含的代码越多,就认为是锁的粒度越粗;反之,锁的粒度就越细
有些逻辑中,需要频繁加锁解锁,编译器就会把多个细粒度的锁合并成一个粗粒度的锁
举个栗子理解锁粗化:
领导给下属交代工作任务:
- 方式一:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.
- 方式二:
打电话, 交代任务1, 任务2, 任务3, 挂电话
显然, 方式二是更高效的方案