专业的编程技术博客社区

网站首页 > 博客文章 正文

“全栈2019”Java原子操作第十一章:CAS与ABA问题介绍与探讨

baijin 2024-09-26 06:59:35 博客文章 3 ℃ 0 评论

难度

初级

学习时间

30分钟

适合人群

零基础

开发语言

Java

开发环境

  • JDK v11
  • IntelliJIDEA v2018.3

友情提示

  • 本教学属于系列教学,内容具有连贯性,本章使用到的内容之前教学中都有详细讲解。
  • 本章内容针对零基础或基础较差的同学比较友好,可能对于有基础的同学来说很简单,希望大家可以根据自己的实际情况选择继续看完或等待看下一篇文章。谢谢大家的谅解!

1.温故知新

前面在《“全栈2019”Java原子操作第六章:AtomicInteger灵活的运算方式》一章中介绍了使用原子操作类AtomicInteger的方法实现更灵活的运算方式

《“全栈2019”Java原子操作第七章:AtomicLong介绍与使用》一章中介绍了什么是原子操作类AtomicLong

《“全栈2019”Java原子操作第八章:AtomicReference介绍与使用》一章中介绍了什么是原子操作类AtomicReference<V>

《“全栈2019”Java原子操作第九章:atomic包下原子数组介绍与使用》一章中介绍了什么是原子数组AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray<E>

《“全栈2019”Java原子操作第十章:atomic包下字段原子更新器介绍》一章中介绍了什么是字段原子更新器AtomicIntegerFieldUpdater<T>、AtomicLongFieldUpdater<T>和AtomicReferenceFieldUpdater<T,?V>。

现在介绍CAS算法中存在的ABA问题

2.什么是CAS算法?

我们之前在《“全栈2019”Java原子操作第三章:比较并交换CAS技术详解》一章中介绍了什么是比较并交换CAS技术

本文就不再赘述了。

这里展示CAS算法的实现(该类具体一步一步是怎么来的还请大家查阅上述文章):

接下来,我们选取实现了CAS算法的原子类之一AtomicInteger来作为本文演示的对象。

演示之后说明其中的ABA问题。

3.CAS示例

CAS算法中涉及到三个值:内存值、预期值和新值。

当且仅当内存值 == 预期值时,内存值 = 新值。

例如,我们定义一个AtomicInteger类型的变量value,初始内存值为0:

然后,在赋值之前获取一次value的值:

接着,调用其compareAndSet(int expectedValue, int newValue)方法进行赋值操作并输出方法返回结果:

然后,在赋值之后再获取一次value的值:

例子书写完毕。

运行程序,执行结果:

从运行结果来看,符合预期。

该例中,value的内存值为0,我们调用compareAndSet(int expectedValue, int newValue)方法时,给的预期值为0(即expectedValue=0),新值为1(即newValue=1)。

根据CAS算法“当且仅当内存值 == 预期值时,内存值 = 新值”原则,因为value==expectedValue,所以value=newValue,即value=1。

4.什么是ABA问题?

在CAS算法中存在这样一个问题:

假如一个值原来是A,现在变成了B,后来又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。

这个问题就是ABA问题。

不理解没关系,我们逐一来看。

假如一个值原来是A

例如,当前value=0。

现在变成了B

例如,value由0变为1,当前value=1。

后来又变成了A

例如,又将value从1变为0,当前value=0。

那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。

这句话说的什么意思呢?

就是说value最初是0,经过一系列操作之后它又回到了0,如果根据CAS算法“当且仅当内存值 == 预期值时,内存值 = 新值”原则来说的话,执行compareAndSet(0,1)赋值操作还是成功的,为什么呢?因为内存值还是0嘛,没有变。

我明明对value进行了一系列操作,你怎么能说value没有变呢?

ABA问题就由此产生了。

下面用程序来阐述一下ABA问题。

5.程序阐述ABA问题

根据这段描述来编写程序:

假如一个值原来是A,现在变成了B,后来又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。

首先,定义出原子变量value,初始值为0,也就是“假如一个值原来是A”:

然后,在改变value值之前先输出一下value的值:

接着,定义一个线程并重写run()方法:

