Java CAS操作的实现原理深度解析与应用案例

473 阅读9分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

首先介绍了CAS的基本概念,然后深入至HotSpot源码级别的角度解析了CAS的底层实现,最后介绍了CAS操作存在的问题以及应用。

对于并发控制而言, 锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,则宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。

而无锁(lock-free)是一种乐观的策略,它会假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下待续执行。那遇到冲突怎么办呢?无锁的策略使用一种叫作比较交换(CAS, Compare And Swap) 的技术来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

1 CAS的概述

CAS(Compare and Swap),翻译过来就是“比较并交换”。CAS 操作包含三个操作数 —— 要更新的字段内存位置V(它的值是我们想要去更新的)、预期原值A(前面从内存中读取的值)和新值B(将要写入的新值)。

CAS操作过程:首先读取预期原值A,然后在要更新数据的时候再次读取内存位置V的值,如果该值与预期原值A相匹配,那么处理器会自动将该位置V值更新为新值B;否则一直重试该过程,直到成功。

CAS 操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,直到成功。当然也允许失败的线程放弃操作。

在这里插入图片描述

2 Java的CAS实现

在硬件层面,大部分的现代处理器都已经支持原子化的CAS 指令。在JDK5 以后, 虚拟机便可以使用这个指令来实现并发操作和并发数据结构, 并且这种操作在虚拟机中可以说是无处不在的。

从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。它们的内部就是使用CAS操作来实现的。

如下案例:

public class Practise {
    private AtomicInteger atomicI = new AtomicInteger(0);
    //使用到计数器实现线程等待,设置初始计数器值为100
    private static CountDownLatch countDownLatch = new CountDownLatch(100);

