java 基础回顾 - 基于 CAS 实现原子操作的基本理解

1. 什么是原子操作

所谓原子操作是指不会被打断的操作,这种”打断”在操作系统层面, 一般是指线程间的上下文切换. 这种操作一旦开始, 就一直运行到结束. 简单来说, 就是这个操作无论多复杂要么都成功, 要么全都失败.

2. 怎么实现原子操作

实现原子操作可以使用锁, 使用锁机制来满足基本的需求是没问题的, 但是有的时候我们需求并非那么简单, 我们需要更有效, 更加灵活的机制. synchronized 关键字是基于阻塞的锁机制, 也就是说当一个线程有锁的时候, 访问同一资源的其他线程需要等待, 直到该线程释放锁.

volatile 是不错的机制, 但是 volatile 并不能保证原子性.

那么如果被阻塞的线程优先级很高很重要怎么办? 如果获得锁的线程一直不释放怎么办? 同时还有可能出现例如死锁之类的情况. 其实锁机制是一种比较粗糙, 粒度较大的机制. 而且加锁, 释放锁会导致比较多的上下文切换和调度延时, 引起性能问题. 那么这里就引出了另外一种实现原子操作的机制 CAS 机制. 那么什么又是 CAS 呢.

3. CAS 的基本理解

CASCompare And Swap (比较并交换), 是利用现代 CPU 的一个指令. 同时借助 JNI 来完成 Java 的非阻塞算法
CAS 的操作包含三个操作数 —— 内存位置 (V). 预期原值/旧值 (A). 和新值 (B). 如果内存位置的值与预期原值/旧值相匹配, 那么处理器会自动将该位置值更新为新值. 否则, 处理器不做任何操作. 无论哪种情况, 它都会在 CAS 指令之前返回该 位置的值. CAS 有效地说明了 “我认为位置 V 应该包含值 A, 如果包含该值, 则将 B 放到这个位置. 否则, 不要更改该位置, 只告诉我这个位置现在的值即可. ”

通常将CAS 用于 同步 的方式从内存中读取旧值, 执行多步计算来获得新值, 准备将新值写入的时候, 执行 CAS 指令, 如果内存中的值与预期的旧值相匹配, 则将新值写入. 若不匹配则表示内存中的值已经被修改, 不做任何操作; 同时返回现在的值. 对现在的值再次进行计算, 再一次执行 CAS操作. 也叫自旋.

例如一个变量 x = 0, 然后 A, B 两个线程拿到 x 的值, 对 x 进行累加. 累加后, 这时候两个线程中的 x 值都为 1, x 旧值都为 0.

  1. 两个线程对 x 累加完在准备写入的时候, A 线程执行 CAS 指令, 这时候内存中 x 的值为 0. 通过比较内存位置的值与 A 线程中 x 的旧值比较发现匹配, 则将 x 的值更新为新的值. 那么这个时候 x 内存中的值为 1.

  2. 到 B 线程来执行 CAS 指令. 这时候 B 线程中 x 的旧值还是一开始的 0, 通过与内存位置值比较发现不匹配, 说明 x 的值已经被修改过了, 就会将内存中 x = 1 重新给线程 B, 线程 B 就会再来执行一次累加的操作 (累加后 B 线程中的 x =2, x旧值为1) ,然后再次执行 CAS指令. 拿 B 线程中 x 的旧值与内存中 x 的值进行对比较, 发现匹配, 则更新 x 的值为 2.

如下图所示.

image.png

JDK5 之前 Java 是靠 synchronized 关键字保证同步的,这是一种独占锁,也是悲观锁。
JDK5 增加了并发包 java.util.concurrent.*, 其内部以 Atomic 开头的原子操作类都是基于 CAS 机制实现了区别于 synchronouse 同步锁的一种乐观锁。
基于 CAS 机制实现的原子操作类