在run()方法中调用value.compareAndSet(0, 1)方法,也就是“现在变成了B”:

然后,继续调用value.compareAndSet(1, 0)方法,也就是“后来又变成了A”:

该线程的run()方法书写完毕。

接着,再创建一个线程并重写run()方法:

然后,在run()方法中调用value.compareAndSet(0, 2)方法,如果最后value的值为2,那么就说明了这句话“那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了”:

该线程的run()方法也书写完毕。

接下来,我们按照“假如一个值原来是A,现在变成了B,后来又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。”这句话的顺序来执行程序。也就是先让thread1线程执行完,再让thread2线程执行完,最后主线程。

先启动thread1线程:

再延迟1秒钟启动thread2线程:

最后,我们延迟1秒钟输出value的最终值:

例子书写完毕。

运行程序,执行结果:

从运行结果来看,符合预期。结果没有错,但是逻辑是有问题的。

value的值被thread1线程改过两次,虽然最后一次把value的值改回最初值,但是对于哪些需要关注value中间变化过程的应用来说,这显然是个问题。

这就好比原值是链表结构的“我-爱-你”。

执行value.compareAndSet(“我”, “你-很-好”)之后变为“你-很-好”。

执行value.compareAndSet(“你”, “我-很-好”)之后变为“我-很-好”。

虽然头节点都是“我”,但原值早已不是那个最初的原值了。

6.如何解决CAS中的ABA问题?

我们只需在每次修改值的时候带上版本号即可。

例如,value=A,此时给value一个版本号0。

修改一次value,版本号变化一次(例如递增版本号)。

如果你想修改value的值,必须拿着预期值为A,版本号为0的方法才可以。

执行预期值为A,版本号为0的方法来修改value,value=B,版本号递增一次,变为1。

执行预期值为B,版本号为1的方法再次来修改value,value=A,版本号再递增一次,变为2。

如果此时其他线程想拿着预期值为A,版本号为0的方法来修改value是行不通的。

为此,Java提供了原子类AtomicStampedReference<V>和AtomicMarkableReference<V>来解决ABA问题。

6.带版本号的原子类AtomicStampedReference<V>

AtomicStampedReference<V>是一个带版本号的原子类,能以原子的方式更新对象的值。

AtomicStampedReference<V>类通过构造方法AtomicStampedReference(V initialRef, int initialStamp)来创建对象:

AtomicStampedReference(V initialRef, int initialStamp)方法参数解释:

V initialRef:初始值。

int initialStamp:初始版本号。

在用AtomicStampedReference<V>类改写上述例子之前,再介绍两个方法:

  • getReference()
  • compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)

其中,getReference()方法的作用是获取对象当前值;

compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)方法的作用是设置新值,CAS算法的体现;

另外,compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)方法中的参数解释如下:

V expectedReference:预期值。

V newReference:新值。

int expectedStamp:预期版本号。

int newStamp:新版本号。

当预期值==原值时,将新值赋给原值;

当预期版本号==原版本号时,将新版本号赋给原版本号;

还是上面的例子,用AtomicStampedReference<V>来解决例子中的ABA问题。

修改例子,用AtomicStampedReference<V>类替换AtomicInteger类:

然后,获取value的值的方式也发生了改变,调用其getReference()方法:

接着,thread1线程run()方法内部的compareAndSet()方法部分有所改变:

然后,thread2线程run()方法内部的compareAndSet()方法部分也有所改变:

最后,将我们获取value值的方法改为getReference()方法:

例子改写完毕。

运行程序,执行结果:

从运行结果来看,符合预期。thread2线程执行的compareAndSet(0, 2, 0, 1)方法失败了,说明我们的ABA问题也得到了解决。

虽然ABA问题解决了,但是我们应该在修复ABA问题之余提供AtomicStampedReference<V>的正确用法。

下面,我们对例子稍加更改,使其变为正常程序。

只需修改两处,第一处便是thread1线程run()方法:

第二处便是thread2线程run()方法:

例子改写完毕。

运行程序,执行结果:

从运行结果来看,符合预期。value的值从0经过中间几次改变,最终为2。

整个例子中用的最多的代码是value.compareAndSet(value.getReference(), 2, value.getStamp(), value.getStamp() + 1);

