CAS底层原理和源码分析

Posted 我来为你写BUG

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CAS底层原理和源码分析相关的知识,希望对你有一定的参考价值。

什么是CAS

CAS 的英文是compare and set,也就是比较并交换。首先介绍一下比较重要的三个概念:initialValue(初始值),expect(期望值),update(更新值)。
初始值就是变量最初的值
期望值就是线程在操作变量之前锁期望的值
更新值就是线程要将变量修改成什么值

为了便于理解,首先大概介绍一下JAVA的内存模型。也可以看一下我之前写的一篇文章:Java关键字volatile全面解析和实例讲解.

JVM运行程序的实体是线程,而每个线程创建时由JVM分配各自的工作内存。线程的工作内存是各自私有的数据区域,互相各不影响。 java内存模型中所有的变量是存储在主内存中的,主内存是线程共享的区域,但是线程对变量的操作(读取赋值)必须在工作内存中进行, 当多个线程访问同一个变量(放在主存中)的时候,并不是直接在主存中操作变量,而是将此对象分别拷贝到每一个线程各自的工作内存中, 当其中一个线程操作了变量之后,需要将修改后的对象(也就是最新值)重新写回主内存,也要将最新值同步给其他的线程。(可见性:让其他的线程可以看到。) 线程之间的通信(传值)必须通过主内存来完成。

下面来看一个场景 :

场景:假设有一个变量num,此时它的值是1(初始值),现在线程T1将要操作变量num更新值为2021(更新值),但是T1不能直接去更新num,因为num的值可能被其他线程修改成了其它的值,所以现在T1线程需要先比较一下num的值,T1期望num的值是1(期望值),首先T1去主存中读取num的值,如果读取的值是1,与T1线程的期望值相同,则更新num的值是2021,如果在T1操作之前其他线程将num的值更新为2020,那么T1的期望值(1)与num现在的值(2020)不相等,则更新失败。

场景对应的代码是:

 		AtomicInteger num = new AtomicInteger(1);
//        boolean flag1 = num.compareAndSet(1, 2020);
        boolean flag2 = num.compareAndSet(1, 2021);
//        System.out.println(flag1); // 注释的两行,模拟另外线程更新num,返回true
        System.out.println(flag2); // 解除注释,返回false,因为上边的代码已经将num的值更新了,将两行注释,返回true,说明更新成功

当然此处只是模拟了多线程,其实上边的代码只有一个线程操作,但是也并不影响对于CAS的理解。

CAS底层原理

java内存模型中需要遵循原子操作,原子操作就是一个或多个操作为一个整体,要么都执行且不会受到任何因素的干扰而中断,要么都不执行。
而我们常用的1++并不是原子操作,而是分为了三步:1、读内存到寄存器;2、在寄存器中自增;3、写回内存。这样就会导致多线程访问的时候导致线程不安全的问题。
我们可以通过命令javap -c进行反汇编,我们可以看到i++操作其实是三条指令:
0: iconst_1
1: istore_1
2: iinc
那么如果要保证线程安全就要保证原子性,java中有一个家族:原子类,在java.util.concurrent.atomic包下。其实CAS的底层就是保证了原子性。那么他是如何保证原子性的呢?两个重要的点:UnSafe类和CAS思想(自旋)。我们以AtomicInteger这个原子类进行研究。

Unsafe

    	AtomicInteger num = new AtomicInteger(1);
        num.getAndIncrement();

上边的这行代码是AtomicInteger + 1 的操作。下边来看一下getAndIncrement方法做了什么。

/**
     * 以原子方式将当前值递增 1
     *
     * this: 当前实例对象
     * valueOffset: 内存偏移量,可以简单的理解为内存地址
     */
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

查看AtomicInteger源码可知其中一个很重要的类就是Unsafe类,也是CAS原理的核心之一,还有一个volatile类型的变量,就是让所有的线程可见。

private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            // 获取对象的偏移量
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    // volatile修饰,变量所有线程可见
    private volatile int value;
  1. 我们查看Unsafe类可以发现里边的方法都是native修饰的。Unsafe是CAS的核心类,由于java方法无法直接访问底层系统需要通过本地方法(native)方法访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据,Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存(通过内存偏移量定位),因为java中的CAS操作的执行依赖于Unsafe类的方法。

    Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的所有的方法都直接调用操作系统底层资源执行相应的任务。

  2. 变量valueOffset表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。

  3. volatile修饰,变量所有线程可见.

