【CAS】CAS原理|乐观锁

前言

CAS的原理

CAS的缺陷

1.ABA问题

2.循环时间长开销大

3.只能保证一个共享变量的原子操作

CAS开销

CAS算法在JDK中的应用


前言

这个视频解释很不错:《大厂面试题:CAS原理怎么回答比较好》

好看视频-轻松有收获

CAS的原理

https://www.bilibili.com/video/BV1JT421C75D

1、什么是CAS?

CAS:Compare and Swap,即比较再交换。

CAS 是一种思想,是一种实现算法。

2、CAS算法理解

对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS 操作包含三个主要参数:内存地址 V、旧的预期值 A 和新值 B。CAS 的工作原理是:

  1. 比较内存地址 V 当前的值是否等于 A。
  2. 如果等于,则将内存地址 V 的值更新为 B,并返回 true。
  3. 如果不等于,不做更新,并返回 false。

整个过程是原子的,意味着它在执行过程中不会被中断。假设是CPU1执行,中间不会被其他线程或操作打断,哪怕其他线程在其他CPU上的。

那么CPU1上的线程是通过什么原理确保不被CPU2上的线程打断的呢?毕竟他们是两个独立的CPU:

当CPU执行CAS操作时,它利用硬件提供的原子指令来确保该操作的原子性。这通常涉及以下几个方面的保障:

  1. 原子指令:CPU提供的原子指令在执行过程中不会被中断。这意味着一旦CPU开始执行原子指令,它将完成该指令的全部操作,然后才会响应其他操作或中断。

  2. 缓存一致性协议:在多核处理器中,每个核心都有自己的缓存。缓存一致性协议(如MESI或MOESI)确保了当一个核心修改了共享内存中的值时,其他核心能够感知到这一变化,并更新它们自己的缓存。这样,即使多个线程在不同的核心上并行执行,它们也能看到一致的内存状态。

  3. 总线锁定或缓存锁定(在某些情况下):虽然现代处理器尽量避免使用总线锁定或缓存锁定,但在某些情况下,为了确保操作的原子性,处理器可能会对总线或缓存行进行短暂锁定。这种锁定确保了在锁定期间,没有其他核心能够访问被锁定的内存区域。

既在多线程环境中,CAS操作利用硬件提供的原子指令和缓存一致性协议来确保其在执行过程中不会被其他线程的操作打断。

CAS比较与交换的伪代码可以表示为:

代码中的synchronized 使得CAS操作利用硬件提供的原子指令和缓存一致性协议来确保函数中的代码的原子性,也就是整个函数是一个完整的事务。

注:t1,t2线程是同时更新同一变量56的值

因为t1和t2线程都同时去访问同一变量56,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,所以t1和t2线程的预期值都为56。

假设t1在与t2线程竞争中,线程t1能去更新变量的值,而其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败了(想改的值不再是原来的值)。

就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

示例

假设我们有一个共享变量 counter,我们希望以线程安全的方式对其执行递增操作。我们可以使用 CAS 来实现这一目标。以下是一个简化的伪代码示例,展示了 CAS 的基本用法:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter(0);

void increment() {
    int expected;
    int desired;

    do {
        expected = counter.load();     // 读取当前值
        desired = expected + 1;       // 计算新值
    } while (!counter.compare_exchange_weak(expected, desired));
    // 如果 compare_exchange_weak 返回 false,表示在准备更新时,
    // counter 的值已经被其他线程修改了,因此需要重试。
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter.load() << std::endl;
    return 0;
}

解释

  1. std::atomic<int> counter(0);
    • 定义一个原子类型的变量 counter,并初始化为 0。
  2. increment 函数
    • expected 用于存储 counter 的当前值。
    • desired 用于存储 counter 增加后的新值。
    • 使用 do-while 循环尝试执行 CAS 操作:
      • counter.load(); 读取当前 counter 的值到 expected
      • expected + 1; 计算新值 desired
      • counter.compare_exchange_weak(expected, desired); 尝试将 counter 的值从 expected 更新为 desired
        • 如果 counter 的值在读取后没有被其他线程修改(即 expected 仍然是 counter 的当前值),则更新成功,并返回 true,退出循环。
        • 如果 counter 的值已经被其他线程修改,则更新失败,并返回 false,继续循环重试。
  3. 主函数
    • 创建两个线程 t1 和 t2,它们都调用 increment 函数。
    • 等待两个线程执行完毕(join)。
    • 输出 counter 的最终值。

通过这种方式,我们可以在不使用传统锁的情况下,实现线程安全的递增操作。

CAS的缺陷

1.ABA问题

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但可能存在潜藏的问题。如下所示:

现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B: head.compareAndSet(A,B); 在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,此时堆栈结构如下图,而对象B此时处于游离状态:

此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,所以此时的情况变为:

其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。