其中,只有newReference需要传入自定义变量,其他的要么是获取原值,要么是在原值的基础上累加。

7.带修改标记的原子类AtomicMarkableReference<V>

AtomicMarkableReference<V>是一个带修改标记的原子类,能以原子的方式更新对象的值。

AtomicMarkableReference<V>类通过构造方法AtomicMarkableReference(V initialRef, boolean initialMark)来创建对象:

AtomicMarkableReference(V initialRef, boolean initialMark)方法参数解释:

V initialRef:初始值。

boolean initialMark:初始修改标记。

在用AtomicMarkableReference<V>类改写上述例子之前,再介绍两个方法:

  • getReference()
  • isMarked()
  • compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark)

其中,getReference()方法的作用是获取对象当前值;

isMarked()方法的作用是获取当前对象修改标记。

compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark)方法的作用是设置新值,CAS算法的体现;

另外,compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark)方法中的参数解释如下:

V expectedReference:预期值。

V newReference:新值。

boolean expectedMark:预期修改标记。

boolean newMark:新修改标记。

当预期值==原值时,将新值赋给原值;

当修改标记==原修改标记时,将新修改标记赋给原修改标记;

还是上面的例子,用AtomicMarkableReference<V>来解决例子中的ABA问题。

修改例子,用AtomicMarkableReference<V>类替换AtomicStampedReference<V>类:

然后,thread1线程run()方法内部的compareAndSet()方法部分有所改变:

接着,thread2线程run()方法内部的compareAndSet()方法部分也有所改变:

例子改写完毕。

运行程序,执行结果:

从运行结果来看,符合预期。value的值从0经过中间几次改变,最终为2。

整个例子中用的最多的代码是value.compareAndSet(value.getReference(), 2, value.isMarked(), !value.isMarked());

其中,只有newReference需要传入自定义变量,其他的要么是获取原值,要么是在原值的基础上取反。

8.AtomicMarkableReference<V>类与AtomicStampedReference<V>类

首先,AtomicStampedReference<V>类和AtomicMarkableReference<V>类都是用来解决ABA问题的。

其次,如果你关系对象被修改的次数,那么请使用AtomicStampedReference<V>类;如果你不关心对象被修改了多少次,只关系对象是否被修改过,那么请使用AtomicMarkableReference<V>类。

最后,希望大家可以把这个例子照着写一遍,然后再自己默写一遍,方便以后碰到类似的面试题可以轻松应对。

祝大家编码愉快!

GitHub

本章程序GitHub地址:https://github.com/gorhaf/Java2019/tree/master/Thread/atomic/ABA

总结

  • CAS算法中涉及到三个值:内存值、预期值和新值。当且仅当内存值 == 预期值时,内存值 = 新值。
  • ABA问题:假如一个值原来是A,现在变成了B,后来又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。
  • AtomicStampedReference<V>是一个带版本号的原子类,能以原子的方式更新对象的值。
  • AtomicMarkableReference<V>是一个带修改标记的原子类,能以原子的方式更新对象的值。
  • 首先,AtomicStampedReference<V>类和AtomicMarkableReference<V>类都是用来解决ABA问题的。其次,如果你关系对象被修改的次数,那么请使用AtomicStampedReference<V>类;如果你不关心对象被修改了多少次,只关系对象是否被修改过,那么请使用AtomicMarkableReference<V>类。

至此,Java中CAS中的ABA问题相关内容讲解先告一段落,更多内容请持续关注。

答疑

如果大家有问题或想了解更多前沿技术,请在下方留言或评论,我会为大家解答。

上一章

“全栈2019”Java原子操作第十章:atomic包下字段原子更新器介绍

下一章

“全栈2019”Java原子操作第十二章:AtomicStampedReference详解

学习小组

加入同步学习小组,共同交流与进步。

  • 方式一:关注头条号Gorhaf,私信“Java学习小组”。
  • 方式二:关注公众号Gorhaf,回复“Java学习小组”。

全栈工程师学习计划

关注我们,加入“全栈工程师学习计划”。

版权声明

原创不易,未经允许不得转载!

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表