Java——聊聊JUC中的CAS原理

Posted 宋子浩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java——聊聊JUC中的CAS原理相关的知识,希望对你有一定的参考价值。

文章目录:

1.引言

2.引出CAS

3.什么是CAS?

4.AtomicReference简单应用

5.CAS与自旋锁

6.CAS两大缺点

6.1 循环时间长开销很大

6.2 ABA问题


1.引言

说到CAS,大家肯定都会想到原子类,其实原子类的基础就是CAS,CAS的基础就是自旋锁、Unsafe类,Unsafe类的基础就是底层C++代码、操作系统CPU的原语指令。所以是越来越底层,那么这篇文章主要给大家分享CAS的原理、同时简单介绍一下AtomicInteger、AtomicStampedReference的使用,更详细的原子类我会在后面的文章中讲解。


2.引出CAS

在没有CAS的时候,多线程环境不使用原子类保证线程安全i++(基本数据类型)

public class Demo 
    volatile int number = 0;

    //读取
    public int getNumber() 
        return number;
    

    //写入加锁保证原子性
    public synchronized void setNumber() 
        number++;
    

有了CAS之后,我们在多线程情况下使用原子类保证线程安全(基本数据类型)。

public class Demo 
    AtomicInteger atomicInteger = new AtomicInteger();

    public int getAtomicInteger() 
        return atomicInteger.get();
    

    public void setAtomicInteger() 
        atomicInteger.getAndIncrement(); //先读再加最后赋值
    


3.什么是CAS?

compare and swap的缩写,中文翻译成比较并交换,实现并发算法时常用到的一种技术。它包含三个操作数——内存位置、预期原值及更新值。

  1. 执行CAS操作的时候,将内存位置的值与预期原值比较。
  2. 如果相匹配,那么处理器会自动将该位置值更新为新值。
  3. 如果不匹配,处理器不做任何操作,多个线程同时执行CAS操作只有一个会成功。

CAS有3个操作数,位置内存值V,旧的预期值A,要修改的更新值B
当且仅当旧的预期值A和内存值V相同时,将内存值V修改B,否则什么都不做或重来。    当它重来重试的这种行为就是——自旋!!!

下面写一个单线程下最简单的 CAS 案例代码:↓↓↓

package com.szh.demo.cas;

import java.util.concurrent.atomic.AtomicInteger;

public class CASDemo1 
    public static void main(String[] args) 
        AtomicInteger atomicInteger = new AtomicInteger(10);
        System.out.println(atomicInteger.compareAndSet(10, 512) + ", " + atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(10, 1024) + ", " + atomicInteger.get());
    

对运行结果而言,其实很容易理解,第一次 compareAndSwap 的时候,会进行比较(刚开始我从内存中拿到的值是10,这个10就是预期的旧值,即将更新的时候,会与该内存位置的值进行比较,如果内存值还是10,那么就更新为新值512),所以第一次肯定比较并交换成功。  第二次由于内存值已经被改为512了,与预期的旧值10自然不相等,所以CAS失败。 

CAS是JDK提供的非阻塞原子性操作,它通过硬件保证了比较-更新的原子性。它是非阻塞的且自身原子性,也就是说这玩意效率更高且通过硬件保证,说明这玩意更可靠。

