Java 并发 - CAS 机制

339

一、CAS

1.1 概念

Compare and swap,就是所谓的 CAS ,是一种机制,是乐观锁的一种实现方式

1.2 定义

联系 JMM ,在 CAS 机制中,可以简单地理解它有三个规定:

  • 主内存的变量具有可见性
  • 线程将变量写回主内存时对变量有一个期望值
  • 线程将变量写回主内存时变量的更新值

那么,多线程采用 CAS 来进行变量的修改,就是一个比较和交换的过程,比较的是变量的期望值,交换的是旧值与更新值

1.3 举例

写一份简单的伪代码说明,有:

class Test{
    public static void main(String[] args){
        volatile int a = 0; 
        compareAndSwap(expected:0, update: 1);
        compareAndSwap(expected:0, update: -1);
    }
}

变量 a 具有可见性,程序执行了主线程的两次的 CAS 操作,第一次期望 a 是 0 值,则更新为 1,第二次期望 a 是 0 值,则更新为 -1

很明显,第一次执行完了后 a 的值就变成 1 了,那么第二次 CAS 的操作就无法满足期望值,不会更新为 -1

二、AtomicInteger

前面说到了 CAS 的伪代码实现例子,还不够过瘾,具体下面还是分析一下具体利用了 CAS 机制实现了线程安全的 AtomicInteger 来更深入地理解 CAS 吧!

2.1 概念

Atomic 就是原子的意思,Integer 就是整形,所以合起来就是原子整型类,它处于 java.util.concurrent.atomic 包下面,而所谓的原子类,就是这个类的实例的操作都具有原子性

原子性操作:操作执行的时候不可分割,不允许其他操作中途介入后继续执行。它要么全执行完,要么不执行。

听着挺耳熟,这不就是数据库事务的原子特性?

意思差不多,总之 AtomicInteger 就是说这个类的操作都具有原子性

2.2 原理

那么首先概括一下 AtomicInteger 原子操作的实现原理:

CAS 机制:

  1. volatile - 保证可见性
  2. 本地方法 - 获取内存地址保证原子性

其实,重量锁 synchronized 直接就可以保证数据和方法操作是具有原子性的,直接加上不是更方便?

事实上显而易见的是,大费周章实现原子操作不使用重量锁的原因就是它的性能效率太低了,所以搞了这么一套。不过其实在 JDK 1.6 之后,对锁引入了大量的优化,例如偏向锁、轻量级锁等,所以某些场景下,它也并不是那么“重”了

现在的锁的情况可以这么说:

  • 线程冲突少的情况下,使用重量锁可以获取和 CAS 类似的性能
  • 线程冲突严重的情况下,使用重量锁的性能要远高于 CAS

直接看源码:

public class AtomicInteger extends Number implements java.io.Serializable {
    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); }
    }

    private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initiSal value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
    }
    
     /**
     * Atomically increments by one the current value.
     *
     * @return the previous value
     */
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

    //...省略自带的 get set 属性以及其他相关方法

}

这里出现了我们要关注的三个字段:value 、unsafe、valueOffset

  • value 字段是 volatile 修饰的,代表这个原子整型的值,保证了值的可见性
  • unsafe 类调用的是本地方法,用来获取变量的最新值,保证了值的操作的原子性
  • valueoffset 就代表变量的内存地址值

可以发现, AtomicInteger 方法是 CAS 方法,但其实它们又都是调用 Unsafe 类的 CAS 方法实现的,同时一些原子操作是靠循环来完成的,这样的循环的过程称之为自旋,自旋 + CAS 构成了 AtomicInteger 的 核心

2.3 总结

那么,通过 Java 里的 AtomicInteger 的原理,知道了 CAS 在 Java 里的具体实现方式,核心就在于 Unsafe 类,里面提供了一系列的本地方法,且这些方法都是实现了 CAS 机制的本地方法

所以 AtomicInteger 的 CAS 靠的是 Unsafe 类 的 CAS 实现

更深层次地讲,CAS 属于一种系统原语,调用 CAS 本地方法时,JVM 会实现 CAS 的汇编指令

在操作系统中系统原语由若干条指令构成,原语的执行不允许中断。高角度来看,原语仿佛就像一条 CPU 指令

所以,CAS 方法执行,不会造成数据不一致的问题,因为它里面的指令执行不会中断

所以能用 synchronized 实现却要用 CAS 实现,就是两个原因:

  • 一个时间段多个线程执行,提高并发度
  • 减少线程阻塞的时间,性能更好些

而 CAS 往往又跟循环一起使用,这是个不断重复的过程,称之为 CAS 自旋,所以当线程冲突严重的时候,自旋的时间会变得很长,那么用 synchronized 会取得比 CAS 自旋实现乐观锁更好的性能

三、CAS 优缺点

3.1 优点

上面说了,CAS 是乐观锁的一种实现方式,它是一种非阻塞的轻量级乐观锁,比起悲观锁,它具有使线程并发度更高,耗费系统资源更少的优点,这是因为没加锁,所以不用一系列加锁,唤醒线程的操作

