什么是 CAS 机制?如何解决 ABA 问题?
什么是 CAS 机制?如何解决 ABA 问题?
什么是 CAS 机制?如何解决 ABA 问题?_Java_小问号的博客 - CSDN 博客
你知道什么是 CAS 机制吗?CAS 和 Synchronized 的区别是什么?适用场景呢?优点与缺点呢?
我们先来看一手代码:
启动两个线程,每个线程中让静态变量 count 循环累加 100 次。

该代码输出结果如下。因为这段代码是线程不安全的,所以自增结果很可能会小于 200. ![img]()
我们加上 synchronized 同步锁,再来看一下。

输出结果如下:

加了同步锁后,count 自增的操作变成了原子性操作,所以最终输出结果一定是 200,代码实现了线程安全。虽然 synchronized 确保了线程的安全,但是在有些情况下,这并不是最好的选择。
关键在于性能问题。
synchronized 关键字会让没获得锁资源的线程进入 BLOCKED(阻塞)状态,只有在争夺到锁资源的时候才转换成 RUNNABLE(运行)状态。这其中涉及到操作系统中用户模式和内核模式之间的切换,代价比较高。
同时,尽管 jdk 对 synchronized 关键字进行了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能依然比较低,所以面对这种只对单个变量进行原子性的操作,最好使用 jdk 自带的 “原子操作类”。
原子操作类,指的是 java.util.concurrent.atomic 包下,一系列以 Atomic 开头的包装类。例如 AtomicBoolean、AtomicInteger、AtomicXXX 都是分别对应 Boolean、Integer 或其他类型的原子性操作。
现在我们采用 AtomicInteger 类试一下:

输出结果如下:

使用原子操作类之后,最终的输出结果同样是 200,保证了线程安全。并且在某种情况下,该方案代码的性能会比 synchronized 更好。
而 Atomic 操作类的底层正是用到了 "CAS 机制"。
首先,CAS 的英文单词是 Compare and Swap,即是比较并替换。
CAS 机制中使用了 3 个基本操作数:内存地址 V,旧的预期值 A,需要替换的值 B。
它的规则是:当需要更新一个变量的值的时候,只有当变量的预期值 A 和内存地址 V 中的实际值相同的时候,才会把内存地址 V 对应的值替换成 B。
我们可以来看一个例子:
\1. 在内存地址 V 当中,存储着值为 10 的变量

\2. 此时线程 1 想要把变量的值增加 1,对于线程 1 而言,它旧的预期值 A=10,需要替换的最新值 B=11。

\3. 在线程 1 要提交更新之前,另外一个线程 2 抢先一步,将内存地址 V 中的值更新成了 11。

\4. 线程 1 开始提交更新的时候,按照 CAS 机制,首先进行 A 的值与内存地址 V 中的值进行比较,发现 A 不等于 V 中的实际值,于是提交失败。

\5. 线程 1 重新获取内存地址 V 的当前值,并重新计算想要修改的值。在现在而言,线程 1 旧的预期值 A=11,B=12. 这个重新尝试的过程被称为自旋。

\6. 这一次比较幸运,没有其他线程改变该变量的值,所以线程 1 进行 CAS 机制,比较旧的预期值 A 与内存地址 V 中的值,发现相同,此时可以替换。

\7. 线程 1 进行替换,把地址 V 的值替换成 B,也就是 12.

从思想上来看,synchronized 属于悲观锁,悲观的认为程序中的并发问题十分严重,所以严防死守,只让一个线程操作该代码块。而 CAS 属于乐观锁,乐观地认为程序中的并发问题并不那么严重,所以让线程不断的去尝试更新。
在 java 中除了上面提到的 Atomic 操作类,以及 Lock 系列类的底层实现,甚至在 jdk1.6 以上,在 synchronized 转变为重量级锁之前,也会采用 CAS 机制。
CAS 的优点自然是在并发问题不严重的时候性能比 synchronized 要快,缺点也有。
CAS 的缺点:
1.CPU 开销过大
在并发量比较高的时候,如果许多线程都尝试去更新一个变量的值,却又一直比较失败,导致提交失败,产生自旋,循环往复,会对 CPU 造成很大的压力和开销。
2. 不能确保代码块的原子性(注意是代码块)
CAS 机制所确保的是一个变量的原子性操作,而不能保证整个代码块的原子性,比如需要保证 3 个变量共同进行原子性的更新,就不得不使用 synchronized 或者 lock 了。
3.ABA 问题
这就是 CAS 最大的问题所在。下面说。
讲解了什么是 CAS 机制、CAS 与 synchronized 的区别、它的优点和缺点之后。
下面我们来介绍两个问题:
1.JAVA 中 CAS 的底层实现。
2.CAS 的 ABA 问题和解决方案。
我们使用 idea 查看一下 AtomicInteger 中常用的自增方法 incrementAndGet



incrementAndGet 调用的是 unsafe 的 getAndAddInt 方法并增加 1

