并发编程之 CAS 的原理

6,230 阅读8分钟

CAS 的原理

前言

在并发编程中,锁是消耗性能的操作,同一时间只能有一个线程进入同步块修改变量的值,比如下面的代码


synchronized void function(int b){
  a = a + b;
}


如果不加 synchronized 的话,多线程修改 a 的值就会导致结果不正确,出现线程安全问题。但锁又是要给耗费性能的操作。不论是拿锁,解锁,还是等待锁,阻塞,都是非常耗费性能的。那么能不能不加锁呢?

可以。

什么意思呢?我们看上面的代码,分为几个步骤:

  1. 读取a
  2. 将 a 和 b 相加
  3. 将计算的值赋值给a。

我们知道,这不是一个原子的操作,多线程上面时候会出问题:当两个线程同时访问 a ,都得到了a 的值,并且通知对a 加 1,然后同时将计算的值赋值给a,这样就会导致 a 的值只增加了1,但实际上我们想加 2.

问题出在哪里?第三步,对 a 赋值操作,如果有一种判断,判断 a 已经别的线程修改,你需要重新计算。比如下面这样:


void function(int b) {
   int backup = a;
   int c = a + b;
   compareAndSwap(a, backup, c);
}

void compareAndSwap(int backup ,int c ){
       if (a == backup) {
           a = c;
       }
}

从代码中,我们看到,我们备份了 a 的值,并且对 a 进行计算,如果 a 的值和备份的值一致,说明 a 没有被别的线程更改过,这个时候就可以进行修改了。

这里有个问题:compareAndSwap 方法有多步操作,不是原子的,并且没有使用锁,如何保证线程安全。其实楼主这里只是伪代码。下面就要好好说说什么是 CAS (compareAndSwap);

1. 什么是 CAS

CAS (compareAndSwap),中文叫比较交换,一种无锁原子算法。过程是这样:它包含 3 个参数 CAS(V,E,N),V表示要更新变量的值,E表示预期值,N表示新值。仅当 V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做两个更新,则当前线程则什么都不做。最后,CAS 返回当前V的真实值。CAS 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。

当多个线程同时使用CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会挂起,仅是被告知失败,并且允许再次尝试,当然也允许实现的线程放弃操作。基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰。

与锁相比,使用CAS会使程序看起来更加复杂一些,但由于其非阻塞的,它对死锁问题天生免疫,并且,线程间的相互影响也非常小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,他要比基于锁的方式拥有更优越的性能。

简单的说,CAS 需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,哪说明它已经被别人修改过了。你就需要重新读取,再次尝试修改就好了。

那么这个CAS 是如何实现的呢?也就是说,比较和交换实际上是两个操作,如何变成一个原子操作呢?

2. CAS 底层原理

这样归功于硬件指令集的发展,实际上,我们可以使用同步将这两个操作变成原子的,但是这么做就没有意义了。所以我们只能靠硬件来完成,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。这类指令常用的有:

  1. 测试并设置(Tetst-and-Set)
  2. 获取并增加(Fetch-and-Increment)
  3. 交换(Swap)
  4. 比较并交换(Compare-and-Swap)
  5. 加载链接/条件存储(Load-Linked/Store-Conditional)

其中,前面的3条是20世纪时,大部分处理器已经有了,后面的2条是现代处理器新增的。而且这两条指令的目的和功能是类似的,在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 的功能。

CPU 实现原子指令有2种方式:

  1. 通过总线锁定来保证原子性。 总线锁定其实就是处理器使用了总线锁,所谓总线锁就是使用处理器提供的一个 LOCK# 信号,当一个处理器咋总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。但是该方法成本太大。因此有了下面的方式。

  2. 通过缓存锁定来保证原子性。 所谓 缓存锁定 是指内存区域如果被缓存在处理器的缓存行中,并且在Lock 操作期间被锁定,那么当他执行锁操作写回到内存时,处理器不在总线上声言 LOCK# 信号,而时修改内部的内存地址,并允许他的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改两个以上处理器缓存的内存区域数据(这里和 volatile 的可见性原理相同),当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

注意:有两种情况下处理器不会使用缓存锁定。

  1. 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定
  2. 有些处理器不支持缓存锁定,对于 Intel 486 和 Pentium 处理器,就是锁定的内存区域在处理器的缓存行也会调用总线锁定。

3. Java 如何实现原子操作

java 在 1.5 版本中提供了 java.util.concurrent.atomic 包,该包下所有的类都是原子操作:

java.util.concurrent.atomic 包

如何使用呢?看代码

  public static void main(String[] args) throws InterruptedException {
    AtomicInteger integer = new AtomicInteger();
    System.out.println(integer.get());


    Thread[] threads = new Thread[1000];

    for (int j = 0; j < 1000; j++) {
      threads[j] = new Thread(() ->
          integer.incrementAndGet()
      );
      threads[j].start();
    }

    for (int j = 0; j < 1000; j++) {
      threads[j].join();
    }

    System.out.println(integer.get());
  }
}

上面的代码,我们启动了1000个线程对 AtomicInteger 变量做了自增操作。结果是我们预期的1000,表示没有发生同步问题。

我们看看他的内部实现,我们找到该类的 compareAndSet 方法,也就是比较并且设置。我们看看该方法实现:

该方法调用了 unsafe 类的 compareAndSwapInt 方法,有几个参数,一个是该变量的内存地址,一个是期望值,一个是更新值,一个是对象自身。完全符合我们之前CAS 的定义。那么 ,这个 unsafe 又是什么呢?

该类在 rt.jar 包中,但不在我们熟悉的 java 包下,而是 sun.misc 包下。并且都是 class 文件,注释都没有,符合他的名字:不安全。

我们能构造他吗?不能,除非反射。

我们看看他的源码:

getUnsafe 方法中,会检查调用 getUnsafe 方法的类,如果这个类的 ClassLoader 不为null ,就直接抛出异常,什么情况下会为null呢?当类加载器是 Bootstrap 加载器的时候,Bootstrap 加载器是没有对象的,也就是说,加载这个类极有可能是 rt.jar 下的。

而在最新的 Java 9 当中,该类已经被隐藏。因为该类使用了指针。但指针的缺点就是不安全。

4. CAS 的缺点

CAS 看起来非常的吊,但是,他仍然有缺点,最著名的就是 ABA 问题,假设一个变量 A ,修改为 B之后又修改为 A,CAS 的机制是无法察觉的,但实际上已经被修改过了。如果在基本类型上是没有问题的,但是如果是引用类型呢?这个对象中有多个变量,我怎么知道有没有被改过?聪明的你一定想到了,加个版本号啊。每次修改就检查版本号,如果版本号变了,说明改过,就算你还是 A,也不行。

在 java.util.concurrent.atomic 包中,就有 AtomicReference 来保证引用的原子性,但楼主觉得有点鸡肋,不如使用同步加互斥,可能会更加高效。

总结

今天我们从各种角度理解了CAS 的原理,该算法特别的重要,从CPU 都特别的设计一条指令来实现可见一斑。而JDK的源码中,到处都 unSafe 的 CAS 算法,可以说,如果没有CAS ,就没有 1.5 的并发容器。好,今天就到这里。

good luck !!!