• 【JavaEE进阶】锁策略, 和 synchronized 优化过程


    1.常见的锁策略

    锁策略和普通程序员基本没有啥关系,和“实现锁” 的人,才有关系,这里所提到的锁策略,和 Java本身就没有关系,适合所有的和“锁”相关的情况

    1.1 悲观锁 vs 乐观锁

    • 悲观锁: 预期锁冲突概率比较高。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改这个数据,所以每次在拿到数据之后就会上锁,这样别人想拿到这个数据就会阻塞等待,直到上面的这个锁被释放之后,才能拿到这个锁。
    • 乐观锁: 预期锁冲突概率很低。假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会真的对数据是否产生并发冲突进行检测,如果发生冲突,则让返回用户错误的信息,让用户决定如何去做

    这里举一个例子:

    就还是拿疫情说吧(我们知道在疫情的时候,吃的米面油很难买到),这里的乐观锁就好比说,因为我们是很乐观的,所以说,如果下次疫情即使来了,但是我们需要的菜应该还是能买到的(根据前两拨疫情的经验),就不必专门做特殊的准备。做的工作更少,付出的成本更低,更低效

    悲观锁认为,下一波疫情来了之后,可能就买不到菜了,于是换一个大的冰箱,去超市定期屯一些米面油等生活用品。所以要做的事情有很多,付出跟多的成本和代价.做的工作更多,付出的成本更多,更低效

    1.2 读写锁 vs 普通的互斥锁

    • 普通的互斥锁: 对于普通的互斥锁,只有两个操作:加锁和解锁。只有两个线程针对同一个对象加锁,就会产生锁竞争(互斥)。
    • 读写锁: 对于读写锁来说,分为三个操作,加读锁加写锁解锁
      • 加读锁:如果代码只是进行读操作,就加读锁。
      • 加写锁:如果代码中进行了修改操作,就加写锁。

    对于读锁和读锁之间,是不存在互斥关系的,因为多线程同时读取同一个变量不会有线程安全问题。

    读锁和写锁之间,写锁和写锁之间,才会互斥。

    而且在很多场景中,都是读操作多,写操作少。

    读写锁就是把读操作和写操作区分对待,Java标准课中提供了ReentrantReadQriteLock类,实现了读写锁。

    • ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了lock/unlock方法进行加锁解锁。
    • ReentrantReadWirteLock.WriteLock 类表示一个写锁,这个对象也提供了lock/unlock方法进行加锁解锁

    注意,只要是设计到"互斥",就会产生线程的挂起等待,一旦线程挂起等待,在此被唤醒就不知道隔了多久了。

    因此尽可能减少"互斥"的机会,就是提高效率的重要途径。

    读写锁特别适合于"频繁读,不频繁写"的场景中(这样的场景其实是非常广泛存在的)

    比如就那一个“教务管理系统”来说,每次老师上课点名都要使用教务系统点名,点名就需要查看班级同学列表(读操作),这个操作可能要每周执行好多次。

    但是什么时候会修改同学的列表信息呢(写操作),那就是新同学加入的时候,可能一个月都不必改一次。再比如,同学梦使用的教务管理系统查看作业(读操作),一个班级的同学很多,读操作一点就要进行几十多次,或者上百次,但是这一节课,老师布置作业(写操作)就一次。

    1.3 重量级锁 vs 轻量级锁

    这两个锁,和上面的悲观锁和乐观锁有一定的重叠。

    • 重量级锁:就是做了更多的事情,开销更大 它的加锁机制重度依赖了OS 提供的 mutex,大量的内核态用户态切换,很容易引发线程调度。
    • 轻量级锁:做的事情更少,开销更小 它的加锁机制尽可能不使用mutex,而是尽量使用用户态代码来完成,实在不行,再使用mutex. 有少量的用户态内核态切换,不容易引起线程调度。
    • 这两种锁都是处理锁冲突的一种结果。

    也可以认为,在通常情况下,悲观锁都是重量级锁,乐观锁一般都是轻量级锁,但是不是绝对的。

    在我们使用的锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的 mutex 接口)此时一般认为是重量级锁。(操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)

    如果锁是纯用户态实现的,此时一般认为这是轻量级锁(用户态的代码更可控,也更加高效

    如果想知道 用户态和内核态更仔细的描述,请看博主的上一篇博客。

    在这里博主在重申一下,悲观锁和乐观锁是造成重量级锁和轻量级锁的原因,重量级锁和轻量级锁是原因造成的结果。

    1.4 挂起等待锁 vs 自旋锁

    挂起等待锁: 往往就是通过内核的一些机制来实现的,往往比较重,是重量级锁的一种典型实现

    自旋锁: 往往就是通过用户态的代码来实现的,往往较轻,是轻量级锁的一种典型实现

    解析自旋锁: 按照我们之前的方式,线程在锁竞争,抢锁失败之后就会进入带阻塞队列,放弃CPU,需要过很久的时间才能被再次唤醒,调度这个线程。但是实际上,大部分情况下,虽然挡墙线程抢锁失败,但过不了多久,锁就会被释放。没有必要放弃CPU,这个时候就可以使用自旋锁
    来处理这样的问题。

    自旋锁的伪代码:while(抢锁(lock) == 失败){}

    如果获取锁失败,立即再次尝试获取锁,无限循环,直到获取到锁位置,第一次获取锁失败之后,第二次的尝试会在极端的时间内到来。一旦锁被释放,就崩在第一时间获得到锁。

    详细的理解挂起等待锁和自旋锁

    在这里插入图片描述

    在这里插入图片描述

    自旋锁的优缺点:

    • 优点: 没有放弃CPU,不涉及到线程阻塞和调度,一旦锁被释放,那么就能在第一时间获取到这个锁。
    • 缺点: 如果这个锁被其他线程持有的时间比较长,那么此时如果再使用自旋锁,就会持续的消耗CPU的资源(挂起等待锁,在锁竞争之后,如果没有抢到锁,那么这个线程就会进入到阻塞状态,不消耗CPU资源)

    小结

    此处我们小结一下,在上面介绍的众多锁中除了读写锁和普通的互斥锁之外,我们可以看到悲观锁和乐观锁,重量级锁和轻量级锁,挂起等待锁和自旋锁之间都有这千丝万缕的联系。重量级锁和轻量级锁是悲观锁和乐观锁造成的结果,挂起等待锁和自旋锁是重量级锁和轻量级锁的一种典型实现。

    1.5 公平锁 vs 非公平锁

    实现"公平锁"和"非公平锁"这两个概念特别容易搞混!!!

    公平锁: 多个线程在等待一把锁的时候,谁先来的,谁就能先获取到这把锁(遵循先来后到)

    非公平锁: 多个线程在等待一把锁的时候,不遵循先来后到(每个等待的线程获取到的概率都是均等的)

    可能会有些老铁觉得,机会是均等的,才是公平的,但是不然,我们在这里说的公平是针对是否约定遵循了先来后到的规则。

    对于操作系统来说,本身线程之间的调度就是随机的(机会均等的),操作系统中提供的mutex这个锁,就是一个非公平锁

    我们有些老铁可能会想到,在操作系统中是不是线程之间存在优先级,那么为什么这个操作系统中的mutex这个锁,还是一个非公平锁呢?

    其实考虑到相同优先级的情况下,实际开发中很少会手动修改线程的优先级,即使改了,在宏观上的体会并不明显。

    要想实现一个公平锁,反而要付出更多的代价(得需要一个队列,来把这些参与竞争的线程给排一下,先来后到的顺序)

    这里举一个例子,理解公平锁和非公平锁

    在这里插入图片描述

    在这里插入图片描述

    注意:

    • 操作次你同内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁,如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程的先后顺序。
    • 公平锁和非公平锁没有好坏之分,关键还是看适用的场景

    1.6 可重入锁 vs 不可重入锁

    可重入锁和不可重入锁,博主在以前的文章中介绍过,这里就简单在描述一下。

    可重入锁的字面意思就是"可以重新进入的锁",即允许同一个线程多次获得同一把锁

    Java中只要以Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有线程的Lock实现的类,包括synchronized关键字都是可重入锁。

    1.7 关于锁策略的相关面试题

    1.你是怎么理解乐观锁和悲观锁的,具体怎么实现的呢?

    悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前去真正的加锁。

    乐观锁认为多个线程访问同一个共享变量冲突概率不带,并不会真的加锁,而是直接尝试访问数据,在访问的同事识别当前的数据是否出现了访问冲突。

    悲观锁的实现就是先加锁(比如借助操作系统提供的mutex),获取到所在操作数据,获取不到锁就等待。

    乐观锁实现可以引入一个版本号。借助版本号识别当前的数据是否冲突

    2.介绍一下读写锁?

    读写锁就是把读操作和写操作分开进行加锁。

    读锁和读锁之间不互斥。

    写锁和读锁之间,写锁和写锁之间互斥。

    读写锁最主要用在"频繁读,不频繁写"的场景中

    3.什么是自旋锁,为什么要使用自旋锁策略,缺点是什么?

    如果获取锁失败,立即在尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。

    相比于挂起等待锁,

    **优点:**没有放弃CPU资源,一旦锁被释放就会在第一时间获取到这个锁,更有效,在锁持有之间比较短的场景中非常有用。

    缺点: 如果锁持有的时间教程,就会浪费CPU资源

    4.synchronized 是可重入锁吗?

    是可重入锁。可重入锁指的是连续两次加锁不会产生死锁。

    实现的方式是,在锁中记录该所持有的线程身份,以及一个计数器(记录加锁的次数),如果发现当前加锁的线程是持有锁的线程,则直接计数自增。

    2. synchronized 是一个怎么样的锁?

    • 既是一个乐观锁,也是一个悲观锁(根据锁竞争的激烈程度,自适应)
    • 不是读写锁,只是一个普通的互斥锁
    • 既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
    • 轻量级锁的部分是基于自旋锁实现的,重量级锁的部分是基于挂起等待锁实现的
    • synchronized 是一个非公平锁
    • synchronized 是一个可重入锁

    3. CAS(compare and swap[比较和交换])

    CAS(compare and swap):字面意思是比较和交换。

    它要做的是就是 拿着 寄存器/某个内存中的值 和 另外一个内存中的值 进行比较,如果值相等,那么就把另外一个寄存器/内存中的值,和当前的这个内存中的值做交换

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

    • 比较A和V是否相等(比较)
    • 如果比较相等,将B写入V(交换)
    • 返回操作是否成功

    使用伪代码辅助理解CAS的工作流程:

    在这里插入图片描述

    在上述的尾代码中,"交换"也可以理解成为赋值,我们可以看看这段代码是不是线程安全的?

    这段代码,显然是线程不安全的,即有读又有写,而且读和写还不是原子的

    两个典型的不是"原子性"的代码:

    • check and set (if判定后设定值)上面的CAS伪代码就是这种形式
    • read and update(i++) 之前我们将线程安全的时候的代码是这种形式的 i++有三种操作

    此处所谓的CAS 指的是CPU提供的一个单独的CAS指令,通过这一条指令,就完成上述伪代码描述的过程那么如果上述的过程都是这"一条指令"就干完了,就相当于这是原子的了(CPU上面执行的指令就是一条一条执行的,指令已经是不可分割的最小单位),那么此时的线程就安全了

    CAS 最大的意义: 就是让我们写的这种多线成安全的代码,提供一个新的思路和方向。

    3.1 CAS 都能干啥?能如何帮我们解决一些线程安全问题?

    3.1.1 基于 CAS 能够实现"原子类"

    Java标准库中提供了一组原子类,针对锁常用的一些int,long,array.....进行了封装,可以基于CAS的方法来进行修改,并且线程安全

    public class TestDemo2 {
        public static void main(String[] args) throws InterruptedException {
            //使用原子类,并且是线程安全的
            AtomicInteger a = new AtomicInteger();
            Thread t1 = new Thread(()->{
                for(int i = 0;i<5000;i++){
                    //相当于a++
                    a.getAndIncrement();
                     //相当于++a
                     //a.incrementAndGet();
                     //相当于a++
                     //a.getAndIncrement();
                     //相当于a--
                     //a.getAndDecrement();
                     //相当于--
                     //a.decrementAndGet();
                     //相当于a+=10
                     //a.getAndAdd(10);
                }
            });
            Thread t2 = new Thread(()->{
               for(int i = 0;i<5000;i++){
                   a.getAndIncrement();
               }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            //得到a的值
            System.out.println(a.get());
        }
    }
    //运行结果:10000
    //显而易见,这个代码不存在线程安全问题,基于CAS实现的++操作,这里面就可以保证技能公线程安全,又能够比synchronized高效,因为synchronized 会涉及到锁的竞争,两个线程要相互等待。然而CAS不涉及到线程阻塞等待。
    //也就是说着两个线程,双管齐下,两个线程之间没有相互影响
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    在CAS中AtomicInteger类的具体实现

    在这里插入图片描述

    那么为什么上面的++操作是线程安全的呢?

    在这里插入图片描述

    3.1.2 基于CAS 能够实现"自旋锁"

    自旋锁伪代码

    在这里插入图片描述

    3.2 如何理解CAS中的ABA问题?

    我们知道CAS中的关键在于 先比较,再交换,比较:其实是在比较 当前值 和 旧值 是否相等,如果这两个值相等,那么就视为中间没有发生过改变。

    但是这里的结论存在漏洞,当前值 和 旧值 相同可能是中间确实没有改变过,也可能改变过了,但是有变回来了 这样的漏洞,在大多数情况下,其实没有什么影响,但是在极端情况下会引起bug

    这里的ABA问题,就好比,我今天要买一个手机,我拿到的这个手机,我无法区别出来,它是一个新机还是一个翻新机。

    新机:从出厂到现在一直没有使用过。

    翻新机:出场之后已经被买给别人了,已经被别人用了一段时间,旧了,但是被回收回来,换个壳,被当做新手机来卖。

    虽然我拿到的可能是一个翻新机,翻新机在大多数情况下,也是能用的,但是少数情况下还是可能会翻车。

    举一个例子:

    在这里插入图片描述

    解决CAS中的ABA问题:

    引入一个“版本号”,这个版本号只能能变大,不能变小,修改变量的时候,比较就不是比较变量本身了,而是比较版本号。
    在这里插入图片描述

    在这里插入图片描述

    • 这种基于版本号的方式进行多线程数据的控制,也是一种乐观锁的典型实现。
    • 版本管理工具(SVN)通过版本号来进行多人开发的协同。
    • 在数据库中应用。在数据库里面,并发的去通过事务来访问表的时候,这也会涉及到类似加锁的一些多线程操作

    3.3 CAS的相关面试题

    讲解一下你是怎样理解CAS机制的

    CAS(Compare and swap) 即“比较并交换”,向当于通过一个原子的操作,同时完成了“读取内存,比较是否相等,修改内存”这三个步骤,本质上需要CPU指令的制成

    ABA问题怎样解决

    给要修改的数据引入一个版本号,在CAS比较数据当前值和旧值的同时,也要比较版号是否符合预期。如果发现当前版本号和之前读到的版本号是一致的,就真的执行修改操作,并让版本号自增,如果发现当前版本号和之前的版本号大,就认为操作失败

    4. synchronized 中的锁优机制

    在这里我们针对的是JDK1.8,所说的锁优化机制,当然JDK的版本非常多,在这些版本变迁的过程中,很多地方都有了不少的变化,在这里JDK1.8对应的是JAVA8.

    • 从Java8以后,Java就变成半年发一次版本了。
    • 在企业中应用最多了,仍然是Java8 以及 Java11
    • Java中的版本,有的是"稳定版"(长期支持的),企业升级JDK版本是一件费力不讨好的事情,升级JDK收益,微乎其微,但是新的版本,是否可能存在BUG呢?非诚有可能的,一旦因为BUG导致公司的产品收到影响,那么相关长许愿的年终奖可能就没了。

    4.1 锁膨胀

    这里的锁膨胀/锁升级:体现了synchronized能够“自适应”这种能力

    在这里插入图片描述

    轻量级锁

    随着其他线程进入锁竞争,偏向锁状态被取消,进入轻量级锁状态(自适应的自旋锁)

    此处的轻量级锁就是通过CAS来实现的。

    自旋操作是一直让CPU空转,比较浪费CPU资源

    因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不再自旋了,也就是所谓的”自适应“

    重量级锁

    如果竞争进一步激烈,自旋不能快速获取到锁的状态,就会膨胀为重量级锁。

    此处的重量级锁就是值用到内核提供的mutex.

    • 执行加锁操作,先进入内核态
    • 在内内和态判定当前锁是否已经被占用
    • 如果该锁没有被占用,则加锁成功,并切换会用户态
    • 如果该所被占用,则加锁失败,此时线程进入锁的等待队列,挂起等待被操作系统唤醒
    • 经历了一系列的沧海桑田,这个锁被其他的线程释放了,操作系统就想起了这个挂起的线程,于是唤醒这个线程,尝试重新获得锁。

    4.2 锁粗化

    锁粗化对应着的是锁细化

    这里的粗细指的是"锁的粒度",加锁代码时涉及的范围,加锁代码的范围越大,任务锁的粒度越粗,范围越小,则认为粒度越细。

    在这里插入图片描述

    4.3 锁消除

    编译器+JVM判断锁是否可消除,如果可以,就直接消除

    什么是锁消除?

    有些程序的代码中,用到了synchronized,但是其实没有在多线程的环境下。例如StringBuffer.我们知道在StringBuffer类的源码中,是用synchronized修饰的,是线程安全的,它适合在多线程的环境下使用。

    StringBuffer sb = new StringBuffer();
    sb.append("a");
    sb.append("a");
    sb.append("a");
    sb.append("a");
    
    • 1
    • 2
    • 3
    • 4
    • 5

    此时每次append()的调用都会涉及到加锁和解锁,但是如果在单线程的环境下使用这个代码,那么那么就会大材小用,同时还会降低代码的执行效率,这些加锁解锁操作是没有必要的,白白浪费一些资源开销。

  • 相关阅读:
    登录页面案例
    人工智能:语音识别技术介绍
    python使用opencv库对比两张图片并用红框标记出不同点
    Go基础学习【3】
    【linux下centos7.9安装docker,docker-composed(root用户)】
    Avalonia 初学笔记(2):简单了解与WPF的区别
    IEC61499的理解及相关应用
    CPU使用率和负载区别及分析
    嵌入式开发:估算电池寿命的7个技巧
    Mapbox实战项目(1)-栅格图片图层实现地图方位展示
  • 原文地址:https://blog.csdn.net/qq_54883034/article/details/126200253