目录
🐑今日良言:追星赶月莫停留,平芜尽处是春山
🐂一、锁策略
🐼二、CAS
🐭三、Synchronized
🐑今日良言:追星赶月莫停留,平芜尽处是春山
🐂一、锁策略
锁策略是实现锁的时候,考虑出现锁竞争了该怎么办
乐观锁:预测锁竞争不是很激烈(这里做的工作相对更少)悲观锁:预测锁竞争很激烈(这里做的工作相对更多)悲观和乐观唯一的区分:主要是看预测锁竞争激烈程度的结论
重量级锁:加锁机制重度依赖了OS提供mutex(互斥锁)大量的用户态和内核态切换 很容易引发线程的调度轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态用代码完成,实在搞不定了.再使用mutex少量的内核态和用户态切换 不太容易引发线程调度轻量级锁加锁解锁开销比较小,效率更高.重量级锁加锁解锁开销比较大,效率更低.
自旋锁:是一种典型的轻量级锁挂起等待锁:是一种典型的重量级锁自旋锁:获取锁失败,立即再次尝试获取锁,无线循环,一旦锁被释放,能第一时间感知到,从而获取到锁. 第一次获取锁失败,第二次尝试获取锁将在极短的时间内到来.相较于挂起等待锁:优点:没有释放CPU资源,一旦锁被释放,第一时间就能获取到,更高效,在锁持有时间比较短的场景中非常有用.缺点:如果锁的持有时间较长,就会浪费CPU资源.挂起等待锁:没有申请到锁的时候,线程被挂起,加入到阻塞队列中等待,当锁被释放后,有机会获取到锁.
互斥锁:就是synchronized这样的锁,提供了加锁解锁两个操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待读写锁:提供了针对读加锁 针对写加锁 解锁 三种操作.读加锁和读加锁之间,不互斥读加锁和写加锁之间,互斥写加锁和写加锁之间,互斥注:只要涉及到"互斥",就会产生线程的挂起等待.一旦挂起等待,再次唤醒就不知道隔了多久了.因此,尽可能减少"互斥"的机会,就是提高效率的重要途径.读写锁适用于"频繁读 不频繁写"的场景中.
假设有三个线程A B C. A先尝试获取锁,获取成功.B尝试获取锁,获取失败,阻塞等待.C最后尝试获取锁,获取失败,阻塞等待,当线程A释放锁以后,会发生什么呢?公平锁:遵循"先来后到".B比C先尝试加锁,当A释放锁之后,B就能比C先获取到锁非公平锁:不遵循"先来后到".B和C都可能获取到锁.举个例子:你和你的兄弟们一起追一个女生,当女生和自己的对象分手后,先追女生的男生先上位,这就是公平锁.如果是女生不按照先后顺序,挑一个自己顺眼的,就是非公平锁注:操作系统内部的线程调度可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁如果要实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序.公平锁和非公平锁没有好坏之分,关键还是看适用场景
可重入锁:一个线程针对一把锁加锁两次,不出现死锁.不可冲入锁:一个线程针对一把锁加锁两次,出现死锁.
🐼二、CAS
1.什么是CAS?
CAS全称: compare and swap. 根据字面意思理解为"比较并交换"
一个CAS涉及到以下操作:
假设内存中的原数据是V,旧的预期值是A,需要修改的新值是B
1).先比较A和V是否相等(比较)
2).如果比较相等,就将B写入V(交换)
3).返回操作是否成功
在上述交换过程中,大多数情况下并不关心B后续的情况,更关心的是V这个变量的情况
这里说是交换,其实可以近似理解成:赋值
此处最特别的地方是:上述这个CAS过程并非是通过一段代码实现的,而是通过一条CPU指令完成的,这说明CAS操作是原子的!!!!!
CAS可以理解成是CPU提供的一个特殊指令,通过这个指令可以一定程度上处理线程安全问题.
CAS的伪代码:
boolean CAS(value, oldValue, swapValue) {
if (value == oldValue) {
value = swapValue;
return true;
}
return false;
}
2.CAS的应用场景
CAS的应用场景主要有两个:
1).实现原子类
java标准库提供了java.util.concurrent.atomic包,里面的类都是基于CAS的方式实现的,其中最典型的就是AtomicInteger类,其中的getAndIncrement相当于i++操作
通过下面一个练习来理解原子类:
两个线程增加同一个变量
public class Exercise { public static void main(String[] args) throws InterruptedException { AtomicInteger integer = new AtomicInteger(0); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { integer.getAndIncrement();// 相当于i++操作 } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { integer.getAndIncrement();// 相当于i++操作 } }); t1.start(); t2.start(); // 为了让主线程等待t1线程和t2线程执行完毕 t1.join(); t2.join(); System.out.println(integer); } }
运行结果:
2).实现自旋锁
通过下面的伪代码理解:
public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
3.ABA问题
CAS运行的核心:检查value与oldValue是否一致.如果一致,就视为value中途没有被修改过,所以进行下一步交换操作是没问题的.
但是,这里的一致,可能是value没有被修改过,也有可能是value被修改过又改回来了.
把value的值设为A的话,CAS判定value为A,此时value的值可能始终为A,也可能是value本来是A,被修改为了B,最后又还原成了A.
这就是CAS的典型问题:ABA问题
通过下面的实例来进一步理解ABA问题
假设 张三 有 100 存款 . 张三 想从 ATM 取 50 块钱 . 取款机创建了两个线程 , 并发的来执行 -50操作 .我们期望一个线程执行 -50 成功 , 另一个线程 -50 失败 .如果使用 CAS 的方式来完成这个扣款过程就可能出现问题 .正常的过程1) 存款 100. 线程 1 获取到当前存款值为 100, 期望更新为 50; 线程 2 获取到当前存款值为 100, 期望更新为 50.2) 线程 1 执行扣款成功 , 存款被改成 50. 线程 2 阻塞等待中 .3) 轮到线程 2 执行了 , 发现当前存款为 50, 和之前读到的 100 不相同 , 执行失败 .
异常的过程1) 存款 100. 线程 1 获取到当前存款值为 100, 期望更新为 50; 线程 2 获取到当前存款值为 100, 期望更新为 50.2) 线程 1 执行扣款成功 , 存款被改成 50. 线程 2 阻塞等待中 .3) 在线程 2 执行之前 ,张三 的朋友正好给张三转账 50, 账户余额变成 100 !!4) 轮到线程 2 执行了 , 发现当前存款为 100, 和之前读到的 100 相同 , 再次执行扣款操作这个时候 , 扣款操作被执行了两次 !!!
解决ABA问题的方法:给要修改的值,引入版本号
引入版本号后,解决刚才转账异常的情况:假设 张三 有 100 存款 . 张三 想从 ATM 取 50 块钱 . 取款机创建了两个线程 , 并发的来执行 -50操作 .我们期望一个线程执行 -50 成功 , 另一个线程 -50 失败 .为了解决 ABA 问题 , 给余额搭配一个版本号 , 初始设为 1.1) 存款 100. 线程 1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程 2 获取到存款值为 100,版本号为 1, 期望更新为 50.2) 线程 1 执行扣款成功 , 存款被改成 50, 版本号改为 2. 线程 2 阻塞等待中 .3) 在线程 2 执行之前 , 张三 的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成 3.4) 轮到线程 2 执行了 , 发现当前存款为 100, 和之前读到的 100 相同 , 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本 , 认为操作失败 .
🐭三、Synchronized
1).synchronized既是一个悲观锁,也是一个乐观锁
synchronized默认是乐观锁,但是如果发现锁竞争激烈,就会变成悲观锁
2).synchronized既是一个轻量级锁,也是一个重量级锁
synchronized默认是轻量级锁,人但是如果发现锁竞争激烈,就会变成重量级锁
3).synchronized这里的轻量级锁,是基于自旋锁的方式实现的.
synchronized这里的重量级锁,是基于挂起等待锁的方式实现的.
4).synchronized 不是读写锁
5).synchronized 是非公平锁
6).synchronized 是可重入锁
synchronized内部有一些优化机制,存在的目的就是为了让这个锁更高效更好用.
1.加锁工作过程(锁升级/锁膨胀)
jvm将synchronized 锁分为:无锁 偏向锁 轻量级锁 重量级锁 状态,会根据情况,进行依次升级.
1).偏向锁
第一个尝试加锁的线程,优先进入偏向锁状态.偏向锁不是真的"加锁",而是做个偏向锁标记,记录这个锁属于哪个线程.
如果整个使用锁的过程中,没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可
如果整个使用锁的过程中.另一个线程也尝试加锁,那么在它加锁之前,迅速的把偏向锁状态升级为加锁状态,另一个线程只能阻塞等待了.
2).轻量级锁
随着其它线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)
此处的轻量级锁就是通过CAS来实现的.
自旋操作是一直让CPU空转,比较浪费CPU资源,因此,此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不在自旋了,也就是所谓的"自适应".
3).重量级锁
如果竞争进一步激烈,自旋不能快速获取到锁状态,就会升级为重量级锁
此处的重量级锁就是指用到内核提供的mutex
2.锁消除
编译器+JVM智能的判定:当前的代码是否真的需要加锁,如果这个场景不需要加锁,程序猿加了,就自动的把锁消除.
3.锁粗化
一段逻辑中如果出现多次加锁解锁,编译器+JVM会自动进行锁的粗化.
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码越少,粒度就越细.
通常情况下,锁的粒度细一点比较好.
加锁的部分代码,是不能并发执行的,锁的粒度越细,能并发执行的代码就越多,反之越少.
但是,有的情况下,锁的粒度粗一些反而好,例如:两次加锁解锁之间间隙非常小,这种情况不如直接使用一次大锁搞定.