常见的锁策略
锁的策略是指:在加锁过程中,处理锁冲突的时候的处理方式;
1. 悲观锁 & 乐观锁
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁, 这样别⼈想拿这个数据就会阻塞直到它拿到锁;
乐观锁:假设数据⼀般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生 并发冲突进行检测,如果发现并发冲突了,则会返回用户错误的信息,让用户决定如何去做;
乐观锁,在加锁前预测锁冲突的概率不大,会少做一些工作,而悲观锁预测锁冲突的概率较大,会多做一些工作;
2. 重量级锁 & 轻量级锁
轻量级锁:加锁的开销更小,加锁的速度更快;一般轻量级锁是乐观锁;
重量级锁:加锁的开销更大,加锁的速度更慢;一般重量级锁是悲观锁;
3. 自旋锁 & 挂起等待锁
自旋锁(纯用户态):是轻量级锁的典型实现,在加锁时,如果加锁不成功,会再次尝试加锁,
while (抢锁(lock) == 失败) {}
反复如此,因此,当其他线程释放锁的时候,能第一时间拿到锁,同时自旋锁也是乐观锁,使用自旋锁的前提就是预期出现锁冲突的概率不大;
挂起等待锁(内核态 + 用户态):是重量级锁的典型实现,在加锁不成功的时候,会进入阻塞等待(等待系统内核调度,再次加锁时,就要花费更长的时间了),不再占用 cpu 资源,当其他线程释放锁的时候,再尝试加锁;挂起等待锁是悲观锁,用于锁冲突激烈的情况;
4. 普通互斥锁 & 读写锁
普通互斥锁:例如 synchronized,涉及加锁和解锁;
读写锁:在加锁的时候分为加读锁和加写锁;读锁和读锁之间不会阻塞,读锁和写锁之间会阻塞,写锁和写锁之间也会阻塞,即加读锁的时候只能读不能写,而加写锁的时候,既不能读也不能写;synchronized 不是读写锁,使用 synchronized 加锁,两个线程读,也会互斥;Java 标准库中提供了读写锁,在java.util.concurrent.locks
包下 ReentrantReadWriteLock
5. 公平锁 & 非公平锁
公平锁:遵循 "先来后到" 的原则,若 A 线程对某个对象加锁了,B 线程先尝试对该对象加锁,则会进入阻塞等待,此时 C 线程后尝试对该对象加锁,C 也进入阻塞等待,当 A 释放锁之后,由于是 B 线程先来的,所以 B 线程会拿到锁;想要实现公平锁,就需要额外的数据结构,来记录线程的先后顺序;
非公平锁:与公平锁相反,不会遵循 "先来后到" 的原则,在上述例子中,A 线程释放锁之后,B 线程和 C 线程都可能会拿到锁; synchronized 是非公平锁;
6. 可重入锁 & 不可重入锁
对于可重入锁和不可重入锁的概念 在这里,Java 中以 Reentrant 开头命名的锁都是可重入锁,并且 JDK 提供的所有现成的 Lock 的实现类,包括 synchronized 关键字锁都是可重入的,而 Linux 系统提供的 mutex 是不可重入锁;
对比 | Java 中的 synchronized | Linux 中的 mutex |
乐观 / 悲观 | 自适应 | 悲观锁 |
轻量级 / 重量级 | 自适应 | 重量级锁 |
自旋 / 挂起等待 | 自适应 | 挂起等待锁 |
是否为读写锁 | 不是读写锁 | 不是读写锁 |
公平 / 非公平 | 非公平锁 | 非公平锁 |
可重入 / 不可重入 | 可重入锁 | 不可重入锁 |
锁升级
synchronized 对于 乐观/悲观,轻量级/重量级,自旋/挂起等待 都是自适应的,什么是自适应呢 ?synchronized 会预测出现锁冲突的概率,来决定使用哪种锁策略;
当执行 synchronized 的时候,如果这个加锁对象处于未被加锁的状态,根据后续锁冲突的概率,就可能会经历以下过程:
偏向锁 -> 轻量级锁 -> 重量级锁 这个过程就是 锁升级
偏向锁:偏向锁的思想和懒汉模式类似,(看懒汉模式的详细讲解可以 点击这里)偏向锁不是真的加锁,只是做个标记,直到另一个线程准备对该对象加锁的时候,该线程会抢先一步,立刻加锁;
此时就升级为了轻量级锁(通过自旋锁来实现的),此时就是真的加锁了;当后续锁竞争更加激烈的时候,轻量级锁就会升级为重量级锁(通过挂起等待锁实现);
可以这样理解:偏向锁假设没有其他线程来竞争;轻量级锁:有线程来竞争,但不多;重量级锁:有线程来竞争并且很多;
目前只有锁升级的过程,没有锁退化的过程;
锁消除(了解)
锁消除只是在编译时存在的,属于编译器优化;
编译器在编译代码的时候,如果发现当前代码不需要加锁,就会自动把锁去掉;
锁粗化(了解)
锁粗化也属于编译器优化;
把多个细粒度的锁,合并为一个粗粒度的锁;synchronized 代码块里面的代码越少,粒度就越细,代码越多,粒度就越粗;
通常情况下,锁的粒度更细,更有利于多个线程并发执行;但有时也需要锁的粒度粗一点,例如在一段代码中,连续多次的对一个对象进行加锁,解锁操作(包含连续的多个 synchronized 代码块),此时编译器可能会将多个 synchronized 代码块合并为一个;