其中 var1 是当前对象,var2 是内存地址 V 中的值,var6 是旧的期望值 A,var6+var4 则是需要替换的值 B。
可以看到,这一段代码是一个无限循环,也就是 CAS 的自旋,循环体中做了三件事:
\1. 获取当前的值,该方法使用 native 实现,底层是用其他语言实现的。
\2. 当前值 + var4,var4 就是上一个方法传进来的 1,计算出目标值 B
\3. 进行 CAS 操作,如果成功交换则跳出循环,如果失败则重复以上步骤。
那我们怎么保证 valueOffset 也就是 var2 是正确的内存中最新的值的呢?很简单,用 volatile 关键字来保证线程间的可见性,也就保证了是最新的值。
可以看到我们的代码始终和 unsafe 这个类相关,那什么是 unsafe 呢?java 语言不像 C,C++ 语言一样可以直接访问底层操作系统,但是 JVM 为我们开了一个后门,这个后门就是 unsafe,unsafe 为我们提供了硬件级别的原子操作。
至于 valueOffset 变量则是通过 unsafe 的 objectFieldOffset 获得,所代表的就是 AtomicInteger 对象 value 成员变量在内存中的偏移量。我们可以简单的把 valueOffset 理解成 value 变量的内存地址也就是 V 了。
我们前面说过,CAS 机制中使用了三种基本操作数:内存地址 V,旧的期望值 A,新的需要替换的值 B。
而 unsafe 的 compareAndSwapXxx 方法中的参数 var2 则就代表 valueOffset(内存地址 V),var6 代表 A,var6+var4 代表 B。
正是 unsafe 的 compareAndSwapXxx 方法保证了比较和替换之间的原子性操作!
现在我们来说下什么是 ABA 问题。
\1. 假设内存中有一个值为 A 的变量,存储在内存地址 V 中。

\2. 此时有三个线程想要使用 CAS 的方式更新这个变量的值,每个线程的执行时间有略微偏差。线程 1 和线程 2 已经获取当前值,线程 3 还未获取当前值。

\3. 接下来,线程 1 先一步执行成功,把当前值成功从 A 更新为 B;同时线程 2 因某种原因阻塞住,没有做更新操作,此时线程 3 在线程 1 更新之后,获取了当前值 B。

\4. 在之后,线程 2 仍然处于阻塞状态,线程 3 继续执行,成功把当前值从 B 更新成 A。

\5. 最后,线程 2 终于恢复了运行状态,由于阻塞之前已经获得到了” 当前值 A“,并且经过 compare 检测,内存地址 V 中的实际值也是 A,所以成功把变量 A 的值更新为 B。

看起来这个例子没有什么问题,但如果结合实际,就可以发现它的问题所在。
我们假设一个取款机的例子。假如有一个遵循 CAS 机制的取款机。小肖有 100 元存款,需要提取 50 元。但由于取款机硬件出现了问题,导致取款操作同时提交了两遍,开启了两个线程,两个线程都是获取当前值 100 元,要更新成 50 元。
理想情况下,应该一个线程更新成功,一个线程更新失败,小肖的存款只扣除一次,余额为 50.

线程 1 首先执行成功,把余额 100 更新为 50,同时线程 2 由于某种原因陷入了阻塞状态,这时候,小肖的妈妈汇款给了小肖 50 元。

线程 2 仍然是阻塞状态,线程 3 此时执行成功,把余额从 50 改成了 100.

这时候,线程 2 恢复运行,由于之前阻塞的时候获得了” 当前值 “100,并且经过 compare 检测,此时存款也的确是 100 元,所以成功把变量值从 100 更新成 50.

原本线程 2 应当提交失败,小肖的正确余额应该保持 100 元,结果由于 ABA 问题提交成功了。
这就是所谓的 ABA 问题,那么怎么解决呢?
添加版本号解决 ABA 问题
真正要做到严谨的 CAS 机制,我们在 compare 阶段不仅需要比较内存地址 V 中的值是否和旧的期望值 A 相同,还需要比较变量的版本号是否一致。
我们仍然以刚才的例子来说明,假设地址 V 中存储着变量值 A,当前版本号是 01. 线程 1 获取了当前值 A 和版本号 01,想要更新为 B,此时线程 1 陷入了阻塞状态。

这时候,内存地址 V 中的变量进行了多次改变,版本号提升到 03,但是变量值仍然是 A。

随后,线程 1 恢复运行,进行 compare 操作。首先经过比较,内存地址 V 中的值与当前值 A 相同,但是版本号不相同,所以这一次更新失败。

在 Java 中,AtomicStampedReference 类就实现了用版本号做比较的 CAS 机制。
1.Java 语言 CAS 底层是如何实现的?
答:利用 unsafe 提供的原子性操作方法。
2. 什么是 ABA 问题?怎么解决?
答:当一个值从 A 更新为 B,再从 B 更新为 A,普通 CAS 机制会误判通过检测。解决方案是使用版本号,通过比较值和版本号才判断是否可以替换。
全文完


浙公网安备 33010602011771号