解决方法(AtomicStampedReference 带有时间戳的对象引用):

      从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(

V expectedReference,//预期引用

V newReference,//更新后的引用

int expectedStamp, //预期标志

int newStamp //更新后的标志

)

2.循环时间长开销大

自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3.只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

CAS与Synchronized的使用情景:   

    1、对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。

    2、对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

   补充: synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

 concurrent包的实现:

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

      1. A线程写volatile变量,随后B线程读这个volatile变量。

      2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。

      3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。

      4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

  Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

      1. 首先,声明共享变量为volatile;  

      2. 然后,使用CAS的原子条件更新来实现线程之间的同步;

      3. 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AbstractQueuedSynchronizer(AQS抽象的队列同步器),atomic类,非堵塞数据结构这些concurrent包中的基础类都是使用这种模式实现的,而concurrent包中的高层类又是依赖于这些基础类实现的。concurrent包的整体示意图如下:

JVM中的CAS(堆中对象的分配): 

     Java调用new object()会创建一个对象,这个对象会被分配到JVM的堆中。那么这个对象到底是怎么在堆中保存的呢?

     首先,new object()执行的时候,这个对象需要多大的空间,其实是已经确定的,因为java中的各种数据类型,占用多大的空间都是固定的(对其原理不清楚的请自行Google)。那么接下来的工作就是在堆中找出那么一块空间用于存放这个对象。 
在单线程的情况下,一般有两种分配策略:

1. 指针碰撞:这种一般适用于内存是绝对规整的(内存是否规整取决于内存回收策略),分配空间的工作只是将指针像空闲内存一侧移动对象大小的距离即可。

2. 空闲列表:这种适用于内存非规整的情况,这种情况下JVM会维护一个内存列表,记录哪些内存区域是空闲的,大小是多少。给对象分配空间的时候去空闲列表里查询到合适的区域然后进行分配即可。

    但是JVM不可能一直在单线程状态下运行,那样效率太差了。由于再给一个对象分配内存的时候不是原子性的操作,至少需要以下几步:查找空闲列表、分配内存、修改空闲列表等等,这是不安全的。解决并发时的安全问题也有两种策略:

1. CAS:实际上虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。

2. TLAB:如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。 
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来进行配置(jdk5及以后的版本默认是启用TLAB的)。

CAS开销

前面说过了,CAS(比较并交换)是CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。但CAS就没有开销了吗?不!有cache miss的情况。这个问题比较复杂,首先需要了解CPU的硬件体系结构:

上图可以看到一个8核CPU计算机系统,每个CPU有cache(CPU内部的高速缓存,寄存器),管芯内还带有一个互联模块,使管芯内的两个核可以互相通信。在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存连接起来。数据以“缓存线”为单位在系统中传输,“缓存线”对应于内存中一个 2 的幂大小的字节块,大小通常为 32 到 256 字节之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。

比如,如果 CPU0 在对一个变量执行“比较并交换”(CAS)操作,而该变量所在的缓存线在 CPU7 的高速缓存中,就会发生以下经过简化的事件序列:

CPU0 检查本地高速缓存,没有找到缓存线。

请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,没有找到缓存线。

请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6和 CPU7 所在的管芯持有。

请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存线。

CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓存线。

CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。

系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。

CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。

CPU0 现在可以对高速缓存中的变量执行 CAS 操作了

以上是刷新不同CPU缓存的开销。最好情况下的 CAS 操作消耗大概 40 纳秒,超过 60 个时钟周期。这里的“最好情况”是指对某一个变量执行 CAS 操作的 CPU 正好是最后一个操作该变量的CPU,所以对应的缓存线已经在 CPU 的高速缓存中了,类似地,最好情况下的锁操作(一个“round trip 对”包括获取锁和随后的释放锁)消耗超过 60 纳秒,超过 100 个时钟周期。这里的“最好情况”意味着用于表示锁的数据结构已经在获取和释放锁的 CPU 所属的高速缓存中了。锁操作比 CAS 操作更加耗时,是因深入理解并行编程

为锁操作的数据结构中需要两个原子操作。缓存未命中消耗大概 140 纳秒,超过 200 个时钟周期。需要在存储新值时查询变量的旧值的 CAS 操作,消耗大概 300 纳秒,超过 500 个时钟周期。想想这个,在执行一次 CAS 操作的时间里,CPU 可以执行 500 条普通指令。这表明了细粒度锁的局限性。

以下是cache miss cas 和lock的性能对比:

CAS算法在JDK中的应用

在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。

Java 1.7中AtomicInteger.incrementAndGet()的实现源码为:

由此可见,AtomicInteger.incrementAndGet的实现用了乐观锁技术,调用了类sun.misc.Unsafe库里面的 CAS算法,用CPU指令来实现无锁自增。所以,AtomicInteger.incrementAndGet的自增比用synchronized的锁效率倍增。



作者:Java技术栈
链接:https://www.jianshu.com/p/21be831e851e
 


 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值