05.CAS

59 阅读7分钟

CAS

1.介绍

CAS的英文全称为Compare and Swap,翻译成中文为“比较并交换”。JDK5所增加的JUC并发包,对操作系统底层CAS原子操作进行了封装,为上层Java程序提供了CAS操作的API

在JUC包中,如AQS组件,Atomic原子操作类等等都是基于CAS实现的,可以说CAS是整个JUC的基石之一

1749622091452.png

2.Unsafe与CAS

Unsafe中提供的CAS方法

/**
 * @param o:需要操作的字段所处的对象
 * @param offset:需要操作的字段的偏移量(相对的,相对于对象头)
 * @param expected:期望值(旧的值)
 * @param update:更新值(新的值)
 * @return true 更新成功 | false 更新失败
 */
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
 
public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

方法含义

Unsafe提供的CAS方法包含四个操作数:字段所处的对象、字段内存位置、预期原值及新值。在执行Unsafe的CAS方法的时候,这些方法首先将内存位置的值与预期值(旧的值)比较,如果相匹配,那么处理器会自动将该内存位置的值更新为新值,并返回true;如果不相匹配,处理器不做任何操作,并返回false,这个行为是原子的不会被中断

Unsafe的CAS操作会将第一个参数(对象的指针、地址)与第二个参数(字段偏移量)组合在一起,计算出最终的内存操作地址

1749622255078.png

获取偏移量

Unsafe提供的偏移量相关方法
/**
 * 静态字段的偏移量,取静态属性 Field 在 Class 对象中的偏移量
 * @param field 需要操作字段的反射
 * @return 字段的偏移量
 */
public native long staticFieldOffset(Field field);
/**
 * 实例属性的偏移量,在 Object 实例中的偏移量
 * @param field 需要操作字段的反射
 * @return 字段的偏移量
 */
public native long objectFieldOffset(Field field);
获取一个实例属性的偏移量案例代码
static {
    try{
        //获取反射的 Field 对象
        Field field = OptimisticLockingPlus.class.getDeclaredField("value");
        //取得内存偏移
        valueOffset = unsafe.objectFieldOffset(field);
    }catch(Exception e){
        e.printStackTrace();
    }
}

3.使用无锁编程实现轻量级安全自增

介绍

  1. 自增i++操作是非线程安全的
  2. 使用synchronized也可以完成但是在并发高的时候性能差,因为重量级锁
  3. 无锁编程是线程安全的,可以使用无锁编程实现高性能的自增

代码

public class OptimisticLockingPlus {
    // 使用 volatile 保证线程可见性,自增值
    private volatile int value;
    // Unsafe
    private static final Unsafe unsafe = getUnsafe();
    // value 的内存偏移(相对与对象头部的偏移,不是绝对偏移)
    private static final long valueOffset;
    
    static {
        try{
            //取得 value 属性的内存偏移
            valueOffset = unsafe.objectFieldOffset(OptimisticLockingPlus.class.getDeclaredField("value"));
        }catch(Exception e){
            e.printStackTrace();
        }
    }
    
    /**
     * 通过 CAS 原子操作,进行“比较并交换”
     */
     public final boolean unSafeCompareAndSet(int oldValue, int newValue) {
         return unsafe.compareAndSwapInt(this, valueOffset,oldValue ,newValue );
     }
     
     
    /**
     * 使用无锁编程实现:安全的自增方法
     */
     public void selfPlus() {
        int oldValue = value;
        do {
            oldValue = value;
        } while(!unSafeCompareAndSet(oldValue, oldValue + 1));
     }
}

字段偏移量的计算

valueOffset实际值为12,原因如下图:

41076cf33e324aeea9653116187b6fa5.png

4.三大问题

多变量问题

介绍

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,CAS就无法保证操作的原子性

解决方案

一个比较简单的规避方法为:把多个共享变量合并成一个共享变量来操作

JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个AtomicReference实例后再进行CAS操作。比如有两个共享变量 i=1、j=2,可以将二者合并成一个对象,然后用CAS来操作该合并对象的AtomicReference引用

开销问题

问题

在争用激烈的场景下,会导致大量的CAS空自旋。比如说,在大量的线程同时并发修改一个AtomicInteger时,可能有很多线程会不停的自旋,甚至有的线程会进入一个无限重复的循环中

大量的CAS空自旋会浪费大量的CPU资源,大大降低了程序的性能

解决方案
  1. 限制重试次数:不能让线程一直重试,我们可以设置一个阈值,超过这个阈值,线程可能需要放弃竞争这个资源,一次或多次自旋失败后进入阻塞状态等待唤醒,如Java的AQS
  2. 以空间换时间:让多个线程去CAS多个值,获取值的时候合并,体现了空间换时间与热点分离的思想,这种思想在Java中的实现就是LongAdder
  3. 退避策略:在高并发情况下,如果CAS操作失败,我们不用立刻进行重试,而是让线程暂时退避,比如休眠一段时间,这样可以减少竞争线程的数量。如果失败次数多了,我们可以逐渐增加休眠时间,比如失败小于10次,休眠500毫秒,10-20次休眠1000毫秒这样递增
注意

在一般情况下如果在高并发场景下,其实不是很建议使用CAS,还不如直接使用锁来的直接,有可能效率会更高

ABA问题

举例

CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:一个变量原来的值为A,后来被某个线程改为B,再后来又被改回A。在这种情况下,使用CAS进行比较时,会发现变量的值仍然为A,从而认为这个变量没有被修改过,导致CAS操作会成功。然而,实际上这个变量经历了A->B->A的变化,其状态已经发生了变化,可能会导致一些逻辑上的错误

解决方案

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候将版本号加1,那么操作序列 A==>B==>A的就会变成A1==>B2==>A3,如果将A1当做作为A3的预期数据时,就会操作失败

JDK提供了两个类AtomicStampedReference、AtomicMarkableReference来解决ABA问题。比较常用的是AtomicStampedReference类,该类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,以及当前印戳是否等于预期印戳,如果全部相等,则以原子方式将引用和印戳的值一同设置为新的值

5.是否加锁

从Java层面看,CAS确实是无锁操作,但是在底层操作系统层面呢?

CAS提作的原子性是由CPU提供保障的,依赖干硬件层面提供的原子指令来实现对共享数据的并发访问和惨改、例如、比较常见的x86架构的CPU来说、其实CAS操作通常使用cmpxchg指令实现的,这个指令的工作流程如下:

  1. 比较累加器(例如EAX寄存器)中的值与指定内存位置的值
  2. 如果两者相等,指令将一个新值存入这个内存位置
  3. 指令返回操作前该内存位置的旧值

为了保证指令的原子性,处理器可能会使用锁定前缀(LOCK#)来锁定总线,防止其他处理器同时访问相同的内存地址

但是,锁总线的成本太高了,我们想下,数据存储在内存中,在同一时刻我们只需保证对某个内存地址的操作是原子性即可,根本没有必要锁住总线。所以,更加现代化的处理器采用缓存加锁,通过缓存锁定来保证原子性

但是,无论是使用总线加锁,还是缓存加锁,从操作系统层面,CAS还是加锁了的