5. CAS 实现原子操作的三大问题

  • ABA 问题
    因为 CAS 需要在操作值的时候, 检查值有没有发生变化, 如果没有发生变化则更新. 但是如果有一个值原来是 A, 有一个线程变为 B 后, 又变成了 A, 那么使用 CAS 进行比较检查的时候发现它的值是没有发生变化, 但是实际上是发生了变化的. 不过 ABA 问题可以通过使用版本号的方式来解决, 就是在变量前追加上版本号. 每次变量更新的时候把版本号加 1, 那么 A-B-A, 就变成了 1A-2B-3A. JDK 也同样提供了两个类来帮助我们实现这个版本号的问题, 分别是 AtomicStampedReference, AtomicMarkableReference 这两个还是有所不同的, 下面会有说明.

  • 循环时间长开销大
    自旋 CAS 如果长时间都不成功, 那么会给 CPU 带来非常大的执行开销. 这个目前好像无解, 但是如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升, pause 指令有两个作用,

    • 第一它可以延迟流水线执行指令(de-pipeline), 使 CPU 不会消耗过多的执行资源, 延迟的时间取决于具体实现的版本, 在一些处理器上延迟时间是零.
    • 第二它可以避免在退出循环的时候因内存顺序冲突 (memory order violation) 而引起 CPU 流水线被清空(CPU pipeline flush), 从而提高 CPU 的执行效率.
  • 只能保证一个共享变量的原子操作
    当对一个共享变量进行操作时, 我们可以使用自旋 CAS 的方式来保证原子操作, 但是对于多个共享变量操作时, 自旋CAS 就无法保证操作的原子性, 这个时候就可以使用锁的机制.
    同样, JDK也提供了 AtomicReference 原子操作类来保证引用对象之间的原子性, 就可以把多个原子变量放在一个对象里来进行 CAS 操作. 下面也会有对其进行说明.

6. JDK 中相关原子操作类的使用示例

JDK 中为我们提供了相关的原子操作类, 常用的如下.
更新基本类型: AtomicBoolean, AtomicInteger, AtomicLong.
更新数组类型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray.
更新引用类型: AtomicReference, AtomicMarkableReference, AtomicStampedReference

  • AtomicInteger 用法基本示例
static AtomicInteger ai = new AtomicInteger(10);
public static void main(String[] args) throws InterruptedException {
    //getAndIncrement 返回的是自增前的值
    //int beforeValue = ai.getAndIncrement();
    //输出 自增前的值:10
    //System.out.println("自增前的值:" + beforeValue);
    //输出 自增后的值:11
    //System.out.println("自增后的值:" + ai);

    //incrementAndGet 返回的是自增后的值
    //int afterValue = ai.incrementAndGet();
    //输出 11
    //System.out.println("afterValue:" + afterValue);

    //addAndGet 增加指定的值并返回增加后的值, 输出 110
    //System.out.println(ai.addAndGet(100));

    //getAndAdd 增加指定的值并返回增加前的值, 输出 10
    //System.out.println(ai.getAndAdd(100));

    //ai 的值是否符合期望值, 符合则修改为 200, 并返回 true., 输出 true.
    //System.out.println(ai.compareAndSet(1,200));
    //输出 200
    //System.out.println(ai);
}

int getAndIncrement(): 以原子操作的方式将当前值加 1. 这里返回的是自增前的值.
int incrementAndGet(): 也是以原子操作的方式将当前值加 1, 这里返回的是自增后的值.
int addAndGet(int delta): 以原子方式将输入的数值与实例中的值相加, 并返回相加后的结果.
int getAndAdd(int delta): 以原子方式将输入的数值与实例中的值相加, 并返回相加前的结果.
boolean compareAndSet(int 期望值, int 新值): 如果输入的数值等于期望值, 则以原子方式将该值设置为输入的值.设置成功返回 true

  • AtomicIntegerArray 用法基本示例
    AtomicIntegerArray 方法的使用基本和 AtomicInteger 基本类似, 不同的是, AtomicIntegerArray 构造方法有两个.
public AtomicIntegerArray(int length) {
    array = new int[length];
}

public AtomicIntegerArray(int[] array) {
    // Visibility guaranteed by final field guarantees
    this.array = array.clone();
}

第一个构造方法不必多说, 就是创建一个指定长度的 AtomicIntegerArray 数组.
主要是第二个构造方法, 传入一个数组后 AtomicIntegerArray 会将传入的数组复制一份, 所以当 AtomicIntegerArray 对内部的数组元素进行修改的时候, 不会影响到传入的数组.

常用方法调用:
int getAndSet(int i, int newValue): 以原子的方式将数组中索引i的元素值设置为newValue.返回数组中索引i元素设置前的值.
int getAndAdd(int i, int delta): 以原子的方式将 delta 与数组索引 i 的元素值相加. 返回数组中索引 i 元素相加前的值.
int addAndGet(int i, int delta): 以原子的方式将 delta 与数组索引 i 的元素值相加. 返回数组中索引 i 元素相加后的值.
boolean compareAndSet(int i, int expectedValue, int newValue): 如果数组索引 i的元素值等于期望值 expectedValue, 那么将以原子方式将位置i的值设置为 newValue.

  • AtomicReference用法基本示例
