• 【JavaEE初阶】多线程 _ 进阶篇 _ 常见的锁策略、CAS及它的ABA问题


    ☕导航小助手☕

       🍚写在前面

            🧇一、常见的锁策略

                 🍔🍔1.1 乐观锁 vs 悲观锁

                 🧀🧀1.2 普通的互斥锁 vs 读写锁

                 🦪🦪1.3 重量级锁 vs 轻量级锁

                 🍣🍣1.4 自旋锁 vs 挂起等待锁

                 🍰🍰1.5 公平锁 vs 非公平锁

                 🍛🍛1.6 可重入锁 vs 不可重入锁

            🍜二、CAS

                 🥮🥮2.1 CAS典型应用场景

                           🥘🥘🥘2.1.1 使用CAS实现原子类

                           🥩🥩🥩2.1.2 使用CAS实现自旋锁

                 🍱🍱2.2 CAS中的ABA问题(小概率bug)

                           🍤🍤🍤2.2.1 什么是ABA问题

                           🎂🎂🎂2.2.2 ABA问题引发的bug

                           🥐🥐🥐2.2.3 解决ABA问题的办法


    写在前面

    前面所介绍的 多线程基础篇的内容,主要介绍的还是一些 和多线程相关性非常高的内容,也都是工作中经常会涉及到的问题和代码~

    而接下来的多线程进阶篇的内容,则是需要对多线程内容进行进一步的补充~

    进阶篇中的很多知识,不再是工作中常用的,但是却是在面试中常考的(俗称:面试造核弹,工作拧螺丝)~

    下面,就正式开始来学习 进阶篇的内容 ......

    一、常见的锁策略

    简单通俗的来说,锁策略就是 加锁的时候是咋加的~

    1.1 乐观锁 vs 悲观锁

    乐观锁:预测接下来锁冲突的概率不大,就会少做一点工作,成本更小~

    悲观锁:预测接下来锁冲突的概率很大,就会多做一点工作,成本更大~

    比如说,就前段时间,西安那边又有确诊的了~


    有居民就比较紧张,就在想是不是要在家里屯点菜啥的(疫情会引起封城,封城会影响买菜),提前屯点菜以备不时之需~

    这个就可以看作是 悲观锁,花费所需成本较大(买菜、运菜、放在地上......)~


    当时有居民却认为,由于之前已经有过几次确诊的经历,所以说 已经有了不少经验了,所以封城的概率比较小,不需要提前屯菜(屯了吃不完大概率会坏)~

    这个就可以看作是 乐观锁,话费所需成本更小~

    synchronized 就既是一个悲观锁,也是一个乐观锁,准确的来说 它是一个自适应锁~

    如果当前锁冲突概率不大,就以乐观锁的方式运行,往往是纯用户态执行的~

    一旦发现锁冲突概率大了,就以悲观锁的方式运行,往往要进入内核,对当前线程进行挂起等待~

    1.2 普通的互斥锁 vs 读写锁

    synchronized 就属于普通的互斥锁,两个加锁操作之间会发生竞争~

    读写锁,把加锁操作细化了 "加读锁" "加写锁" ~

    情况一:

    线程A 尝试加写锁,线程B 尝试加写锁,此时 A和B 产生竞争,和普通的锁没有区别~

    情况二:

    线程A 尝试加读锁,线程B 尝试加读锁,此时 A和B 不产生竞争,和没有加锁一样(多线程读,不涉及修改,是线程安全的)~

    这种情况是相当普遍的~

    情况三:

    线程A 尝试加读锁,线程B 尝试加写锁,此时 A和B 产生竞争,和普通的锁没有区别~

    1.3 重量级锁 vs 轻量级锁

    重量级锁:锁开销比较大,做的工作比较多~

    轻量级锁:锁开销比较小,做的工作比较小~

    重量级锁、轻量级锁 与之前所介绍的 乐观锁、悲观锁 差不多(内容上不是完全的区分开),但是最终的着力点还是不一样的~

    其中,在大部分情况下(不绝对),悲观锁 经常会是重量级锁,乐观锁 经常会是轻量级锁~

    重量级锁 主要依赖了操作系统提供的锁,使用这种锁,容易产生阻塞等待~

    轻量级锁 主要尽量的避免使用操作系统提供的锁,尽量在用户态完成功能,尽量避免用户态和内核态的切换,尽量避免挂起等待~

    同时,synchronized 是一个自适应锁,既是轻量级锁,也是重量级锁~

    锁冲突不高:轻量级

    锁冲突很高:重量级

    1.4 自旋锁 vs 挂起等待锁

    自旋锁 是轻量级锁的具体实现,挂起等待锁 是重量级锁的具体实现~

    自旋锁:当发生锁冲突的时候,不会挂起等待,会迅速来尝试看这个锁能不能获取到(更轻量,乐观锁)~

    特点:

    1. 一旦锁被释放,就可以第一时间获取到
    2. 如果锁一直不释放,就会消耗大量的

    可以看作是一个 不断的循环,可以用一个伪代码来表示:

    1. //自旋锁伪代码,不停的循环
    2. while(抢锁(lock) == 失败) {
    3. }

    挂起等待锁:发现锁冲突,就挂起等待(更重量,悲观锁)~

    特点:

    1. 一旦锁被释放,不能第一时间获取到
    2. 在锁被其他线程占用的时候,会放弃CPU资源

    synchronized 作为轻量级锁的时候,内部是 自旋锁;作为重量级锁的时候,内部是 挂起等待锁~

    1.5 公平锁 vs 非公平锁

    啥样的情况才算是公平?

    一般认为,符合 "先来后到" 这样的规则,就是公平!!!

    公平锁:多个线程等待一把锁的时候,谁先来尝试拿着一把锁,这把锁就是谁的~

    非公平锁:多个线程等待一把锁的时候,就和哪个线程先来后到没有关系,每个线程拿到锁的概率是均等的~

    synchronized 是非公平锁~

    1.6 可重入锁 vs 不可重入锁

    一个线程连续加锁两次,不会造成死锁,那么这个锁就叫做 可重入锁~

    一个线程连续加锁两次,会造成死锁,那么这个锁就叫做 不可重入锁

    1. private static void func() {
    2. //......进行一些多线程操作
    3. //第一次加锁
    4. synchronized (Demo27.class) {
    5. //第二次加锁
    6. synchronized (Demo27.class) {
    7. }
    8. }
    9. }

    如上述代码,第一次加锁能够成功,Demo27.class 处于被加锁的状态;但是 第二次加锁,由于 Demo27.class 已经是被加锁的状态了,所以就会呈现出 阻塞状态~

    要等待第一次加锁释放掉,第二次加锁才能够成功;但是 要想第一次加锁释放,那么 又必须要到第二次加锁成功之后,代码往下执行 ......

    这样就构成了一个死循环,就叫做 死锁!!!

     synchronized 属于可重入锁~

    二、CAS

    CAS 是操作系统硬件 给JVM提供的另外一种更轻量的原子操作的机制~

    准确来说,CAS是CPU提供的一条特殊的指令 —— compare and swap(比较和交换)~

    CAS 是一个原子指令~

    比较:是比较内存和寄存器的值~

    如果相等,则把寄存器和另一个值进行交换;如果不相等,就不进行操作~

    1. //CAS 的伪代码来理解它的工作流程
    2. //其中,address表示内存地址,expextValue表示一个寄存器中 用来比较的值,
    3. //expextValue表示另一个寄存器中 用来交换的值
    4. boolean CAS(address,expextValue,swapValue) {
    5. if(&address == expextValue) {
    6. &address = swapValue;
    7. return true;
    8. }
    9. return false;
    10. }
    11. //上面一系列操作都是由一个CPU指令来完成的

    2.1 CAS典型应用场景

    2.1.1 使用CAS实现原子类

    原子类:这是标准库中提供的一组类,可以让原子的进行 ++、-- 等运算~

    1. package thread;
    2. public class Demo28 {
    3. public static int count = 0;
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 50000; i++) {
    7. count++;
    8. }
    9. });
    10. Thread t2 = new Thread(() -> {
    11. for (int i = 0; i < 50000; i++) {
    12. count++;
    13. }
    14. });
    15. t1.start();
    16. t2.start();
    17. t1.join();
    18. t2.join();
    19. System.out.println("count = " + count);
    20. }
    21. }

    在之前,我们已经介绍过,最终的结果不是 10_0000 ~

    运行结果:


    我们可以使用加锁来解决这个问题,也可以使用原子类来解决这个问题:

    1. package thread;
    2. import java.util.concurrent.atomic.AtomicInteger;
    3. public class Demo28 {
    4. //public static int count = 0;
    5. public static AtomicInteger count = new AtomicInteger(0);
    6. public static void main(String[] args) throws InterruptedException {
    7. Thread t1 = new Thread(() -> {
    8. for (int i = 0; i < 50000; i++) {
    9. //count++;
    10. //这个方法相当于count++
    11. count.getAndIncrement();
    12. }
    13. });
    14. Thread t2 = new Thread(() -> {
    15. for (int i = 0; i < 50000; i++) {
    16. //count++;
    17. count.getAndIncrement();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. t1.join();
    23. t2.join();
    24. System.out.println("count = " + count);
    25. }
    26. }
    27. //和之前的不同的代码已注释,这是使用 原子类来解决问题的,没有使用加锁操作,也实现了线程安全

    运行结果:


    在Java标准库 里面提供了基于CAS所实现的 "原子类",是线程安全的~

    这些 "原子类" 通常以 Atomic 开头,对常用的 int、long等等 进行了封装,如:

    2.1.2 使用CAS实现自旋锁

    1. //自旋锁伪代码
    2. public class SpinLock {
    3. private Thread owner = null;
    4. public void lock() {
    5. //当前的owner是否为空,为空即为当前没有加锁,于是就进行交换,
    6. //把当前要给加锁的线程的值赋予owner
    7. //非空就不去进行交换,就循环继续进行,呈现自旋的状态
    8. while(!CAS(this.owner,null,Thread.currentThread())) {
    9. }
    10. }
    11. }

    当 owner 为 null 的时候 CAS 才能成功,循环才能结束~

    当 owner 为非null,这说明当前的锁已经被其他线程给占用了,因此 就需要继续循环(自旋)~

    2.2 CAS中的ABA问题(小概率bug)

    2.2.1 什么是ABA问题

    ABA问题可以单纯的这样理解:如果你去买一个手机,那么你无法区分 它是一个新机,还是一个翻新机(二手的、外面包装和新机一样)~

    类似的,在CAS里面,也无法区分,数据始终就是A;还是数据从 A 变成 B,之后又变回了 A ~

    如果是前者,那么一点问题都没有;但是如果是后者,那么 CAS 就会有一定的概率引发 bug(极端情况下的小概率事件) ~ 

    2.2.2 ABA问题引发的bug

    这里结合一个具体的例子,来介绍ABA问题引发的bug~

    假设滑稽老铁有 1000 存款,此时想要从 ATM机 上取走 500(ATM机 按照CAS的方式来进行操作)~

    取钱的时候,按下取款按钮,就会触发一个 "取钱的线程",但是 滑稽老铁手一滑,连续按了两下(即 产生了两个线程)~ 


    符合预期的方式(即使手滑了多点了两次,仍然只取走了500):


    但是,怕就怕在这期间 突然又来了一个线程(比如说 滑稽老铁的一个朋友,此时正好向滑稽老铁转了500)~

     

    这时候,就扣除了两次小钱钱了,这个就是典型的ABA问题(极端情况下的小概率问题)~

    此时,线程2不知道 当前的1000,始终是1000;还是 1000 -> 500 -> 1000 ~

    2.2.3 解决ABA问题的办法

    正经的解决ABA问题的办法,是想办法获取到中间过程 —— 引入一个 "版本号" 来解决~

    在上述的例子当中,CAS是比较的是 余额,余额相同,就可以进行修改(余额是可以变大和变小,所以就会出现ABA问题)~

    但是,如果换成 "版本号",并且规定 "版本号" 只能增不能减,那么就不会出现ABA问题 ~

    当然,解决ABA问题的办法肯定不止这一种,这里只是列举了一种非常典型的办法 ~ 

     

    好了,这篇博客到这里就已经结束了~

    本篇博客主要介绍的是 各种常见的锁策略,以及CAS、CAS中的小概率bug —— ABA问题,并且介绍了ABA问题的解决方案 ~

     如果感觉这一篇博客对你有帮助的话,可以一键三连走一波,非常非常感谢啦 ~

  • 相关阅读:
    Java IO流处理 面试题汇总
    某华为外包功能测试花四个月时间学习自动化测试成功拿下25k15薪offer
    Eclipse-MAT的插件介绍使用
    什么是Quartz
    今晚8点,iPhone15开启预售
    行车记录仪E-mark认证要如何办理?
    [python][flask] Flask 入门(以一个博客后台为例)
    对强缓存和协商缓存的理解
    【数据结构】选择排序 & 堆排序(二)
    猿创征文|产品工具-面向综合效能提升的工具库
  • 原文地址:https://blog.csdn.net/qq_53362595/article/details/126485856