常见的锁策略
此处谈到的锁策略,不局限于 Java,C++,Python,数据库,操作系统……但凡是涉及到锁,都是可以应用到下列的锁策略的
乐观锁 vs 悲观锁
锁的实现者,预测接下来锁冲突(锁竞争,两个线程针对一个对象加锁,产生阻塞等待了)的概率是大,还是不大,根据这个冲突的概率,来接下来做什么
~~ 这不是两把具体的锁,而是“两类锁”.
悲观锁:预测锁竞争不是很激烈.
乐观锁:预测锁竞争会很激烈.
通常来说,悲观锁一般做的工作更多一些,效率更低一些,乐观锁做的工作会更少一些,效率更高一点.但是并不绝对.
悲观锁和乐观锁,唯一的区别,主要就是看预测锁竞争激烈程度的结论.
轻量级锁 vs 重量级锁
轻量级锁: 加锁解锁,过程更快更高效.
重量级锁: 加锁解锁,过程更慢,更低效.
一个乐观锁很可能也是一个轻量级锁,一个悲观锁很可能也是一个重量级锁.
多数情况下,乐观锁,也是一个轻量级锁.
多数情况下,悲观锁也是一个重量级锁.
自旋锁 vs 挂起等待锁
自旋锁是轻量级锁的一种典型实现.
挂起等待锁是重量级锁的一种典型实现.
互斥锁 vs 读写锁
互斥锁
synchronized,是互斥锁
synchronized 只有两个操作:
1.进入代码块,加锁
2.出了代码块,解锁
加锁,就只是单纯的加锁,没有更细化的区分了
读写锁
~~ 读写锁,能够把读和写两种加锁区分开
读写锁:
1.给读加锁
2.给写加锁
3.解锁
注: 如果多个线程读同一个变量,不会涉及到线程安全问题!!!
读写锁中,约定:
1.读锁和读锁之间,不会锁竞争.不会产生阻塞等待,不会影响程序的速度,代码执行很快.
2.写锁和写锁之间,有锁竞争,减慢速度,但是保证准确性.
3.读锁和写锁之间,也有锁竞争,减慢速度,但是保证准确性.
注:
1.非必要不加锁.
2.读写锁更适合于,一写多读的情况.
3.多线程针对同一个变量并发读,这时是没有线程安全问题的,也就不需要加锁控制.
4.很多开发场景中,读操作非常高频,比写操作的频率高很多.
5.在Java标准库里面也提供了读写锁的具体实现(两个类,读锁类,写锁类).
公平锁 vs 非公平锁
此处把公平定义成“先来后到”
公平锁: 当女神分手之后,就由等待队列中,最早来的舔狗上位.
非公平锁: 雨露均沾了.
注: 操作系统和 Java synchronized 原生都是“非公平锁”,操作系统这里的针对加锁的控制,本身就依赖于线程调度顺序.这个调度顺序是随机的,不会考虑到这个线程等待锁多久了.
要想实现公平锁,就得在这个基础上,能够引入额外的东西(引入一个队列,让这些加锁的线程去排队).
可重入锁 vs 不可重入锁
不可重入锁: 一个线程针对一把锁,连续加锁两次,出现死锁.
可重入锁: 一个线程针对一把锁,连续加锁多次都不会死锁.
注: 系统原生的锁,C++标准库的锁,Python标准库的锁…都不是可重入的锁!
synchronized是个"可重入锁",(加锁的时候会判定一下,看当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是,直接放行)
synchronized锁
针对上述六组锁策略, synchronized这把锁属于哪种呢??
synchronized 既是悲观锁,也是乐观锁 ~~ synchronized会根据当前锁竞争的激烈程度,自适应.
既是轻量级锁,也是重量级锁 ~~ synchronized默认是轻量级锁,如果发现当前锁竞争比较激烈,就会转换成重量级锁.
synchronized这里的轻量级锁部分基于自旋锁的方式实现,synchronized这里的重量级锁部分基于挂起等待锁的方式实现.
synchronized不是读写锁.
synchronized是非公平锁.
synchronized是可重入锁.
总结: 上述谈到的六种锁策略,可以视为是“锁的形容词”.
CAS
CAS ~~ 全称Compare and swap, 字面意思:”比较并交换“
一个 CAS 涉及到以下操作 :
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1.比较 A 与 V 是否相等。(比较)
2.如果比较相等,将 B 写入 V。(交换)
3.返回操作是否成功
此处最特别的地方,上述这个 CAS 的过程,并非是通过一段代码实现的,而是通过一条 CPU 指令完成的 => CAS 操作是原子的 ~~ 就可以在一定程度上回避线程安全问题
因此解决线程安全问题除了加锁之外,又有了一个新的方向了.
小结: CAS 可以理解成是 CPU 给我们提供的一个特殊指令,通过这个指令,就可以一定程度的处理线程安全问题.
CAS 伪代码
注: 下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.
CAS 的应用场景
1.实现原子类(Java 标准库里提供的类)
标准库中提供了
java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的.典型的就是AtomicInteger
类,其中的getAndIncrement
相当于i++
操作.
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created with IntelliJ IDEA.
* Description:
* User: fly(逐梦者)
* Date: 2023-10-09
* Time: 10:49
*/
public class ThreadDemo28 {
public static void main(String[] args) throws InterruptedException {
// 这些原子类,就是基于 CAS 实现了 自增,自减等操作.此时进行这类操作不需要加锁,也是线程安全的.
AtomicInteger count = new AtomicInteger(0);
// 使用原子类, 来解决线程安全问题
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
// 因为 java 不支持运算符重载,所以只能使用普通方法来表示自增自减
count.getAndIncrement();// count++
//count.incrementAndGet(); => ++ count
//count.getAndDecrement(); => count--
//count.decrementAndGet(); => -- count
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
伪代码实现AtomicInteger
2.实现自旋锁
自旋锁伪代码
注: CAS 属于“特殊方法”,synchronized 属于“通用方法” –> 各种场景,都能使用(打击面广)
CAS 的典型问题: ABA 问题
CAS 在运行中的核心,检查 value 和 oldValue 是否一致.如果一致,就视为value 中途没有被修改过,所以进行下一步交换操作是没问题的.
这里一致,可能是没改过.也可能是,改过,但是还原回来了?!
把 value 的值设为A的话, CAS 判定 value 为 A,此时可能确实value始终是A,也可能是value本来是A,被改成了B,又还原成了A …
ABA 问题就相当于,买个手机,买到的这个手机,可能是新机,也可能是翻新机.
翻新机: 二手的,被销售商回收了,经过一些翻新操作(把外壳换了,重新包装).
ABA 这个情况,大部分情况下,其实是不会对代码/逻辑产生太大影响的,但是不排除一些“极端情况”,也是可能造成影响的.
例子:
上述场景,概率非常低!!!一方面,恰好,滑稽这边多按了几次,产生多个扣款动作了,另一方面,恰好在这个非常极限的时间内,有人转账了一样的金额.
解决方案
针对当前问题,采取的方案,就是加入一个版本号.想象成,初始版本号是1,每次修改版本号都+1,然后进行 CAS 的时候不是以金额为基准了,而是以版本号为基准.此时,版本号要是没变,就是一定没有发生改变(版本号是只能增长,不能降低的).