阿里二面: 什么是 CAS?
背景
在高并发的业务场景下,线程安全问题是必须考虑的,在 JDK5 之前,可以通过 synchronized 或 Lock 来保证同步,从而达到线程安全的目的。但 synchronized 或 Lock 方案属于互斥锁的方案,比较重量级,加锁、释放锁都会引起性能损耗问题。
而在某些场景下,我们是可以通过 JUC 提供的 CAS 机制实现无锁的解决方案,或者说是它基于类似于乐观锁的方案,来达到非阻塞同步的方式保证线程安全。
CAS 机制不仅是面试中会高频出现的面试题,而且也是高并发实践中必须掌握的知识点。如果你目前对 CAS 还不甚了解,或许只有模糊的印象,这篇文章一定值得你花时间学习一下。
什么是 CAS?
CAS 是 Compare And Swap 的缩写,直译就是比较并交换。CAS 是现代 CPU 广泛支持的一种对内存中的共享数据进行操作的一种特殊指令,这个指令会对内存中的共享数据做原子的读写操作。其作用是让 CPU 比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新。
本质上来讲 CAS 是一种无锁的解决方案,也是一种基于乐观锁的操作,可以保证在多线程并发中保障共享资源的原子性操作,相对于 synchronized 或 Lock 来说,是一种轻量级的实现方案。
Java 中大量使用了 CAS 机制来实现多线程下数据更新的原子化操作,比如 AtomicInteger、CurrentHashMap 当中都有 CAS 的应用。但 Java 中并没有直接实现 CAS,CAS 相关的实现是借助 C/C++调用 CPU 指令来实现的,效率很高,但 Java 代码需通过 JNI 才能调用。比如,Unsafe 类提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg。
CAS 的基本流程
下面我们用一张图来了解一下 CAS 操作的基本流程。
CAS 操作流程图
在上图中涉及到三个值的比较和操作:修改之前获取的(待修改)值 A,业务逻辑计算的新值 B,以及待修改值对应的内存位置的 C。
整个处理流程中,假设内存中存在一个变量 i,它在内存中对应的值是 A(第一次读取),此时经过业务处理之后,要把它更新成 B,那么在更新之前会再读取一下 i 现在的值 C,如果在业务处理的过程中 i 的值并没有发生变化,也就是 A 和 C 相同,才会把 i 更新(交换)为新值 B。如果 A 和 C 不相同,那说明在业务计算时,i 的值发生了变化,则不更新(交换)成 B。最后,CPU 会将旧的数值返回。而上述的一系列操作由 CPU 指令来保证是原子的。
在《Java 并发编程实践》中对 CAS 进行了更加通俗的描述:我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我原来的值是多少。
在上述路程中,我们可以很清晰的看到乐观锁的思路,而且这期间并没有使用到锁。因此,相对于 synchronized 等悲观锁的实现,效率要高非常多。
基于 CAS 的 AtomicInteger 使用
关于 CAS 的实现,最经典最常用的当属 AtomicInteger 了,我们马上就来看一下 AtomicInteger 是如何利用 CAS 实现原子性操作的。为了形成更新鲜明的对比,先来看一下如果不使用 CAS 机制,想实现线程安全我们通常如何处理。
在没有使用 CAS 机制时,为了保证线程安全,基于 synchronized 的实现如下:
至于上面的实例具体实现,这里不再展开,很多相关的文章专门进行讲解,我们只需要知道为了保证 i++的原子操作,在 increase 方法上使用了重量级的锁 synchronized,这会导致该方法的性能低下,所有调用该方法的操作都需要同步等待处理。
那么,如果采用基于 CAS 实现的 AtomicInteger 类,上述方法的实现便变得简单且轻量级了:
之所以可以如此安全、便捷地来实现安全操作,便是由于 AtomicInteger 类采用了 CAS 机制。下面,我们就来了解一下 AtomicInteger 的功能及源码实现。
CAS 的 AtomicInteger 类
AtomicInteger 是 java.util.concurrent.atomic 包下的一个原子类,该包下还有 AtomicBoolean, AtomicLong,AtomicLongArray, AtomicReference 等原子类,主要用于在高并发环境下,保证线程安全。
AtomicInteger 常用 API
AtomicInteger 类提供了如下常见的 API 功能:
上述方法中,getAndXXX 格式的方法都实现了原子操作。具体的使用方法参考上面的 addAndGet 案例即可。
AtomicInteger 核心源码
下面看一下 AtomicInteger 代码中的核心实现代码:
上述代码以 AtomicInteger#incrementAndGet 方法为例展示了 AtomicInteger 的基本实现。其中,在 static 静态代码块中,基于 Unsafe 类获取 value 字段相对当前对象的“起始地址”的偏移量,用于后续 Unsafe 类的处理。
在处理自增的原子操作时,使用的是 Unsafe 类中的 getAndAddInt 方法,CAS 的实现便是由 Unsafe 类的该方法提供,从而保证自增操作的原子性。
同时,在 AtomicInteger 类中,可以看到 value 值通过 volatile 进行修饰,保证了该属性值的线程可见性。在多并发的情况下,一个线程的修改,可以保证到其他线程立马看到修改后的值。
通过源码可以看出, AtomicInteger 底层是通过 volatile 变量和 CAS 两者相结合来保证更新数据的原子性。其中关于 Unsafe 类对 CAS 的实现,我们下面详细介绍。
CAS 的工作原理
CAS 的实现原理简单来说就是由 Unsafe 类和其中的自旋锁来完成的,下面针对源代码来看一下这两块的内容。
UnSafe 类
在 AtomicInteger 核心源码中,已经看到 CAS 的实现是通过 Unsafe 类来完成的,先来了解一下 Unsafe 类的作用。关于 Unsafe 类在之前的文章《各大框架都在使用的 Unsafe 类,到底有多神奇?》也有详细的介绍,大家可以参考,这里我们再简单概述一下。
sun.misc.Unsafe 是 JDK 内部用的工具类。它通过暴露一些 Java 意义上说“不安全”的功能给 Java 层代码,来让 JDK 能够更多的使用 Java 代码来实现一些原本是平台相关的、需要使用 native 语言(例如 C 或 C++)才可以实现的功能。该类不应该在 JDK 核心类库之外使用,这也是命名为 Unsafe(不安全)的原因。
JVM 的实现可以自由选择如何实现 Java 对象的“布局”,也就是在内存里 Java 对象的各个部分放在哪里,包括对象的实例字段和一些元数据之类。
Unsafe 里关于对象字段访问的方法把对象布局抽象出来,它提供了 objectFieldOffset()方法用于获取某个字段相对 Java 对象的“起始地址”的偏移量,也提供了 getInt、getLong、getObject 之类的方法可以使用前面获取的偏移量来访问某个 Java 对象的某个字段。在 AtomicInteger 的 static 代码块中便使用了 objectFieldOffset()方法。
Unsafe 类的功能主要分为内存操作、CAS、Class 相关、对象操作、数组相关、内存屏障、系统相关、线程调度等功能。这里我们只需要知道其功能即可,方便理解 CAS 的实现,注意不建议在日常开发中使用。
Unsafe 与 CAS
AtomicInteger 调用了 Unsafe#getAndAddInt 方法:
上述代码等于是 AtomicInteger 调用 UnSafe 类的 CAS 方法,JVM 帮我们实现出汇编指令,从而实现原子操作。
在 Unsafe 中 getAndAddInt 方法实现如下:
getAndAddInt 方法有三个参数:
第一个参数表示当前对象,也就是 new 的那个 AtomicInteger 对象;
第二个表示内存地址;
第三个表示自增步伐,在 AtomicInteger#incrementAndGet 中默认的自增步伐是 1。
getAndAddInt 方法中,首先把当前对象主内存中的值赋给 val5,然后进入 while 循环。判断当前对象此刻主内存中的值是否等于 val5,如果是,就自增(交换值),否则继续循环,重新获取 val5 的值。
在上述逻辑中核心方法是 compareAndSwapInt 方法,它是一个 native 方法,这个方法汇编之后是 CPU 原语指令,原语指令是连续执行不会被打断的,所以可以保证原子性。
在 getAndAddInt 方法中还涉及到一个实现自旋锁。所谓的自旋,其实就是上面 getAndAddInt 方法中的 do while 循环操作。当预期值和主内存中的值不等时,就重新获取主内存中的值,这就是自旋。
这里我们可以看到 CAS 实现的一个缺点:内部使用自旋的方式进行 CAS 更新(while 循环进行 CAS 更新,如果更新失败,则循环再次重试)。如果长时间都不成功的话,就会造成 CPU 极大的开销。
另外,Unsafe 类还支持了其他的 CAS 方法,比如 compareAndSwapObject、 compareAndSwapInt、compareAndSwapLong。更多关于 Unsafe 类的功能就不再展开,大家可以去看《各大框架都在使用的 Unsafe 类,到底有多神奇?》这篇文章。
CAS 的缺点
CAS 高效地实现了原子性操作,但在以下三方面还存在着一些缺点:
循环时间长,开销大;
只能保证一个共享变量的原子操作;
ABA 问题;
下面就这个三个问题详细讨论一下。
循环时间长开销大
在分析 Unsafe 源代码的时候我们已经提到,在 Unsafe 的实现中使用了自旋锁的机制。在该环节如果 CAS 操作失败,就需要循环进行 CAS 操作(do while 循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成 CPU 极大的开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升。
只能保证一个共享变量的原子操作
在最初的实例中,可以看出是针对一个共享变量使用了 CAS 机制,可以保证原子性操作。但如果存在多个共享变量,或一整个代码块的逻辑需要保证线程安全,CAS 就无法保证原子性操作了,此时就需要考虑采用加锁方式(悲观锁)保证原子性,或者有一个取巧的办法,把多个共享变量合并成一个共享变量进行 CAS 操作。
ABA 问题
虽然使用 CAS 可以实现非阻塞式的原子性操作,但是会产生 ABA 问题,ABA 问题出现的基本流程:
进程 P1 在共享变量中读到值为 A;
P1 被抢占了,进程 P2 执行;
P2 把共享变量里的值从 A 改成了 B,再改回到 A,此时被 P1 抢占;
P1 回来看到共享变量里的值没有被改变,于是继续执行;
虽然 P1 以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA 问题最容易发生在 lock free 的算法中的,CAS 首当其冲,因为 CAS 判断的是指针的地址。如果这个地址被重用了呢,问题就很大了(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)。
维基百科上给了一个形象的例子:你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,然后她很暖昧地挑逗着你,并趁你不注意,把用一个一模一样的手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了。
ABA 问题的解决思路就是使用版本号:在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A->B->A 就会变成 1A->2B->3A。
另外,从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
小结
本文从 CAS 的基本使用场景、基本流程、实现类 AtomicInteger 源码解析、CAS 的 Unsafe 实现解析、CAS 的缺点及解决方案等方面来全面了解了 CAS。通过这篇文章的学习想必你已经更加深刻的理解 CAS 机制了,如果对你有所帮助,记得关注一下,持续输出干货内容。
原文链接:https://mp.weixin.qq.com/s/oGCGcqfq3shq9KSd1eLCTw
评论