CAS 原理,看这一篇就够了!
一、核心思想
CAS 涉及到以下操作:
假设内存中的原数据 V,旧的预期值 A,需要修改的新值 B。
1、比较 A 与 V 是否相等。(比较)
2、如果比较相等,将 B 写入 V。(交换)
3、返回操作是否成功
二、CAS 基础实现
CAS 是 compare and swap 的简写,即比较并交换。它是指一种操作机制,而不是某个具体的类或方法。在 Java 平台上对这种操作进行了包装。在 Unsafe 类中,调用代码如下:
它需要三个参数,分别是内存位置 V,旧的预期值 A 和新的值 B。
操作时,先从内存位置读取到值,然后和预期值 A 比较。
如果相等,则将此内存位置的值改为新值 B,返回 true。
如果不相等,说明和其他线程冲突了,则不做任何改变,返回 false。
这种机制在不阻塞其他线程的情况下避免了并发冲突,比独占锁的性能高很多。 CAS 在 Java 的原子类和并发包中有大量使用。
三、重试机制(循环 CAS)
有很多文章说,CAS 操作失败后会一直重试直到成功,这种说法很不严谨。
第一,CAS 本身并未实现失败后的处理机制,它只负责返回成功或失败的布尔值,后续由调用者自行处理。只不过我们最常用的处理方式是重试而已。
第二,这句话很容易理解错,被理解成重新比较并交换。实际上失败的时候,原值已经被修改,如果不更改期望值,再怎么比较都会失败。而新值同样需要修改。
所以正确的方法是,使用一个死循环进行 CAS 操作,成功了就结束循环返回,失败了就重新从内存读取值和计算新值,再调用 CAS。看下 AtomicInteger 的源码就什么都懂了:
在 AtomicInteger 数据定义的部分,我们可以看到,其实实际存储的值是放在 value 中的,除此之外我们还获取了 unsafe 实例,并且定义了 valueOffset。
再看到 static 块,懂类加载过程的都知道,static 块的加载发生于类加载的时候,是最先初始化的,这时候我们调用 unsafe 的 objectFieldOffset 从 Atomic 类文件中获取 value 的偏移量,那么 valueOffset 其实就是记录 value 的偏移量的。
再回到上面一个函数 getAndAddInt,我们看 var5 获取的是什么,通过调用 unsafe 的 getIntVolatile(var1, var2),这是个 native 方法,具体实现到 JDK 源码里去看了,其实就是获取 var1 中,var2 偏移量处的值。var1 就是 AtomicInteger,var2 就是我们前面提到的 valueOffset,这样我们就从内存里获取到现在 valueOffset 处的值了。
现在重点来了,compareAndSwapInt(var1, var2, var5, var5 + var4)其实换成 compareAndSwapInt(obj, offset, expect, update)比较清楚:
如果 obj 内的 value 和 expect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update;
如果这一步的 CAS 没有成功,那就采用自旋的方式继续进行 CAS 操作;
其实这两个步骤,在 JNI 里是借助于一个 CPU 指令完成的。所以还是原子操作。
四、核心原理
CAS 底层使用 JNI 调用 C 代码实现,如果你有 Hotspot 源码,那么在 Unsafe.cpp 里可以找到实现:
我们可以看到 compareAndSwapInt 实现是在 Unsafe_CompareAndSwapInt 里面,再深入到 Unsafe_CompareAndSwapInt:
p 是取出的对象,addr 是 p 中 offset 处的地址,最后调用了 Atomic::cmpxchg(x, addr, e),其中参数 x 是即将更新的值,参数 e 是原内存的值。代码中能看到 cmpxchg 有基于各个平台的实现,这里我选择 Linux X86 平台下的源码分析:
这是一段小汇编,__asm__说明是 ASM 汇编,__volatile__禁止编译器优化:
os::is_MP 判断当前系统是否为多核系统,如果是就给总线加锁,所以同一芯片上的其他处理器就暂时不能通过总线访问内存,保证了该指令在多处理器环境下的原子性。
在正式解读这段汇编前,我们来了解下嵌入汇编的基本格式:
template:就是 cmpxchgl %1,(%3)表示汇编模板
output operands:表示输出操作数,=a 对应 eax 寄存器
input operand:表示输入参数,%1 就是 exchange_value, %3 是 dest, %4 就是 mp, r 表示任意寄存器,a 还是 eax 寄存器
list of clobbered registers:就是些额外参数,cc 表示编译器 cmpxchgl 的执行将影响到标志寄存器, memory 告诉编译器要重新从内存中读取变量的最新值,这点实现了 volatile 的感觉。
那么表达式其实就是 cmpxchgl exchange_value ,dest,我们会发现 %2 也就是 compare_value 没有用上,这里就要分析 cmpxchgl 的语义了。cmpxchgl 末尾 l 表示操作数长度为 4,上面已经知道了。cmpxchgl 会默认比较 eax 寄存器的值即 compare_value 和 exchange_value 的值,如果相等,就把 dest 的值赋值给 exchange_value,否则,将 exchange_value 赋值给 eax。具体汇编指令可以查看 Intel 手册 CMPXCHG
最终,JDK 通过 CPU 的 cmpxchgl 指令的支持,实现 AtomicInteger 的 CAS 操作的原子性。
五、底层实现
CAS 底层是靠调用 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架构中的 compare and exchange 指令。在多核的情况下,这个指令也不能保证原子性,需要在前面加上 lock 指令。
lock 指令可以保证一个 CPU 核心在操作期间独占一片内存区域。那么 这又是如何实现的呢?
在处理器中,一般有两种方式来实现上述效果:总线锁和缓存锁。在多核处理器的结构中,CPU 核心并不能直接访问内存,而是统一通过一条总线访问。
总线锁就是锁住这条总线,使其他核心无法访问内存。这种方式代价太大了,会导致其他核心停止工作。
缓存锁并不锁定总线,只是锁定某部分内存区域。当一个 CPU 核心将内存区域的数据读取到自己的缓存区后,它会锁定缓存对应的内存区域。锁住期间,其他核心无法操作这块内存区域。
CAS 就是通过这种方式实现比较和交换操作的原子性的。值得注意的是, CAS 只是保证了操作的原子性,并不保证变量的可见性,因此变量需要加上 volatile 关键字。
六、CAS 的问题
1、ABA 问题
CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新。但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。这就是 CAS 的 ABA 问题。
常见的解决思路是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么 A-B-A 就会变成 1A-2B-3A。
目前在 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2、循环时间长开销大
如果 CAS 不成功,则会原地自旋,如果长时间自旋会给 CPU 带来非常大的执行开销。
最后
LZ整理了一整套2023年最新的面试资料,如果有需要的小伙伴点击此处即可~
转载
作者:summer_west_fish
原文链接:https://blog.csdn.net/summer_fish/article/details/124980876
评论