日升时奋斗,日落时自省
目录
1、常见的锁策略
1.1、悲观锁vs乐观锁
1.2、轻量级锁vs重量级锁
1.3、自旋锁vs挂起等待锁
1.4、互斥锁vs读写锁
1.5、公平锁vs非公平锁
1.6可重入锁vs不可重入锁
2、CAS
2.1、CAS解析
2.2、CAS的应用场景
2.2.1、实现原子类
2.2.2、实现自旋锁
2.2.3、CAS相关ABA问题
3、Synchronized原理
3.1、锁升级/锁膨胀
3.2、锁消除
3.3、锁粗化
1、常见的锁策略
谈到锁不分语言,因为多线程操作中,锁是一种很通用的方法保证线程安全,锁也不局限于Java,C++,Python,数据库,操作系统等,如果涉及到锁,基本都是可以使用下列策略锁的
1.1、悲观锁vs乐观锁
这里不是具体的锁,应该叫做“两类锁” (两种锁的类型)
乐观锁:预测锁竞争不是很激烈(所以做一些相对更少的工作)
如:数据改变的时候 一般不会发生冲突,所以提交更新数据后才会检测是否发生冲突,如果冲突交给用户决定如何做(也就是后知后觉)
悲观锁:预测锁竞争是很激烈的(工作可能会比较多)
如:悲观锁相反而已,你要操作数据,就先加锁,谁也拿不走别人修改不了,保证安全
总结:两类锁的背后工作是截然不同的,这里也不是绝对的,判定依据,主要就是看预测锁竞争激烈程度
1.2、轻量级锁vs重量级锁
轻量级锁:加锁解锁开销都比较小,效率更高(多数情况下,乐观锁,也是一个轻量级锁,但是不完全保证)
重量级锁:加锁解锁开销都比较大,效率更低(多数情况下,悲观锁,也是一个重量级锁,但是不完全保证)
1.3、自旋锁vs挂起等待锁
自旋锁:是一种典型的轻量级锁
事例解释:一个庙里的主持,,有多个小徒弟,有一个小徒弟每天都问老主持,我能不能担任咱们庙里的主持(老和尚就对当前主持这个位置加锁),(这个小和尚就是自旋锁,老和尚辞去主持位置也就是解锁,自己就可以上位了)
优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁
缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源(此时挂起等待就不会消耗CPU的资源)
挂起等待锁:是一种典型的重量级锁
事例解释:还是小和尚们和老和尚主持,但是老和尚虽然对主持加锁了,但是没有小和尚主动去争取主持位置,想等老和尚主持主动辞职,让这个小和尚担任主持(小和尚就是挂起等待),或许老和尚主持辞去让别的小和尚担任主持,他就只是等待自动轮到他
总述:以上三种锁任何一个需要锁的场景,其实都涉及到这样的一些类似的策略情况
1.4、互斥锁vs读写锁
互斥锁:就是咱们前面用过的像synchronized这样的锁,提供加锁 和 解锁 两个操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待
读写锁:提供了三种操作 针对读加锁、针对写加锁、解锁(适合频繁读,不频繁写的场景)
多线程并发读是不存在线程安全问题的,也不需要加锁控制
(1)读锁和读锁之间,没有互斥
(2)写锁和读锁之间,存在互斥
(3)写锁和写锁之间,存在互斥
只有一组操作有读也有写,才会产生竞争(解释为多线程操作情况下)
注:只要涉及到“互斥”,就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了。
因此尽可能减少“互斥”的机会,就是提高效率的重要途径
在我们所做的开发中一般情况下,读操作比写操作更加高频
1.5、公平锁vs非公平锁
公平锁:就是所有的线程都在等待,但是等待时间肯定是有差异的,公平起见就让等的时间最长的那个来执行吧。(遵循先来后到的规则)
非公平锁:还是那线程来说,所有线程都在等,有的线程等的时间久,有的只等了一会,但是现在选择线程不会有任何条件,随机来选,选到谁来执行就谁来,对于等的时间长的线程来说就不公平了。(不遵循先来后到的规则)
操作系统和java synchronized原生都是“非公平锁”
操作系统这里的针对加锁的控制,本身就是依赖于线程调度顺序,这个调度顺序是随机的,不会考虑线程锁等了多久,如果不做任何额外的限制,锁就是非公平锁;如果要是实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序
该两种锁没有好坏之分,看程序的不同情况
1.6可重入锁vs不可重入锁
可重入锁:一个线程对一把锁,连续加锁多次都不会出现死锁(这里多次加的是同一把锁,有点像递归一样锁里套锁,可重入锁也可以叫做“递归锁”)
不可重入锁:一个线程针对一把锁 ,连续加锁两次,出现死锁
针对java:Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的
针对java提供的锁synchronized判别
(1)synchronized 即是一个悲观锁,也是乐观锁
注:synchronized默认是乐观锁,竞争变激烈后就会变成悲观锁
(2)synchronized即是轻量级锁,也是一个重量级锁
注:synchronized默认是轻量级锁,如果当前锁竞争比较激烈,就会转换成重量级锁
(3)synchronized这里轻量级锁,是基于自旋锁的方式实现的。
synchronized这里的重量级锁,是基于挂起等待锁的方式实现的
(4)synchronized不是读写锁
(5)synchronized不是公平锁
(6)synchronized是可重入锁
2、CAS
2.1、CAS解析
CAS:全称Compare and swap ,字面意思:比较并且交换
翻译有点直白,不过确实是这样的
解释CAS:原数据为 V 预期值A 需要修改值B
(1)比较A 与 V 是否相等 (比较)
(2)如果比较相等 ,将B 写入 V(交换),如果不等的话就是直接到(3)
(3)返回操作成功
这里文字解释还是有点模糊不清
来写我们这里拿图来解释 寄存器 与 内存的关系
此处最特别的地方,上述这个CAS的过程,并非是通过一段代码实现的,而是通过一条CPU指令实现的,也就是说该操作是一个“原子”(CAS是原子)
原子就可以在一定程度上回避线程安全问题,咱们解决线程安全问题除了加锁之外又有了一个新的路子
总述:CAS可以理解为CPU给我们提供了一个特殊指令,通过这个指令,就可以一定程度的解决线程安全问题
(这里写一个伪代码来解释CAS)
伪代码:就是假的,不能运行编译的,用来帮助我们来断思路的
2.2、CAS的应用场景
2.2.1、实现原子类
java标准库里提供的类AtomicInteger
AtomicInteger count=new AtomicInteger(0);
不要感觉陌生,这里就和创建一个包装类一样,直接赋值,后面的传参就是赋值
说了CAS是一个“原子” 那如何证明呢,之前的博客中有提及到锁synchronized可以解决多线程安全问题,这里也用代码尝试一下
public class Test {
public static void main(String[] args) throws InterruptedException {
//这些原子类 就是基于 CAS实现了 自增 自减等操作, 此时进行这类操作,也是线程安全的
AtomicInteger count=new AtomicInteger(0);
//使用原子类 来解决线程安全问题
Thread t1=new Thread(()->{
for (int i = 0; i < 5000; i++) {
//java中不支持运算重载 ,所以只能使用普通的方法表示自增自减
count.getAndIncrement(); //解释 count++
/* count.incrementAndGet(); //解释 ++count
count.getAndDecrement(); //解释 count--
count.decrementAndGet(); //解释 --count*/
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 5000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join(); //等待
t2.join();
System.out.println(count); //直接解决线程安全问题 不会影响多线程加的结果
}
}
我们知道如果两个线程都执行++操作,在线程不安全的情况下不会得到一个准确的数值,当前可以。
之所以有这个代码出现,不仅仅是证明CAS可以保证线程安全,还有就是java中不支持运算重载,所以中用普通的方法来表示加加或者减减(前或者后)
伪代码解释一下CAS的精妙之处
上图的(3)没有截上,这里补充一下
到这里是不是友友想问既然一条CPU指令就实现了,精妙还安全为啥还要锁来维护线程安全
CAS属于“特殊方法” ->只能特定场景使用,没有那么通用(共享访问一个量可以,例如解决++在线程的安全问题)。
synchronized 属于“通用方法” ->各种场景,都能使用。
2.2.2、实现自旋锁
伪代码解释:
2.2.3、CAS相关ABA问题
CAS在运行中的核心,检查value 和 oldValue 是否一致,如果一致就任务value没有被修改过,进行交换操作是没有问题的。(这个真的,下面解释的问题和这个有点点冲突)
要知道线程操作是很快的
value值 一致 有两种可能
value=A;
一种是真没有修改过 value =A;
第二种就是改了但是又改回来了。 value=A -> value=B -> value=A
ABA问题 家人看见的你出远门的时候好好的 ,中途可能生病了,但是回家的时候好了但是在你不说的情况下,家人认为你一直是好的。(基本就是这样一个情况)
但是ABA仍然算是个缺陷,极端情况下,也可能会造成影响(实际中概率很低)
以取钱为例,去取款机上取钱这种
许某账户余额 有1000块,准备取500元
当按下取款机一瞬间,机器卡了许某就想着多按几下,能不能快点出来,这时候就会产bug重复扣款,考虑使用CAS方式扣款(图解)
当然能看出来,上面这种情况概率还是很低的。
主要是要满足两个条件(1)恰好许某按了很多下,产生扣款操作(2)刚刚好非常极限的时间有人转账一样的金额
概率很低,但是不代表不会有,还是要考虑的,因为此时只是一种现实的情况,但是此情况一旦出现,不好解决吧,提前准备是最好的。
解决当前问题的就是 加一个版本号(就是标记),每次修改时,版本号都会更新+1,然后CAS不是以金额为基准,以版本号为基准,版本号就是无限加(版本号没有改变就代表什么都没有发生)
3、Synchronized原理
使用原理前面提及过 : 保证线程执行安全,如果两个线程加锁同一个对象,就会产生阻塞。
synchronized不仅仅是我们看到的,内部还有很多操作。
3.1、锁升级/锁膨胀
锁要经历的4个过程 无锁、偏向锁、轻量级锁、重量级锁
(1)无锁
(2)偏向锁
加锁的时候,首先会进入到偏向锁状态,偏向锁,并不是真正的加锁,而只是占个位置,有需要了在真加锁,没有需要就算了。(其实看的出还是很优质的,毕竟加锁是要有开销的)
举个例子解释偏向锁:该两个小孩买了一个夜光球,小孩A先看见就在旁边看(尚未加锁),过了一会小孩B来了,孩子都喜欢独一份的,小孩A立马将夜光球抱到怀里(加锁),这就是偏向锁(有偏向性,只是没有遇到竞争者,一旦遇到立刻加锁)
偏向锁又是怎么做到:为了降低冲突,减少开销。
这里synchronized等待也不是凭空等待,有偏向性,做个标记 (这个过程很轻量)
<1> 如果整个使用锁的过程中没有出现锁竞争synchronized执行完后,取消偏向锁即可(清楚标记)
<2> 如果另一个线程也尝试加锁,在它加锁之前,迅速就标记的偏向锁升级为真正的加锁状态
(3)轻量级锁
synchronized发生锁竞争的时候,会从偏向锁,升级到轻量锁,此时,synchronized通过自选的方式来加锁,也是自旋锁(与刚才CAS伪代码一样思路)
(4)重量级锁
自旋锁不能一直自旋不是嘛,CPU是资源开销的,自旋次数多了就不划算了,自旋到一定程度会自己停的,再次升级为重量级锁(挂起等待锁)
重量级锁就不像前面的锁一样了,很保险,是基于操作系统的原生API来进行加锁,linux原生提供了mutex一组API,操作系统内核提供加锁功能,这个锁会影响到线程的调度,如果此时线程进行到了重量级锁,发生锁竞争就只能等了,该线程也就被放阻塞队列中,直到锁被释放,线程才有机会被调度,并且有机会获取锁,(注:是有机会,不是直接就获取)线程一旦被切出CPU就会变的低效
关于锁升级就是以上内容,有升级是不是就降级,当前还没有,到了重量级锁就在这个位置了,JVM只有锁升级;要想降级也只有等该线程执行完,锁释放了,从新有一个对象获取锁,开始重复刚刚加锁的过程(偏向锁,轻量级锁,重量级锁)
3.2、锁消除
编译器智能判定,看当前的代码是否是真的要加锁
判定当前场景不需要加锁,程序员也加了,就自动把锁给卸掉
在学习StringBuffer的时候说个这个类是标准库提供的,具有线程安全的涉及线程安全会在标准库里面已经加了synchronized,如果我们针对StringBuffer就锁的话,就会被编译器直接卸掉,判定安全不需要加锁
3.3、锁粗化
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含代码越少,粒度就越细,很符合生活中的场景,留个印象就行
通常认为锁的粒度细一点比较好,为什么这么理解?我们知道多线程随机调度并发执行的,但是加锁部分的代码,是不能进行并发执行的,会降低多线程的执行速度,锁的粒度越细,synchronized内代码越少,有更多的代码能够进行并发执行。
也不是说粒度越细就一定越好,如果加锁比较频繁的情况下,大部分还是都加锁,这时候就不划算了,加锁也是有开销的,此时锁与锁之间的间隙就很小,不如一次给整个阶段都加上锁,减少加锁的开销,直接只加一次。(用图举个例子)