下面再来看一下unsafe.getAndAddInt方法。

	/**
         *
         * unsafe.getAndAddInt(this, valueOffset, 1)
         *     参数对应         var1     var2     var4
         *
         */
	public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
        	// 根据this和内存偏移量获取对象,相当于将主存中的变量拷贝到线程自己的工作内存。表示拿变量的最新值。
            var5 = this.getIntVolatile(var1, var2);
            // this.compareAndSwapInt(var1, var2, var5, var5 + var4)
            // 进行比较交换:var1,var2用于确定初始值,var5表示期望值,初始值和期望值进行比较,相等
            // 则进行加操作(var5 + var4)得到更新值,并返回true,不相等返回false。
            // while的循环条件中取反表示如果初始值和期望值相等(!true),可以进行更新,并跳出循环,
            // 如果初始值和期望值不相等(!false),不可以进行更新,一直循环直到相等。
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

CAS

CAS是一条CPU并发原语,它的功能是判断内存某个位置是否为预期值,如果是则改为新的值,这个过程是原子的。
CAS并发原语体现在JAVA语言汇总就是Unsafe类中的各个方法,调用Unsafe类中的CAS方法,JVM会帮我们实现CAS汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致性

CAS的缺点

  1. Unsafe类中的getAndAddInt方法有一个while循环,用于比较初始值和期望值,假如有很多的线程(10w个)同时访问变量,变量的值被修改了很多次,每一个线程都需要从主存读取交换,很容易导致CAS失败,就会一直尝试,如果CAS长时间一直不成功,就会给CPU带来很大的开销。
  2. 从源码中(unsafe.getAndAddInt(this, valueOffset, 1))可以看出来,this表示的当前对象,也就是说CAS只能保证一个共享变量的原子操作。对于多个共享变量需要加锁保证原子性。
  3. ABA问题。

ABA问题

定义:CAS算法实现一个重要前提是需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差类会导致数据的变化。

如上图,T1线程要将变量num更新成2021,T2线程要将变量num更新成2。
首先T1和T2分别将主存中的num读取到自己的工作内存,但是不巧的是T2此时睡眠一秒,暂停了操作变量。此时T1线程抢占了CPU资源,将num更新成了2021并写回主存,但是T1后来又不想更新,后悔了,想皮一下,就又改回原来的1,此时的T2睡醒了,查看此时主存的num还是1就将num的值变成了2并写回主存。但是T2并不知道T1已经将num的值更新了两次,甚至更多次,这就是ABA的问题了。

所以说尽管T2的CAS的操作成功,但是并不代表这个过程是没有问题的。

看一下上边过程的代码:

public class Test {

    static AtomicInteger num = new AtomicInteger(1);
    
	public static void main(String[] args) {

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");

            try {
                // 让线程等待1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (Exception e) {
                e.printStackTrace();
            }
            boolean flag = num.compareAndSet(1,2);
            System.out.println(Thread.currentThread().getName() + "已经结束," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
        },"T1").start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");
            boolean flag = false;
            flag = num.compareAndSet(1,2021);
            System.out.println(Thread.currentThread().getName() + "执行," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
            flag = num.compareAndSet(2021,1);
            System.out.println(Thread.currentThread().getName() + "执行," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
        },"T2").start();

    }
}

输出结果为:

T1开始启动
T2开始启动
T2执行,	更新是否成功true	num >>> 2021
T2执行,	更新是否成功true	num >>> 1
T1已经结束,	更新是否成功true	num >>> 2

如果理解不了的话,那你们应该都看过周星驰的电影情圣吧,乌鸦哥变猪头的那一段就是ABA问题了。感兴趣的可以看一下。

原子类引用

在java中juc包给我们提供了很多的原子类


但是这还是满足不了我们的需求,假如我们的自建的一个类User,怎么保证原子性呢?我们可以使用AtomicReference类。AtomicReference带的泛型就是我们要保证原子性的类了。