static class UserInfo {
    private String name;
    private int age;

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

    public String getName() {return name;}
    public int getAge() {return age;}

    @Override
    public String toString() {
        return "UserInfo{" + "name='" + name + '\'' + ", age=" + age + '}';
    }
}
static AtomicReference atomicReference;
  public static void main(String[] args) {
      UserInfo userInfo = new UserInfo("张三", 28);
      atomicReference = new AtomicReference<>(userInfo);
      //或者
      //atomicReference = new AtomicReference<>();
      //atomicReference.set(userInfo);

      //与预期值不符合, 输出 UserInfo{name='张三', age=28}
      // UserInfo expectedValue = new UserInfo("张三", 30);
      // UserInfo updateUser = new UserInfo("李四", 30);
      // atomicReference.compareAndSet(expectedValue,updateUser);
      // System.out.println(atomicReference.get());

      //与预期值匹配, 这更新为新值.输出 UserInfo{name='李四', age=30}
      //UserInfo updateUser = new UserInfo("李四", 30);
      //atomicReference.compareAndSet(userInfo,updateUser);
      //System.out.println(atomicReference.get());
  }

用法基本和其他的都基本类似. 这里就不再进行说明.

  • AtomicStampedReference
    上面说过, 通过 AtomicStampedReferenceAtomicMarkableReference 利用版本号可以解决 ABA 的问题. 记录了每次改变以后的版本号. 这样的话就不会存在 ABA 问题.
    AtomicStampedReference 是使用 pairint stamp 作为计数器使用.
    AtomicMarkableReferencepair 使用的是 boolean 来记录.不关心次数的问题, 只关心有没有被改动过.
    他们两个的用法差不多, 这里就只用 AtomicStampedReference 为例来解决一个 ABA 的问题.
//创建一个带版本号的 String 类型的变量, 值为"张三", 版本号为 1
static AtomicStampedReference asr = new AtomicStampedReference<>("张三",1);

public static void main(String[] args) throws Exception {

    System.out.println("原始值为:" + asr.getReference() +"----原始版本号为:"+ asr.getStamp());

    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            //参数依次为 期望值, 新值, 期望版本号, 新版本号
            boolean compareAndSetResult = asr.compareAndSet(asr.getReference(),"李四", asr.getStamp(), asr.getStamp() + 1);
            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 后的值为:" + asr.getReference()
                    + "----版本号为:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult);

            boolean compareAndSetResult2 = asr.compareAndSet(asr.getReference(),"张三", asr.getStamp(), asr.getStamp() + 1);
            System.out.println(Thread.currentThread().getName()
                    + "----第二次 compareAndSet 后的值为:" + asr.getReference()
                    + "----版本号为:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult2);
        }
    },"线程 1");

    Thread thread2 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 前的值为:" + asr.getReference()
                    + "----版本号为:" + asr.getStamp());

            boolean compareAndSetResult = asr.compareAndSet(asr.getReference(), "麻子", asr.getStamp(), asr.getStamp() + 1);

            System.out.println(Thread.currentThread().getName()
                    + "----第一次 compareAndSet 后的值为:" + asr.getReference()
                    + "----版本号为:" + asr.getStamp()
                    + "----修改是否成功:" + compareAndSetResult);
        }
    },"线程 2");

    thread1.start();
    thread1.join();
    thread2.start();
    thread2.join();

    System.out.println("最后值为:" + asr.getReference() +"----版本号为:"+ asr.getStamp());
}

输出结果为

原始值为:张三----原始版本号为:1
线程 1----第一次 compareAndSet 后的值为:李四----版本号为:2----修改是否成功:true
线程 1----第二次 compareAndSet 后的值为:张三----版本号为:3----修改是否成功:true
线程 2----第一次 compareAndSet 前的值为:张三----版本号为:3
线程 2----第一次 compareAndSet 后的值为:麻子----版本号为:4----修改是否成功:true
最后值为:麻子----版本号为:4

在线程 1 中第一次将值改为了 "李四", 第二次再将 "李四" 改为 "张三"
接着在线程 2 中拿到的"张三", 版本号已经变成了 3. 这也就解决了 ABA 的问题.


你可能感兴趣的:(java 基础回顾 - 基于 CAS 实现原子操作的基本理解)