• 多线程进阶(CAS和synchronized原理)


    一、CAS

    1.1 CAS是什么

    CAS: 全称Compare and swap,字面意思:“比较并交换”,
    是操作系统/硬件,给JVM提供的另外一个更轻量的原子操作的机制,是CPU提供的一个特殊指令
    一个 CAS 涉及到以下操作:

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

    1. 比较 A 与 V 是否相等。(比较)
    2. 如果比较相等,将 B 写入 V。(交换)
    3. 返回操作是否成功。

    CAS的伪代码:
    下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解CAS的工作流程

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

    1.2 CAS是怎么实现的

    针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

    java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作; unsafe 的 CAS 依赖了的是 jvm
    针对不同的操作系统实现的 Atomic::cmpxchg; Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu
    硬件提供的 lock 机制保证其原子性。

    简而言之,是因为硬件予以了支持,软件层面才能做到。

    1.3 CAS的应用

    1.3.1 实现原子类

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

    【实现源自类的伪代码】:

    class AtomicInteger {
        private int value;
        public int getAndIncrement() {
            int oldValue = value;
            while ( CAS(value, oldValue, oldValue+1) != true) {
                oldValue = value;
           }
            return oldValue;
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    【使用示例】:

    package thread;
    
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Test1 {
    
        public static AtomicInteger count=new AtomicInteger();
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(()->{
                for(int i=0;i<50000;i++){
                    count.getAndIncrement();
                }
            });
            Thread t2=new Thread(()->{
                for(int i=0;i<50000;i++){
                    count.getAndIncrement();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("count:"+count);
        }
    }
    执行结果:
    count:100000
    
    • 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

    通过CAS实现了原子的自增

    【原子类是如何保证线程安全的】

    在这里插入图片描述
    CAS 是直接读写内存的, 而不是操作寄存器.
    CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.

    【补充】:

    一个线程T在CAS操作的时候,其他线程无法访问V指向的内存地址,并且线程T一旦更新了V指向的内存中的值,其他所有线程的V指向内存都变得无效

    1.3.2 实现自旋锁

    基于 CAS 实现更灵活的锁, 获取到更多的控制权.
    【自旋锁伪代码】:

    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;
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    1.4 CAS的ABA问题

    1.4.1 什么是ABA问题

    假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
    接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要

    • 先读取 num 的值, 记录到 oldNum 变量中.
    • 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.

    但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A

    线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这 个时候 t1 究竟是否要更新 num 的值为 Z 呢?

    这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.

    1.4.2 ABA问题带来的bug

    【场景】:

    假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 但由于手滑连续按了两次,取款机创建了两个线程, 并发的来执行 -50 操作.
    我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
    如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.

    【正常的过程】:

    (1)存款100,线程1读取到当前内存值100,期望更新为50,线程2获取到当前内存之100,期望更新为50
    (2)线程1扣款成功,存款改成了50,线程2阻塞等待
    (3)轮到线程2执行,发现存款为50,和之前读到的100不同,执行失败

    【产生bug的过程】:

    (1)存款100,线程1读取到当前内存值100,期望更新为50,线程2获取到当前内存之100,期望更新为50
    (2)线程1扣款成功,存款改成了50,线程2阻塞等待
    (2)在线程2执行之前,滑稽的朋友正好给滑稽转了50元,此时存款变回100
    (3)线程2读取存款为100,和之前读到的值相同,执行扣款操作,这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼!!

    1.4.3 ABA问题解决方案

    给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期

    • CAS 操作在读取旧值的同时, 也要读取版本号
    • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
    • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)

    这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的手机记为版本1,以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意这是翻新机, 就买. 如果买家在意, 就可以直接略过.

    【对比上面取前的例子】:
    为了解决 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, 版本小于当前版本, 认为操作失败.

    1.5 相关面试题

    1.讲解下你自己理解的 CAS 机制

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

    2.ABA问题怎么解决?

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

    二、synchronized原理

    2.1 基本特点

    结合上面的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):

    1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
    2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
    3. 实现轻量级锁的时候大概率用到的自旋锁策略
    4. 是一种不公平锁
    5. 是一种可重入锁
    6. 不是读写锁

    2.2 加锁工作过程(锁升级)

    JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
    (1)偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态

    偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁,
    那么就不用进行其他同步操作了(避免了加锁解锁的开销) 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,
    很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态. 偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销. 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

    【举例理解偏向锁】

    假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证 结婚(避免了高成本操作),也可以一直幸福的生活下去. 但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作 完成了, 让女配死心.

    (2)轻量级锁:随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
    此处的轻量级锁就是通过 CAS 来实现.

    自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
    因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 “自适应”

    (3)重量级锁:如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
    此处的重量级锁就是指用到内核提供的 mutex .

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

    【注意】:

    并不是每次加锁都要经历这4个锁状态,在任意锁状态都有释放锁的可能

    上述的锁升级属于synchronized的一种优化操作,下面介绍synchronized的另外两种优化操作

    2.3 锁消除

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

    【锁消除的场景】

    有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

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

    此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的,
    白白浪费了一些资源开销.

    2.4 锁粗化

    一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

    锁的粒度:synchronized包含的代码范围是大还是小,范围越大,粒度越粗,范围越小,粒度越细
    在这里插入图片描述
    实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM就会自动把锁粗化, 避免频繁申请释放锁.

    锁的粒度细了,能够更好的提高线程的并发,但也会增加加锁解锁的次数

    2.5 相关面试题

    (1)什么是偏向锁

    偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线程参与竞争锁, 那么就不会真正执行加锁操作,从而降低程序开销. 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.

    (2)synchronized 实现原理 是什么?

    上述的synchronized的实现原理和之前线程状态和线程安全中介绍的synchronized

  • 相关阅读:
    首届COLM顶会:见证NLP领域的又一里程碑
    3、Python量化交易-股票数据预处理&跌幅买卖收益分析
    【Linux】package ‘python-yaml‘ has no installation candidate 如何解决
    中断机制-通过interrupt实现线程中断停止
    智能合约升级原理01---起源
    QGis软件 —— 9、QGis - 由点绘制热力图来模拟人流量(绘制点方式、excel导入数据方式)
    0分钟!搞懂计算机内存实现原理
    【学习笔记】CAN总线冲突仲裁与重发机制
    springboot之三:原理分析之自动配置condition
    某快递公司Java一面
  • 原文地址:https://blog.csdn.net/m0_60631323/article/details/126517169