    public static void main(String[] args) throws InterruptedException {
        final Practise cas = new Practise();
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(50, 100, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(50), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
        for (int j = 0; j < 100; j++) {
            threadPoolExecutor.submit(() -> {
                for (int i = 0; i < 10000; i++) {
                    cas.safeCount();
                    //cas.unsafeCount();
                }
                //上面的任务执行完毕,计数器值减去一
                countDownLatch.countDown();
            });
        }
        threadPoolExecutor.shutdown();
        //main线程将会等待所有任务都执行完,即计数器值变为0时,才继续执行
        countDownLatch.await();
        System.out.println(cas.atomicI.get());
    }

    /**
     * 不安全的更新方式,unsafeCount方法中的代码不是原子性的,有可能造成多个线程重复写同一个变量
     */
    private void unsafeCount() {
        int i = atomicI.get();
        atomicI.set(++i);
    }

    /**
     * 使用CAS实现安全的更新方法
     */
    private void safeCount() {
        //for循环尝试使得共享变量atomicI的值增加1
        for (; ; ) {
            int i = atomicI.get();
            //compareAndSet方法是Java帮我们实现的一个CAS方法,CAS成功之后会返回true,CAS失败则返回false
            //compareAndSet方法的参数含义是:预估原值为i,让后将原值尝试CAS更新为++i;其他所需的参数则是在compareAndSet方法内部帮我们自动获取了。
            //如果是true,说明变量atomicI的值增加成功,跳出循环,如果返回false,说明变量atomicI的值增加失败,重新循环直到成功为止
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }

    /**
     * compareAndSet方法内部,变量和字段内存偏移量帮我们获取了
     * @param expect
     * @param update
     * @return
     */
//    public final boolean compareAndSet(int expect, int update) {
//        //this:变量  valueOffset:value值的偏移量,通过此可以定位该字段在JVM内存中的位置  expect:预估原值   update:更新为指定值
//        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
//    }
}

3 CAS的底层实现原理

CAS的底层实现,是通过lock cmpxchg汇编指令来实现的。cmpxchg用来实现比较交换操作,lock前缀指令用来保证多cpu环境下指令所在缓存行的独占同步,保证了原子性,有序性和可见性。

原子类的很多方法都是使用CAS操作,以上例中的compareAndSet方法为例子:

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

可以看到内部调用了Unsafe类的compareAndSwapInt方法,Unsafe类中的方法能够通过直接操作字段偏移量来修改字段的值,相当于通过“指针”来操作字段,这个字段的偏移量-“指针”的位置需要自己计算,因此如果计算错误那么会带来一系列错误,比如会导致整个JVM实例崩溃等严重问题。因此Java并不鼓励使用该类,甚至命名为“Unsafe”。Unsafe类是Java实现CAS的基石,但它的作用不仅仅是实现CAS,还有操作直接内存等作用,实际上Unsafe类也是juc包的实现的基石。关于Unsafe的详细理解,可以看这篇文章:JUC中的Unsafe类详解与使用案例

Unsafe的compareAndSwapInt方法是一个native的方法,native方法又称本地方法,可以看作Java代码调用非java代码的接口,即该方法的实现由非java语言实现。由于Java语言无法访问操作系统底层信息,这时候如果想要直接操作底层系统,那就需要借助C++来完成了,因此native方法一般是Java通过借助C、C++来实现直接操作底层系统、直接内存的方法。

Unsafe的具体实现是和虚拟机实现相关的,不同的虚拟机具有不同的实现。在openjdk8的hotspot源码(unsafe.cpp)中能看到unsafe的源码,由于hotspot使用C++编写,那么unsafe的对应方法的C++源码如下:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
//获取obj这个对象在jvm里面的对应的内存对象实例
  oop p = JNIHandles::resolve(obj);
//通过对象实例和偏移量获取字段在对象中的偏移地址
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 通过判断调用Atomic.cmpxchg方法的结果返回值是不是原来的e值,如果是表示更新成功,否则更新失败。
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

我们可以看到在该方法的最后调用了Atomic::cmpxchg的方法,但是你如果直接去atomic.cpp中是找不到的,并且上面的方法只是C++的源码并没有具体的汇编指令,但是我们在atomic.cpp中能够找到很多预处理指令,即#include "",该指令会在实现定义的位置查找文件,并将其包含。我们找到:

#include "runtime/atomic.inline.hpp"

进入atomic.inline.hpp,这里面又有很多预处理指令,这些包含的文件均具有Atomic::cmpxchg的不同实现,有windows的也有linux的,一般生产应用运行在linux环境中,因此我们找到其中一个-我们分析Linux的x86的环境:

# include "atomic_linux_x86.inline.hpp"

在atomic_linux_x86.hpp的源码中,终于能找到该方法,汇编指令(CPU指令)可以自由的嵌入C/C++,下面的方法就是最好的证明,接下来进入汇编指令的世界:

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value, cmpxchg_memory_order order) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

其中__asm__表示汇编指令开始,volatile禁止优化重排序,MP表示multiprocessor,LOCK_IF_MP开始时表示它会判断当前系统是否是多核处理器,如果是,那么就在cmpxchg指令前面加上lock指令前缀,否则就使用cmpxchg一条指令。

Cmpxchg后面加了一个后缀l,表示操作数字长32位。另外cmpxchgl %1,(%3)是一种汇编代码风格: 操作指令 源操作数 首操作数。

CMPXCHG r,r/m 将累加器AL/AX/EAX/RAX中的值与第二个操作数(首操作数)比较,如果相等,第1操作数(源操作数)的值装载到首操作数,zf置1。如果不等,首操作数的值装载到AL/AX/EAX/RAX并将zf清0。

针对上面的汇编代码,exchange_value表示交换的值,compare_value表示被比较的值(预期值),dest表示内存偏移量,其大概过程就是将被比较的值compare_value与dest地址的值比较,如果相等则将exchange_value写入该地址值,并将compare_value赋值给exchange_value,如果不等则将目前dest地址的值赋给exchange_value,将最终会返回exchange_value。

可以看到Java的CAS操作的最终实现,是通过lock cmpxchg汇编指令实现的。关于这两条指令,在Intel文档中有详细介绍,这里将它简单翻译成中文如下:

cmpxchg

作用:汇编指令,比较并交换操作数

该指令只能用于486及其后继机型。第2操作数(源操作数)只能用8位、16位或32位寄存器。第1操作数(目地操作数)则可用寄存器或任一种存储器寻址方式。

注意:虽然cmpxchg看起来只有一条指令,但在多核cpu下仅比较交换的指令仍然不具有原子性,因为cmpxchg作为复杂指令,同时带有读写操作,在执行时会被分解为一条条的更小的微码(微指令),一般来说只有单一的load、stroe等指令是真正原子性的。

但是该指令可以与lock同步一起使用,以允许以原子方式执行该指令(来自Intel手册)。

======================================== lock 前缀指令,通常可以与某些指令连用(ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令),它具有如下特性(来自Intel手册):

