这几天学习了关于锁策略和CAS的知识, 于是乎有了此文作笔记
锁策略
对于多线程中的锁有很多有意思的思想, 这些思想应用在不同的地方可以减少锁竞争, 提高程序运行效率
乐观锁和悲观锁
- 乐观锁就是预测接下来发生锁冲突的概率不大, 觉得每一次操作数据的时候都觉得不会发生锁冲突, 就不会加锁。只是当更新数据的时候会检查一下数据有没有更新过, 如果更新过, 就会重新读取一下数据, 再检查是否更改, 并尝试更新。乐观锁很适合应用在读取次数多, 修改次数少的场景中
- 悲观锁就是预测接下来发生锁冲突的概率很大, 认为每一次操作数据都会发生锁冲突, 就会一直加锁。如果当前线程加上了锁, 其他线程就会一直阻塞。
- synchronized 是一种自适应锁, 在锁冲突概率小的时候, 就会是乐观锁, 具体表现为偏向锁(下面会说), 当锁冲突大的时候, 就会是悲观锁
读写锁
加锁的操作更加细致化, 可以加的锁有两种 : 读锁 和 写锁
- 如果多个线程都只进行读取操作,就可以共享读取这一操作, 多个线程一起共同读取数据, 相当于没有锁
- 如果线程要进行写入数据操作, 就会进行加锁, 别的线程无法进入, 也就无法进行读取操作和写操作
- 如果频繁读取数据, 该锁就可以避免很多不必要的开销, 提升效率
重量级锁和轻量级锁
重量级锁
-
重量级锁开销更大, 那么重量级锁开销大体现在哪里嘞??
-
我们先简单说一下内核态和用户态是什么:
操作系统启动时会对内存进行分区,操作系统的数据都是存放于内核空间,用户进程的数据是存放于用户空间的。处于用户态级别的程序只能访问用户空间,而处于内核态级别的程序可以访问用户空间和内核空间。
当一个进程执行系统调用而进入内核代码中执行时,我们就称进程处于内核态 -
然而, 重量级锁在底层是在操作系统中需要依靠其中的锁实现的, 所以在重量级锁的状态下, 线程反复阻塞或者被唤醒, 就会导致操作系统在内核态和用户态的频繁切换, 获取锁and释放锁, 造成很大的资源开销
-
重量级锁如果没有得到锁, 就会进入阻塞状态;锁被释放后, 也无法马上获取锁。挂起等待锁是重量级锁的一个具体实现, 但是好处被阻塞的线程不会消耗CPU
轻量级锁
- 相反于重量级锁, 它锁开销更小, 会避免使用操作系统内的锁, 尽量在用户态就能正常工作
- 自旋锁是轻量级锁的具体实现方式, 发生锁冲突的时候, 会马上再试一下能不能获得锁, 努力能在其他线程释放锁后能马上得到锁, 当然, 这样的弊端就是会导致 CPU 循环判断锁状态, 持续消耗 CPU 资源
公平锁和非公平锁
- 公平锁
各个线程会按照申请锁的顺序进入队列, 按照"先到先得"的原则, 最先申请的会更快获取到锁。这么做的好处是所有线程都有机会得到锁 - 非公平锁
线程会先尝试去获取锁, 如果能获取到, 那就获取到了锁;如果获取不到, 就会进入等待的队列, 相反, 这样的弊端是部分线程会长时间拿不到锁
可重入锁和不可重入锁
- 可重入锁
即某个线程已经获得了锁, 可以再次获取本对象的锁而不出现死锁, 即可重入锁, 比如这种情况(有点像双层锁)
public static Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
System.out.println("第一层锁");
synchronized (lock) {
System.out.println("第二层锁");
}
};
}).start();
}
在第一层 lock 锁获取而未释放的情况下, 再次获取该锁
- 不可重入锁
这个就和上面相反, 当线程获取锁后, 必须要等这个线程的锁释放后, 才能获取其他锁
CAS
正文
CAS 即 compare and swap, 是操作系统提供的一种原子操作的机制
CAS 涉及到三个变量 : A. 内存地址, B.寄存器中用来比较值, C.用来交换的值
如果 内存中的值 和 用于比较的值 相等, 那就将 交换的值 赋给 内存地址对应的值
这里用代码语言翻译一下应该比较清晰, 但是实际上并非如此实现! 仅供理解
内存地址 用来比较的值 用来交换的值
boolean CAS(address, compareValue, swapValue) {
if (&address == compareValue) {
&address = swapValue;
return true;
}
return false;
}
如果成功修改, 返回 true, 否则返回 false
AtomicInteger 类就应用了 CAS 机制, 其中的 getAndIncrement() 就是原子性的自增操作, 应用 CAS 后, 我们不加锁, 也可以实现两个线程各自对一个变量自增1000次 ↓
public static void main(String[] args) throws InterruptedException {
// 创建 AtomicInteger 对象, 并赋值为 0
AtomicInteger cas = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i ++) {
cas.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
cas.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 打印 cas 值
System.out.println(cas.get());
}
运行结果如下
总结
- 合理使用 CAS 能够减少上锁的次数, 也就减少了线程唤醒和线程阻塞之间的转换, 能够提高效率
- CAS 同时还有一个弊端, ABA问题
ABA
我们举个例子, 假设目前有三个线程 ABC, 对同一内存地址进行访问并更改
突然有两个线程AB进行一样的操作, 都读取内存值100, 想更新为 50, 但是线程 B 突然阻塞了, 而 A 顺利进行, 将内存中的100, 更新为了 50
线程A更改后,B还在阻塞, 线程C这时候进来读取内存值为50 , 线程C又是顺利改为100
这时候线程B回来了, 原来想要读取内存值为100, 如果是100, 那就更新。但是现在的内存值仍是100, 而线程 B 并不知道这个100不是原来那个100了, 已经是经过一两轮变化的了! 就继续更改为50
这种情况看起来可能暂时没什么问题, 但是如果这种场景发生在银行, 那就不是个小问题了, 例如
还是上面的情形,
由于某种原因, 飞飞到银行里取钱, 余额 100, 想拿 50, 但是飞飞不小心多按了一次, 导致同时有两个线程同时执行这个操作。
还是一样, 线程 B 突然给系统整阻塞了, 线程 A 顺利执行, 飞飞拿出了50块钱, 余额剩50
B还在阻塞的时候, 这时候你的好兄弟突然就给你转了 50, 即线程 C 读取到余额50, 并要更新到 100
然后线程B回来了, 线程B一开始的操作就是→读取余额100, 更改为50。所以现在还是没变化, 继续读取余额100, 然后更改为50
诶, 问题就来了, 原来有100, 飞飞拿了50, 好兄弟转了50, 余额应该是 100 才对, 现在多扣了50, 这就很尴尬了
为了解决这种问题, CAS 引入了版本号, 并规定版本号只增不减, 并且每次在比较内存值的时候, 都需要比较版本号是否一致, 这个版本号也就一定程度反映了数据的变化过程
就像上面那个问题, 当线程B阻塞的时候, 其他线程CAS完后, 版本号都会自增, 当线程B回来更新数据时候, 就会发现版本号不一样而无法更新。
synchronized 自适应过程
然后我们再来谈谈 synchronized 加锁的时候经历的几个阶段
- 无锁 : 当线程创建后, 还没有线程获取锁, 就会处于无锁状态
- 偏向锁 : 当只有一个线程参与锁的获取与释放的时候, 就处于偏向锁状态。获取偏向锁后, 执行完同步代码块的时候也不会主动释放锁, 而是等到下一次进入同步代码块的时候判断一下自己有没有偏向锁, 如果还是同一个线程, 那由于之前没有释放锁, 现在也不用再获取锁, 只有当出现锁竞争的时候, 就会撤销偏向锁, 膨胀为轻量级锁, 这样就能大量减少获取锁的消耗。
- 轻量级锁 : 开始发生锁竞争的时候, 开始转向轻量级锁, 会自旋尝试获取锁
- 重量级锁: 当够多的线程竞争锁, 或者自旋次数过多又没获取到锁的时候, 进入重量级锁, 没获取到锁的线程阻塞, 防止 CPU 空转