3.2 缺点

CAS 的优点仅仅只是在系统并发量不是很大、资源竞争不是很激烈时才体现出来

而 CAS 的缺点有三:

  1. 只能保证一个变量的原子性操作
  2. 并发量大的情况使得自旋效率低下,CAS 长时间不成功,会导致其一直自旋,给CPU带来非常大的开销
  3. 无法保证变量的共享变量的原子性操作,后来 JDK 1.5 提供了 AtomicReference 才解决了这问题
  4. ABA 问题

四、ABA 问题

4.1 举例

由于一般 CAS 使用的时候,是采用自旋的方式的,就是在循环中失败重试,那么如果并发量很大时,会大大增加自旋的时间,占据大量的 CPU 资源

而所谓的 ABA 问题就是由时间差引起的 CAS 的漏洞

举例:

  • 变量 A = 0
  • 线程 1 做 A += 5
  • 线程 2 做 A += 10

开始,线程 1 与 线程 2 都取得 A 变量的值,从主内存拷贝到工作内存

如果线程 1 比线程 2 快得多,在线程 2 写好主内存时可以来回执行几次,那么假设线程 1 做如下操作:

  • A += 5
  • A -= 5
  • A += 5
  • A -= 5

此时终于轮到线程 2 写好主内存了,那么它 CAS 一判断发现还是 0 ,就写回去 A = 10

虽然这样一个过程下来,对线程 2 来说 A 的值是没变化的。尽管线程 2 CAS 操作成功,但实际上是有变化的,这就是 ABA 问题,B 代表过程中是有变化的意思

ABA 问题在过程不重要的情况下是没什么问题的,例如对存钱的过程来说,保证我存的余额还是有那么多就没啥问题,存进去,余额增加,这个过程 CAS 自旋判断的是账户余额

但假如在存钱过程中,CAS 判断的是系统余额改变布尔值,isChange 有没有改变,那么过程中如果线程 2 改变了余额,最后再写回去 isChange 没有改变,那么就会使得账户余额数据不一致了

所以在对过程安全有要求的业务场景下 ABA 问题需要规避,那么如何规避?

4.2 规避 ABA

规避方法就是使用原子引用 + 时间戳的方式

CAS 比较的时候除了要比较变量值外,还要比较时间戳,若时间戳更新了,即使值相同也要再次获取一次变量的新的值

Java 提供原子引用类,可以对任意类的实例提供一个原子引用,AtomicReference<T>

同时基于版本号的原子类是,AtomicStampReference<T>

4.2.1 原子引用

//可以指定初始值
AtomicReference<Integer> atomicReference = new AtomicReference<>(100);

未使用时间戳的原子引用,引发的 ABA 问题:

class ABADemoI {
    private static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);

    public static void main(String[] args) {
        new Thread(() -> {
            atomicReference.compareAndSet(100, 101);
            atomicReference.compareAndSet(101, 100);
        }, "t1").start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean isSuccess = atomicReference.compareAndSet(100, 102);
            Integer curVal = atomicReference.get();
            System.out.println(isSuccess + " " + curVal);
        }, "t2").start();
    }
}

以上代码在 t1 中经历了 ABA,但最后 t2 中得到的 isSuccess 仍然 true,值更新为 102

4.2.2 时间戳原子引用

//第一个参数指定初始值,第二个参数指定初始时间戳值
AtomicStampedReference<Integer> asReference = new AtomicStampedReference<>(100, 1);

使用时间戳的原子引用,解决 ABA 问题

class ABADemoII {
    private static AtomicStampedReference<Integer> asReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        new Thread(() -> {
            int stamp1 = asReference.getStamp();
            try {
                TimeUnit.SECONDS.sleep(1); //让 t2 顺利获取初始时间戳
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            asReference.compareAndSet(100, 101, stamp1, stamp1 + 1);
            int stamp2 = asReference.getStamp();
            asReference.compareAndSet(101, 100, stamp2, stamp2 + 1);

        }, "t1").start();

        new Thread(() -> {
            int stamp1 = asReference.getStamp();
            try {
                TimeUnit.SECONDS.sleep(2); // 让 t1 完成 ABA 操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean isSuccess = asReference.compareAndSet(100, 102, stamp1, stamp1 + 1);
            int curVal = asReference.getReference();
            int curStamp = asReference.getStamp();

            System.out.println(isSuccess
                    + "\n" + "t2 stamp: " + stamp1
                    + "\n" + "ref stamp: " + curStamp
                    + "\n" + "t2 expected val: " + 100
                    + "\n" + "ref current val: " + curVal);
        }, "t2").start();
    }
}

输出:

false
t2 stamp: 1
ref stamp: 3
t2 expected val: 100
ref current val: 100

因为时间戳不同,所以避免了 ABA 问题,t2 想更新,需要重新获取时间戳与原子变量的值