CAS是一条CPU的**原子指令**(`cmpxchg指令`),不会造成所谓的数据不一致问题,`Unsafe`提供的`CAS方法`(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。

执行cmpxchg指令的时候,会判断当前系统是否为多核系统,如果是就**给总线加锁* *,**只有一个**线程会对总线加锁**成功* *,加锁成功之后会执行cas操作,也就是说CAS的原子性实际上是**CPU实现的* *, 其实在这一点上还是有排他锁的,只是比起用synchronized, 这里的排他时间要短的多, 所以在多线程情况下性能会比较好。

 

  • o:表示要操作的对象
  • offset:表示要操作对象中属性地址的偏移量
  • expected:表示需要修改数据的期望的值
  • x:表示需要修改为的新值 

Unsafe:它是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门 ,基于该类可以直接操作特定内存\\ 的数据 。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。

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

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

而针对value值的 value++ 操作,如何保证操作完成之后,所有线程都能知道并获取呢?  就是 变量value用volatile修饰 的原因。

CAS的全称为Compare-And-Swap,它是一条CPU并发原语。
它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令 。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语 ,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

我们知道i++线程不安全的,那atomicInteger.getAndIncrement() 是怎么做的呢?

 

假设线程A和线程B两个线程同时执行getAndAddInt操作(分别跑在不同CPU上):

  1. AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据JMM模型,线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存。
  2. 线程A通过getIntVolatile(o, offset)拿到value值3,这时由于某些原因线程A被**挂起* *。
  3. 此时线程B也通过getIntVolatile(o, offset)方法获取到value值3,此时刚好线程B没有被挂起,并继续向下执行compareAndSwapInt方法比较内存值也为3,成功将value值自增一次,将内存值修改为4,线程B执行完收工,一切OK。
  4. 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的value值3和主内存中的value值4不一致(因为此前已经被线程B CAS了一次),说明该值已经被其它线程抢先一步修改过了,那A线程本次修改失败,只能重新读取重新来一遍了。(也即此时线程A调用compareAndSwapInt方法执行CAS失败,方法返回false,再取反为true,while循环重新执行,线程A再次尝试CAS,也就处于了自旋的状态)
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,内存值是4,线程A重新获取value值也是4,所以比较成功,再次value++,将value的值修改为5并刷新到主内存,成功结束。

4.AtomicReference简单应用

譬如AtomicInteger原子整型,AtomicLong原子长整型,那么可否有其他原子引用类型,可以传入我自定义的一些类,来保证原子操作呢?   比如AtomicBook、AtomicOrder。

答案是肯定的:AtomicReference

package com.szh.demo.cas;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.concurrent.atomic.AtomicReference;

@Data
@NoArgsConstructor
@AllArgsConstructor
class User 
    private String userName;
    private Integer age;


public class CASDemo2 
    public static void main(String[] args) 
        User zs = new User("zhangsan", 30);
        User ls = new User("lisi", 55);
        AtomicReference<User> atomicReference = new AtomicReference<>();
        atomicReference.set(zs);
        System.out.println(atomicReference.compareAndSet(zs, ls) + ", " + atomicReference.get());
        System.out.println(atomicReference.compareAndSet(zs, ls) + ", " + atomicReference.get());
    


5.CAS与自旋锁

CAS落地的重要应用-自旋锁,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试**获取锁* *,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

其实就对应我上面的源码截图中的查看Unsafe.java 的 getAndAddInt 方法,这里while体现了自旋的思想。

假如是ture,取反false退出循环;假如是false,取反true要继续循环。

下面我们可以手写一个简单的自旋锁:↓↓↓
 
通过CAS操作完成自旋锁,A线程先进来调用lock方法自己持有锁5秒钟,B随后进来后发现,当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到。

package com.szh.demo.cas;


import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

public class CASDemo3 
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void lock() 
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + " --- come in");
        //用这个while循环实现自旋,最初我们肯定期望没有其他线程进来,也即第一个线程进来的时候,Thread对象最好是null
        //如果Thread对象是null,那么我们就通过CAS操作将初始化好的thread对象放进去
        //如果Thread对象不为null,那么此时线程就只能自旋等待,不断尝试重新获取
        while (!atomicReference.compareAndSet(null, thread)) 
            //System.out.println(thread.getName() + " ---- 自旋ing");
        
    

    public void unLock() 
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
        System.out.println(thread.getName() + " ---- task over");
    

    public static void main(String[] args) 
        CASDemo3 cas = new CASDemo3();

        new Thread(() -> 
            cas.lock();
            try 
                TimeUnit.SECONDS.sleep(5);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            cas.unLock();
        , "threadA").start();

        try 
            TimeUnit.MILLISECONDS.sleep(300);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(() -> 
            long startTime = System.currentTimeMillis();
            cas.lock();
            long endTime = System.currentTimeMillis();
            System.out.println(Thread.currentThread().getName() + " ---- 自旋了 " + (endTime - startTime) + " ms");
            cas.unLock();
        , "threadB").start();
    

 