public class AtomTest {
    public static void main(String[] args) {
        User user1 = new User(11,"zhangsan");
        User user2 = new User(12,"lisi");
        AtomicReference<User> atomicReference = new AtomicReference<>();
        atomicReference.set(user1);
        System.out.println(atomicReference.compareAndSet(user1, user2) + "\\t" + atomicReference.get()); // true	User{age=12, name='lisi'}
        System.out.println(atomicReference.compareAndSet(user1, user2) + "\\t" + atomicReference.get()); // false	User{age=12, name='lisi'}

    }
}

class User {
    private int age;
    private String name;

    public User(int age, String name) {
        this.age = age;
        this.name = name;
    }

    @Override
    public String toString() {
        return "User{" +
                "age=" + age +
                ", name='" + name + '\\'' +
                '}';
    }
}

上边的代码就是AtomicReference基本使用的demo,从输出结果可以看出来,对于我们的自己创建的类也可以保证原子性。

ABA问题解决

ABA问题的解决可以添加一种机制,就是版本号,类似于时间戳。就是说变量每新增一次,版本号就 加1 ,当线程在操作变量的时候,不仅仅要比较期望值,还要比较版本号,如果期望值相同,但是版本号不同则也不允许修改。

以上边的例子来说,num的值变化为1 >> 2021 >> 1,那么添加版本号之后就是1(1)>> 2021(2) >> 1(3)(括号里边的数字为版本号),当T2访问变量的时候,T2此时的变量为1(1),1(1)和1(3)相比较期望值相同,但是版本号不同,所以T2无法更新num的值。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;

public class Test {
    static AtomicInteger num = new AtomicInteger(1);
    // initialRef 初始值 initialStamp 初始版本号
    static AtomicStampedReference<Integer> stampedNum = new AtomicStampedReference<>(1, 1);

    public static void main(String[] args) {
        System.out.println("==========================ABA问题的产生==================================");
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");

            try {
                // 让线程等待1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (Exception e) {
                e.printStackTrace();
            }
            boolean flag = num.compareAndSet(1,2);
            System.out.println(Thread.currentThread().getName() + "已经结束," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
        },"T1").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");
            boolean flag = false;
            flag = num.compareAndSet(1,2021);
            System.out.println(Thread.currentThread().getName() + "执行," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
            flag = num.compareAndSet(2021,1);
            System.out.println(Thread.currentThread().getName() + "执行," + "\\t更新是否成功" + flag + "\\tnum >>> " + num.get());
        },"T2").start();


        try {
            // 让线程等待2秒,保证上边的ABA问题已经发生一次
            TimeUnit.SECONDS.sleep(2);
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("==========================ABA问题的解决==================================");

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");

            try {
                // 让线程等待1秒,保证T3拿到版本号之后,T4拿到同一个版本号
                TimeUnit.SECONDS.sleep(1);
            } catch (Exception e) {
                e.printStackTrace();
            }

            int stamp = stampedNum.getStamp();
            System.out.println(stamp);
            System.out.println(Thread.currentThread().getName() + "\\t第一次版本号是 >>> " + stampedNum.getStamp());
            boolean flag = false;
            flag = stampedNum.compareAndSet(1, 3, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + "\\t是否修改成功 >>> " + flag + "\\t第二次版本号是 >>> " + stampedNum.getStamp() + "\\tstampedNum >>> " + stampedNum.getReference());
            stamp = stampedNum.getStamp();
            System.out.println(stamp);
            flag = stampedNum.compareAndSet(3, 1, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + "\\t是否修改成功 >>> " + flag + "\\t第三次版本号是 >>> " + stampedNum.getStamp() + "\\tstampedNum >>> " + stampedNum.getReference());

        },"T3").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始启动");
            int stamp = stampedNum.getStamp();
            try {
                // 让线程等待3秒,第一秒保证T4和T3线程拿到同一个版本号
                TimeUnit.SECONDS.sleep(3);
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName() + "\\t第一次版本号是 >>> " + stamp);
            boolean flag = false;
            flag = stampedNum.compareAndSet(1, 2, stamp, stamp + 1)以上是关于CAS底层原理和源码分析的主要内容,如果未能解决你的问题,请参考以下文章

Java底层类和源码分析系列-ConcurrentHashMap源码分析

CAS(CompareAndSwap)底层原理

JDK源码分析-AtomicInteger

《Docker 源码分析》全球首发啦!

Java并发编程之CAS二源码追根溯源

两万五千字的ConcurrentHashMap底层原理和源码分析