• 多线程进阶:常见的锁策略、CAS


    之前我们所了解的属于多线程的初阶内容。今天开始,我们进入多线程进阶的学习。

    锁的策略

    乐观锁 悲观锁

    这不是两把具体的锁,应该叫做“两类锁”

    乐观锁:预测锁竞争不是很激烈(这里做的工作可能就会少一些)

    悲观锁,预测锁竞争会很激烈(这里的工作可能就会多一些)

    这里都不绝对,悲观和乐观,唯一的区分主要就是看预测锁竞争激烈程度的结论~

    轻量级锁 重量级锁

    轻量级锁加锁解锁开销比较小~效率更高~

    重量级锁加锁解锁开销比较大~效率更低~

    多数情况下,乐观锁也是一个轻量级锁

    多数情况下,悲观锁也是一个重量级锁

    自旋锁 挂起等待锁

    自旋锁,是一种典型的轻量级锁:当某个线程没有申请到锁的时候,该线程不会被挂起,而是每隔一段时间检测锁是否被释放。如果锁被释放了,那就竞争锁;如果没有释放,过一会儿再来检测。

    挂起等待锁,是一种典型的重量级锁:当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。

    也就是说自旋锁在申请锁的过程中会一直重复申请,会占用大量的cpu资源。挂起等待锁也就可以把cpu省下来干别的事情~

    互斥锁 读写锁

    互斥锁:像synchronized这样的锁,提供加锁和解锁两个操作~

    如果一个线程加锁了,另一个线程也尝试加锁就会阻塞等待~

    读写锁:只有一组操作中有读也有写,才会产生竞争。

    公平锁 非公平锁

    此处应该把公平定义成“先来后到”。

    操作系统和Java synchronized 原生都是“非公平锁”。操作系统这里的针对加锁的控制,本身就非常依赖线程调度顺序,这个调度顺序是随机的,不会考虑到这个线程等待多久了~

    要想实现公平锁,就得在这个基础上,引入额外的东西(比如一个给锁排序的队列)

    可重入锁 不可重入锁

    不可重入锁:针对同一个线程连续加锁多次,会出现死锁

    可重入锁:针对同一个线程连续加锁多滴,不会出现死锁

    synchronized

    1.synchronized既是一个悲观锁,又是一个乐观锁。

            synchronized默认是一个乐观锁,但是如果发现当前锁竞争比较激烈,就会变成悲观锁。

    2.synchronized既是一个轻量级锁,也是一个重量级锁。

            synchronized默认是一个轻量级锁,如果发现当前锁竞争比较激烈,就会转换成重量级锁。

    3.synchronized这里的轻量级锁,是基于自旋锁的方式来实现的。

            synchronized这里的重量级锁,是基于挂起等待锁的方式来实现的。

    4.synchronized不是读写锁

    5.synchronized是非公平锁

    6.synchronized是可重入锁

    总结:上述谈到的6种锁策略,可以视为是“锁的形容词”。

    CAS

    CAS全称Compare and swap,字面意思“比较和交换”

    一个 CAS 涉及到以下操作:

    我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

    1. 比较 A 与 V 是否相等。(比较)

    2. 如果比较相等,将 B 写入 V。(交换)

    3. 返回操作是否成功。

    最重要的是:CAS的操作是原子的!

    CAS的过程并非是通过一段代码实现的,而是通过一条CPU指令完成的。

    既然是原子的,那么就可以一定程度上处理线程安全问题~

    1. boolean CAS(address, expectValue, swapValue) {
    2. if (&address == expectedValue) {
    3. &address = swapValue;
    4. return true;
    5. }
    6. return false;
    7. }

    通过一段伪代码(不能编译运行,只是表达了一个大概的逻辑)可以更好地理解CAS。

    CAS的应用场景

    1.实现原子类:Java标准库里提供的类

    标准库中提供了 java.util.concurrent.atomic 包,,里面的类都是基于这种方式来实现的.。典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。

    1. AtomicInteger atomicInteger = new AtomicInteger(0);
    2. // 相当于 i++
    3. atomicInteger.getAndIncrement();

    伪代码:

    1. class AtomicInteger{
    2. private int value;
    3. public int getAndIncrement(){
    4. int oldvalue = value;
    5. while(CAS(value,oldvalue,oldvalue+1) != true){
    6. oldvalue = value;
    7. }
    8. return oldvalue;
    9. }
    10. }此处为伪代码

    可以把oldvalue理解成为寄存器里的值。

    我们就拿伪代码来说明:

    正常情况下,oldValue应该和value是一样的值,紧接着这里会产生CAS,把oldValue + 1写到value中去。

    但是:可能会执行完把value的值写到oldvalue(寄存器)这一步后,线程发生切换了,另一个线程也进行修改了value的值

    此时这个线程回来后,通过CAS判定,就认为value和oldvalue不相等了。

    于是就返回false,不进行任何交换。进入循环,循环内部重新读取value的值到oldvalue中去。

    此时在比较,发现相等了,进行CAS操作,并返回true,就不进入循环结束了。

    原子类这里的实现,每次修改之前,再确认一下这个值是否符合要求。

    通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。

    2.实现自旋锁

    自旋锁伪代码:

    1. public class SpinLock {
    2. private Thread owner = null;
    3. public void lock(){
    4. // 通过 CAS 看当前锁是否被某个线程持有.
    5. // 如果这个锁已经被别的线程持有, 那么就自旋等待.
    6. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
    7. while(!CAS(this.owner, null, Thread.currentThread())){
    8. }
    9. }
    10. public void unlock (){
    11. this.owner = null;
    12. }
    13. }

    while循环中:监测当前的owner是否是null,如果是null,就进行交换,也就是把当前线程的引用赋值给owner 。如果赋值成功,此时循环结束,加锁完成了。
    如果当前锁,已经被别的线程占用了,CAS就会发现,,this.owner 不是null,CAS就不会产生赋值,也同时返回false.循环继续执行,并进行下次判定....
     

    ABA问题

    CAS在运行中的核心:检查value和oldValue是否一致。如果一致,就视为value 中途没有被修改过,所以进行下一步交换操作是没问题的。

    但是这里的一致,可能是没改过,也可能是改过,但是改回来了。

    把value的值设为A的话,CAS判定value为A,此时可能确实value始终是A,也可能是value本来是A,被改成了B,又被还原成了A。

    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相同,再次执行扣款操作

    这个时候扣款就被执行了两次。

    解决办法

    给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。CAS 操作在读取旧值的同时,也要读取版本号。真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1 。如果当前版本号高于读到的版本号,操作失败(认为数据已经被修改过了)。

    synchronized原理

    两个线程,针对一个对象加锁,就会产生阻塞等待。

    synchronized内部其实还有一些优化机制,存在的目的就是为了让这个锁更高效,更好用。

    1.锁升级、锁膨胀

    1)无锁  2)偏向锁  3)轻量级锁  4)重量级锁

    1. synchronized(locker){
    2. }

    当代码执行到这个代码块中之后,加锁过程会经历前面说的这几个阶段:

    偏向锁:

    进行加锁的时候,首先会进入到偏向锁的状态。偏向锁的过程就是:“非必要,不加锁”。

    synchronized的时候,并不是真的加锁,先偏向锁状态,做个标记(这个过程是非常轻量的),如果整个使用锁的过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可。

    (反正也没有锁竞争,这样就开销最低了~)

    但是如果使用过程中,另一个线程也尝试加锁,在它加锁之前,迅速地把偏向锁升级成真正的加锁状态,另一个线程也就只能阻塞等待了~

    轻量级锁:

    当synchronized发生锁竞争的时候,就会从偏向锁,升级成轻量级锁。

    此时,synchronized相当于是通过自旋的方式来进行加锁的。刚才那个CAS那里的那个伪代码一样~

    synchronized内部的自旋循环中,有个计数器,记录了循环了多少次/多久了,达到一定程度,就结束循环,执行重量级锁的逻辑。

    重量级锁:

    此时如果线程进行了重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列中,暂时不参与CPU调度了~

    然后锁被释放了这个线程才有机会被调度到,并且有机会获取到锁。

    锁降级:

    锁能升级了,但是不能降级。只有锁升级,没有锁降级。除非是另外搞一个对象,重复刚才的从偏向锁开始升级的过程~

    2.锁消除

    编译器智能的判定,看当前的代码是否是真的要加锁,如果这个场景不需要加锁,程序猿也加了,就自动把锁给干掉~~
    StringBuffer关键方法都带有synchronized。
    但是如果在单线程中使用StringBuffer, synchronized 加了也白加,此时编译器就会直接把这些加锁操作干掉了。

    3.锁粗化

    锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码就越少,粒度就越细。

    通常情况下,认为锁的粒度细一点比较好。加锁的部分,是不能并发执行的。锁的粒度越细,能并发的代码就越多,反之就越少。

    但是有些情况下,锁的粒度粗一些反而更好~


     

  • 相关阅读:
    云呐|动环监控有什么作用?动环监控系统的监控对象有哪些?
    Linux计划任务以及进程检测与控制
    VS2019+cmake 方式添加ffmpeg库文件,cmake添加lib文件dll文件,包含目录示例
    ECCV 2022最新研究成果:全球首个text-sketch-image数据集FS-COCO
    Vue脚手架一站式搭建项目
    JVM基础-Hotspot VM相关知识学习
    java毕业生设计校园一卡通管理系统计算机源码+系统+mysql+调试部署+lw
    lock 和 synchronized
    数据结构与算法笔记七(暴力递归到动态规划)
    在windows中使用mysql workbench连接vmware windows虚拟机中的mysql
  • 原文地址:https://blog.csdn.net/m0_62319039/article/details/133175403