6.CAS两大缺点

6.1 循环时间长开销很大

do while 如果它一直自旋会一直占用CPU时间,造成较大的开销。         如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

6.2 ABA问题

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

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且线程two进行了一些操作将值变成了B,然后线程two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功。            尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

如何解决?  答案:AtomicStampedReference 添加版本号。 我把jdk中的api附上,大家看着这个应该更好理解。

下面,我们写一个单线程下解决ABA问题的简单案例:↓↓↓

package com.szh.demo.cas;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.concurrent.atomic.AtomicStampedReference;

@Data
@NoArgsConstructor
@AllArgsConstructor
class Book 
    private Integer id;
    private String bookName;


public class CASDemo4 
    public static void main(String[] args) 
        Book javaBook = new Book(1, "Java从精通到入门");
        Book mysqlBook = new Book(2, "MySQL从删库到跑路");

        //A
        AtomicStampedReference<Book> stampedReference = new AtomicStampedReference<>(javaBook, 1);
        System.out.println("初始值:" + stampedReference.getReference());
        System.out.println("初始版本号:" + stampedReference.getStamp());

        //AB
        boolean flag;
        flag = stampedReference.compareAndSet(javaBook, mysqlBook, stampedReference.getStamp(), stampedReference.getStamp() + 1);
        System.out.println(flag + ", 第二次修改之后的值:" + stampedReference.getReference());
        System.out.println(flag + ", 第二次修改之后的版本号:" + stampedReference.getStamp());

        //ABA
        flag = stampedReference.compareAndSet(mysqlBook, javaBook, stampedReference.getStamp(), stampedReference.getStamp() + 1);
        System.out.println(flag + ", 第三次修改之后的值:" + stampedReference.getReference());
        System.out.println(flag + ", 第三次修改之后的版本号:" + stampedReference.getStamp());
    

下面,我们写一个多线程下解决ABA问题的简单案例:↓↓↓

package com.szh.demo.cas;

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

public class CASDemo5 
    private static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) 
        new Thread(() -> 
            int stamp = stampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " ---- 首次版本号:" + stamp);

            try 
                TimeUnit.MILLISECONDS.sleep(500);
             catch (InterruptedException e) 
                e.printStackTrace();
            

            stampedReference.compareAndSet(100, 101, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " ---- 2次版本号:" + stampedReference.getStamp());

            stampedReference.compareAndSet(101, 100, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " ---- 3次版本号:" + stampedReference.getStamp());
        , "threadA").start();

        new Thread(() -> 
            int stamp = stampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " ---- 首次版本号:" + stamp);

            //让threadB线程睡眠2秒,确保上面的threadA线程已经发生了ABA问题
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            

            boolean flag = stampedReference.compareAndSet(100, 101, stamp, stamp + 1);
            System.out.println(flag + ", 版本号:" + stampedReference.getStamp() + ", 值:" + stampedReference.getReference());
        , "threadB").start();
    

这里首先让两个线程都获取到首次版本号1、首次值100,之后,threadB线程睡眠2秒,确保threadA先发生ABA问题,也即100改为101、100再改为100,版本号自然也增加了2,变成了3。那么此时threadB睡眠结束,它会去CAS,它期望的版本号还是自己首次拿到的那个1,但显然已经不是了(被threadA改为了3),那么此时threadB的CAS操作就失败了。

尽管你此时的值仍然和threadB最初拿到的一样都是100,但版本号的出现就彻底解决了ABA问题,在这期间实际上值是被其他线程更改过的。 

以上是关于Java——聊聊JUC中的CAS原理的主要内容,如果未能解决你的问题,请参考以下文章

Java——聊聊JUC中的CAS原理

juc学习二(CAS底层原理)

Java中CAS 基本实现原理

聊聊并发——CAS算法

JUC多线程:Atomic原子类与CAS原理

Java的CAS乐观锁原理解析