  1. 确保对内存的读-改-写操作原子执行,即确保lock后面的指令变成一个原子操作。 在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问共享内存,保证内存独占。很显然,这会带来昂贵的开销。从Pentium4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。这实际上也算一种CPU指令级别的同步。
  2. 不是内存屏障,但是具有内存屏障的功能,能够禁止该指令与之前和之后的读和写指令重排序。
  3. 如果lock后面的指令具有写操作,那么lock会导致把写缓冲区中的所有数据刷新到主存中,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据。

注意这里的lock和Java中的Lock是完全不一样的,Java中的Lock被实现为一个高级锁,而这里的lock仅仅是一条低级CPU指令。

Lock锁可以保护Java代码片段的原子性,由于不确定代码片段的执行时间(可能很长),没有获取Lock锁的线程将会等待,直到Lock锁可用,这会带来线程状态切换的开销。这里的lock指令仅保护单个指令,针对单个指令实现同步,单个指令的执行时间一般很短的,因此不需要线程变成等待状态。

从底层汇编指令看起来,CAS实现的“无锁算法”,并不是真正的消除同步,而是将同步限定在单个指令上面,或者说锁定单个指令,不过这相比于常见的synchronized和Lock锁这些重量级锁来讲,确实可以算作“无锁了”。

4 CAS的三大问题

4.1 ABA问题

CAS需要再操作值的时候,检查值有没有发生变化,如果没有发生变化则更新。但是一个值,如果原来为A,变成了B,又变成了A,那么使用CAS进行compare and set的时候,会发现它的值根本没变化过,但实际上是变化过的。

ABA问题的解决思路就是使用版本号,1A->2B->3A,在Atomic包中(JDK5),提供了一个现成的AtomicStampedReference类来解决ABA问题,使用的就是添加版本号的方法。

4.2 循环时间长开销大

由于线程并不会阻塞,如果CAS自旋长时间不成功,这会给CPU带来非常大的执行开销。

如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

4.3 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,由于CAS底层只能锁定单个指令,均是针对单个变量的操作,对多个共享变量操作时意味着多个指令,此时CAS就无法保证所有操作的原子性,这个时候就可以用锁

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

5 总结

synchronized被称为重量锁,因为synchronized会进行比较复杂的加锁、解锁和唤醒操作,这其中涉及到线程状态的转换,比较耗费时间。CAS作为非阻塞的轻量级的乐观锁,通过CPU指令实现,没有线程状态的切换,CAS在资源竞争不激烈的情况下性能高,而如果资源竞争激烈的话CAS可能导致大量线程长时间空转(自旋),这样同样会消耗大量CPU资源。

但是上面的一切在JDK6之后变得不那么一定了。JDK6之后Java对synchronized进行了一系列“锁升级”的优化Java中的synchronized的底层实现原理以及锁升级优化详解),其中synchronized的很多底层实现就是采用了CAS操作,对于线程冲突较少时使用的偏向锁和轻量级锁也没有了线程状态切换,可以获得和CAS类似的性能,而线程冲突严重的情况下,synchronized的性能仍然远高于CAS。这使得synchronized没有那么“重”了,实际上JDK6之后的synchronized的性能已经非常好了,所以目前volatile的应用范围已经不大了。

还有一点,我们看到了lock指令那么多的作用,我相信你们已经猜到了,除了CAS操作之外,实际上Java中的final、synchronized、volatile关键字的底层实现都和lock指令有关。具体是有什么关系,后面的博客会有讲解。

参考资料:

  1. 《Java并发编程之美》
  2. 《实战Java高并发程序设计》
  3. 《Java并发编程的艺术》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!