【JUC进阶】一文深度讲解CAS

1. 什么是CAS

在JDK5之前,可以通过synchronized或Lock来保证高并发的业务场景下的线程安全,但是synchronized或Lock都属于互斥锁的方案,互斥锁所带来的比较重量级、加锁、释放锁都会带来性能上的损耗。

于是,就出现了CAS机制实现无锁的解决方案,CAS和乐观锁类似,CAS可以达到非阻塞同步的方式来保证线程安全。

CAS是现代CPU广泛支持的一种对内存中共享数据进行操作的一种特殊指令这个指令可以对内存中的共享数据做原子的读写操作。

CAS的核心思想就是让CPU比较内存中某个值是否和预期值相同,如果相同则将这个值设置为新值,如果不相同则不做更新操作。

image-20211231142156322

CAS的基本流程如上所示:

  1. 程序一开始读取某块内存的值为E,现要将数据E更新为数据V
  2. 将新的数据V写入这块内存之前,首先比较一下当前这个内存的值是否等于E
    1. 如果等于,那么说明没有其他线程修改过这个内存的值(这样说法是错的,因为还存在ABA问题),进行更新操作。
    2. 如果不等于,说明其他线程将这个内存的值修改了,则不进行更新操作。

上述这些操作都是由CPU指令来保证原子性的。


2. CAS的工作原理

在CAS底层的实现原理,实际上是通过Unsafe类和自旋锁来实完成的。

AtomicInteger源码中,可以看见都是通过Unsafe类来实现更新的。

Unsafe类是JDK内部常用工具栏,**它通过暴露一些Java意义上说“不安全”的功能给Java层代码,来让JDK能够更多的使用Java代码来实现一些原本是平台相关的、需要使用native语言(例如C或C++)才可以实现的功能。**该类不应该在JDK核心类库之外使用,这也是命名为Unsafe(不安全)的原因。

public class AtomicInteger extends Number implements java.io.Serializable {
    
    
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
    static {
    
    
        try {
    
    
            // 用于获取value字段相对当前对象的“起始地址”的偏移量
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) {
    
     throw new Error(ex); }
    }

    private volatile int value;

    //返回当前值
    public final int get() {
    
    
        return value;
    }

    //递增加detla
    public final int getAndAdd(int delta) {
    
    
        // 1、this:当前的实例 
        // 2、valueOffset:value实例变量的偏移量 
        // 3、delta:当前value要加上的数(value+delta)。
        return unsafe.getAndAddInt(this, valueOffset, delta);
    }

    //递增加1
    public final int incrementAndGet() {
    
    
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
    ...
}

Unsafe里关于对象字段访问的方法把对象布局抽象出来,它提供了objectFieldOffset()方法用于获取某个字段相对Java对象的“起始地址”的偏移量,也提供了getInt、getLong、getObject之类的方法可以使用前面获取的偏移量来访问某个Java对象的某个字段。在AtomicIntegerstatic代码块中便使用了objectFieldOffset()方法。

Unsafe类的功能主要分为内存操作、CAS、Class相关、对象操作、数组相关、内存屏障、系统相关、线程调度等功能。这里我们只需要知道其功能即可,方便理解CAS的实现,注意不建议在日常开发中使用。

public final int incrementAndGet() {
    
    
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

上述代码等于是AtomicInteger调用UnSafe类的CAS方法,JVM帮我们实现出汇编指令,从而实现原子操作。

public final int getAndAddInt(Object var1, long var2, int var4) {
    
    
    int var5;
    do {
    
    
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

在上面的getAndAddInt方法中三个参数

  1. 第一个参数表示当前对象,也就是new的那个AtomicInteger对象
  2. 第二个表示内存地址;
  3. 第三个表示自增步伐,在AtomicInteger#incrementAndGet中默认的自增步伐是1。

getAndAddInt方法中,会首先将当前对象主内存的值赋值给val5,然后进入while循环,判断当前对象此刻主内存中的值是否等于val5,如果是,那么就更新内存中的值,否则继续循环,重写获取val5的值(这里是针对上面的incrementAndGet自增来说的)

这样的话即使有其他线程修改了内存的值,CAS会比对内存值是否和预期值相同从而判断是否要更新内存的值。同时因为上面的compareAndSwapInt是一个native方法,这个方法汇编之后是CPU原语指令,原语指令是连续执行不会被打断的,所以可以保证原子性。

像上面getAndAddInt方法中的do while循环操作,就是所谓的自旋,如果预期值和主内存的值不一样,则需要重新获取主内存的值,这就是自旋。

CAS虽然解决了多线程下的安全问题,但是存在一个非常明显的缺点,那就是当内存使用自旋进行CAS更新的时候(while循环CAS更细你,如果更新失败,则),如果长时间不成功,对CPU来说将会造成极大的开销。


3. CAS的缺点

CAS虽然高效实现了原子性的操作,但是存在下面三个缺点:

  1. 循环时间长,开销大
  2. 只能保证一个共享变量的原子操作
  3. ABA问题

3.1 循环时间长开销大

前面也提到了Unsafe的实现使用了自旋锁的机制,如果当前CAS操作失败,就需要循环进行CAS,如果长时间不成功,那么就会造成CPU极大的开销。


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

CAS是一个针对一个共享变量使用的机制,可以保证原子性,但是如果存在多个共享变量,或者需要一整块代码的逻辑都保证线程安全,那么CAS就无法保证原子操作了,这时候就需要考虑使用synchronized或Lock这些重量级锁来保证线程安全了。

或者将多个共享变量合并程一个共享变量从而进行CAS操作。


3.3 ABA问题

虽然使用CAS可以实现非阻塞式的原子性操作,但是会产生ABA问题

  1. 线程A在共享变量读到的值为A
  2. 线程A被抢夺了,线程B执行
  3. 线程B将数据A改为B,再改为A,此时被线程A抢占
  4. 线程A此时看见共享变量的值没有改变,于是继续执行

虽然线程A以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free的算法中的,CAS首当其冲,因为CAS判断的是指针的地址。如果这个地址被重用了呢,问题就很大了(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)。

ABA问题的解决思路就是使用版本号:在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

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


参考:一篇搞定CAS,深度讲解,面试实践必备 - 腾讯云开发者社区-腾讯云 (tencent.com)

猜你喜欢

转载自blog.csdn.net/weixin_51146329/article/details/129658550