热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

并发编程CAS机制原理分析及ABA问题解决

CAS:微信公众号一、为什么需要CAS机制?为什么需要CAS机制呢?我们先从一个错误现象谈起。我们经常使用volatile关键字修饰某一个变量&#x
CAS:微信公众号

一、为什么需要CAS机制?

为什么需要CAS机制呢?我们先从一个错误现象谈起。我们经常使用volatile关键字修饰某一个变量,表明这个变量是全局共享的一个变量,同时具有了可见性和有序性。但是却没有原子性。比如说一个常见的操作a++。这个操作其实可以细分成三个步骤:

(1)从内存中读取a

(2)对a进行加1操作

(3)将a的值重新写入内存中

在单线程状态下这个操作没有一点问题,但是在多线程中就会出现各种各样的问题了。因为可能一个线程对a进行了加1操作,还没来得及写入内存,其他的线程就读取了旧值。造成了线程的不安全现象。如何去解决这个问题呢?最常见的方式就是使用AtomicInteger来修饰a。我们可以看一下代码:

public class Test3 {//使用AtomicInteger定义astatic AtomicInteger a &#61; new AtomicInteger();public static void main(String[] args) {Test3 test &#61; new Test3();Thread[] threads &#61; new Thread[5];for (int i &#61; 0; i <5; i&#43;&#43;) {threads[i] &#61; new Thread(() -> {try {for (int j &#61; 0; j <10; j&#43;&#43;) {//使用getAndIncrement函数进行自增操作System.out.println(a.incrementAndGet()); Thread.sleep(500);}} catch (Exception e) {e.printStackTrace();}});threads[i].start();}}
}

现在我们使用AtomicInteger类并且调用了incrementAndGet方法来对a进行自增操作。这个incrementAndGet是如何实现的呢&#xff1f;我们可以看一下AtomicInteger的源码。
 

/*** Atomically increments by one the current value.* &#64;return the updated value*/public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) &#43; 1;}

我们到这一步可以看到其实就是usafe调用了getAndAddInt的方法实现的&#xff0c;但是现在我们还看不出什么&#xff0c;我们再深入到源码中看看getAndAddInt方法又是如何实现的&#xff0c;

public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 &#61; this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 &#43; var4)); return var5;
}

到了这一步就稍微有点眉目了&#xff0c;原来底层调用的是compareAndSwapInt方法&#xff0c;这个compareAndSwapInt方法其实就是CAS机制。因此如果我们想搞清楚AtomicInteger的原子操作是如何实现的&#xff0c;我们就必须要把CAS机制搞清楚&#xff0c;这也是为什么我们需要掌握CAS机制的原因。

二、分析CAS

1、基本含义

CAS全拼又叫做compareAndSwap&#xff0c;从名字上的意思就知道是比较交换的意思。比较交换什么呢&#xff1f;

过程是这样&#xff1a;它包含 3 个参数 CAS&#xff08;V&#xff0c;E&#xff0c;N&#xff09;&#xff0c;V表示要更新变量的值&#xff0c;E表示预期值&#xff0c;N表示新值。仅当 V值等于E值时&#xff0c;才会将V的值设为N&#xff0c;如果V值和E值不同&#xff0c;则说明已经有其他线程做两个更新&#xff0c;则当前线程则什么都不做。最后&#xff0c;CAS 返回当前V的真实值。

我们举一个我之前举过的例子来说明这个过程&#xff1a;

比如说给你儿子订婚。你儿子就是内存位置&#xff0c;你原本以为你儿子是和杨贵妃在一起了&#xff0c;结果在订婚的时候发现儿子身边是西施。这时候该怎么办呢&#xff1f;你一气之下不做任何操作。如果儿子身边是你预想的杨贵妃&#xff0c;你一看很开心就给他们订婚了&#xff0c;也叫作执行操作。现在你应该明白了吧。

CAS 操作时抱着乐观的态度进行的&#xff0c;它总是认为自己可以成功完成操作。所以CAS也叫作乐观锁&#xff0c;那什么是悲观锁呢&#xff1f;悲观锁就是我们之前赫赫有名的synchronized。悲观锁的思想你可以这样理解&#xff0c;一个线程想要去获得这个锁但是却获取不到&#xff0c;必须要别人释放了才可以。

2、底层原理

想要弄清楚其底层原理&#xff0c;深入到源码是最好的方式&#xff0c;在上面我们已经通过源码看到了其实就是Usafe的方法来完成的&#xff0c;在这个方法中使用了compareAndSwapInt这个CAS机制。因此&#xff0c;现在我们有必要进一步深入进去看看&#xff1a;

public final class Unsafe {// compareAndSwapInt 是 native 类型的方法public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);//剩余还有很多方法
}

我们可以看到这里面主要有四个参数&#xff0c;第一个参数就是我们操作的对象a&#xff0c;第二个参数是对象a的地址偏移量&#xff0c;第三个参数表示我们期待这个a是什么值&#xff0c;第四个参数表示的是a的实际值。

不过这里我们会发现这个compareAndSwapInt是一个native方法&#xff0c;也就是说再往下走就是C语言代码&#xff0c;如果我们保持好奇心&#xff0c;可以继续深入进去看看。

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))UnsafeWrapper("Unsafe_CompareAndSwapInt");oop p &#61; JNIHandles::resolve(obj);// 根据偏移量valueOffset&#xff0c;计算 value 的地址jint* addr &#61; (jint *) index_oop_from_field_offset_long(p, offset);// 调用 Atomic 中的函数 cmpxchg来进行比较交换return (jint)(Atomic::cmpxchg(x, addr, e)) &#61;&#61; e;
UNSAFE_END

上面的代码我们解读一下&#xff1a;首先使用jint计算了value的地址&#xff0c;然后根据这个地址&#xff0c;使用了Atomic的cmpxchg方法进行比较交换。现在问题又抛给了这个cmpxchg&#xff0c;真实实现的是这个函数。我们再进一步深入看看&#xff0c;真相已经离我们不远了。

unsigned Atomic::cmpxchg(unsigned int exchange_value,volatile unsigned int* dest, unsigned int compare_value) {assert(sizeof(unsigned int) &#61;&#61; sizeof(jint), "more work to do");/** 根据操作系统类型调用不同平台下的重载函数&#xff0c;这个在预编译期间编译器会决定调用哪个平台下的重载函数*/return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest, (jint)compare_value);
}

皮球又一次被完美的踢走了&#xff0c;现在在不同的操作系统下会调用不同的cmpxchg重载函数&#xff0c;我现在用的是win10系统&#xff0c;所以我们看看这个平台下的实现&#xff0c;别着急再往下走走&#xff1a;

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {int mp &#61; os::is_MP();__asm {mov edx, destmov ecx, exchange_valuemov eax, compare_valueLOCK_IF_MP(mp)cmpxchg dword ptr [edx], ecx}
}

这块的代码就有点涉及到汇编指令相关的代码了&#xff0c;到这一步就彻底接近真相了&#xff0c;首先三个move指令表示的是将后面的值移动到前面的寄存器上。然后调用了LOCK_IF_MP和下面cmpxchg汇编指令进行了比较交换。现在我们不知道这个LOCK_IF_MP和cmpxchg是如何交换的&#xff0c;没关系我们最后再深入一下。

真相来了&#xff0c;他来了&#xff0c;他真的来了。

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {//1、 判断是否是多核 CPUint mp &#61; os::is_MP();__asm {//2、 将参数值放入寄存器中mov edx, dest mov ecx, exchange_valuemov eax, compare_value //3、LOCK_IF_MP指令cmp mp, 0//4、 如果 mp &#61; 0&#xff0c;表明线程运行在单核CPU环境下。此时 je 会跳转到 L0 标记处&#xff0c;直接执行 cmpxchg 指令je L0_emit 0xF0
//5、这里真正实现了比较交换
L0:/** 比较并交换。简单解释一下下面这条指令&#xff0c;熟悉汇编的朋友可以略过下面的解释:* cmpxchg: 即“比较并交换”指令* dword: 全称是 double word 表示两个字&#xff0c;一共四个字节* ptr: 全称是 pointer&#xff0c;与前面的 dword 连起来使用&#xff0c;表明访问的内存单元是一个双字单元 * 这一条指令的意思就是&#xff1a;将 eax 寄存器中的值&#xff08;compare_value&#xff09;与 [edx] 双字内存单元中的值进行对比&#xff0c;如果相同&#xff0c;则将 ecx 寄存器中的值&#xff08;exchange_value&#xff09;存入 [edx] 内存单元中。*/cmpxchg dword ptr [edx], ecx}
}

到这一步了&#xff0c;相信你应该理解了这个CAS真正实现的机制了吧&#xff0c;最终是由操作系统的汇编指令完成的。

3、CAS机制的优缺点

&#xff08;1&#xff09;优点

一开始在文中我们曾经提到过&#xff0c;cas是一种乐观锁&#xff0c;而且是一种非阻塞的轻量级的乐观锁&#xff0c;什么是非阻塞式的呢&#xff1f;其实就是一个线程想要获得锁&#xff0c;对方会给一个回应表示这个锁能不能获得。在资源竞争不激烈的情况下性能高&#xff0c;相比synchronized重量锁&#xff0c;synchronized会进行比较复杂的加锁&#xff0c;解锁和唤醒操作。

&#xff08;2&#xff09;缺点

缺点也是一个非常重要的知识点&#xff0c;因为涉及到了一个非常著名的问题&#xff0c;叫做ABA问题。假设一个变量 A &#xff0c;修改为 B之后又修改为 A&#xff0c;CAS 的机制是无法察觉的&#xff0c;但实际上已经被修改过了。这就是ABA问题&#xff0c;

 

ABA:https://www.cnblogs.com/lmj612/p/10836912.html

ABA问题会带来大量的问题&#xff0c;比如说数据不一致的问题等等。我们可以举一个例子来解释说明。

你有一瓶水放在桌子上&#xff0c;别人把这瓶水喝完了&#xff0c;然后重新倒上去。你再去喝的时候发现水还是跟之前一样&#xff0c;就误以为是刚刚那杯水。如果你知道了真相&#xff0c;那是别人用过了你还会再用嘛&#xff1f;举一个比较黄一点的例子&#xff0c;一个女孩变了心之后又回来&#xff0c;还是之前的那个女娃嘛&#xff1f;其实本质已经变了&#xff0c;但是自己从外观来看好像并没有变化。


1、基本的ABA问题

在CAS算法中&#xff0c;需要取出内存中某时刻的数据(由用户完成)&#xff0c;在下一时刻比较并交换(CPU保证原子操作)&#xff0c;这个时间差会导致数据的变化。
假设有以下顺序事件&#xff1a;

1、线程1从内存位置V中取出A
2、线程2从内存位置V中取出A
3、线程2进行了写操作&#xff0c;将B写入内存位置V
4、线程2将A再次写入内存位置V
5、线程1进行CAS操作&#xff0c;发现V中仍然是A&#xff0c;交换成功

尽管线程1的CAS操作成功&#xff0c;但线程1并不知道内存位置V的数据发生过改变

2、ABA问题示例

public class ABADemo {private static AtomicReference atomicReference &#61; new AtomicReference(100);public static void main(String[] args) {new Thread(() -> {atomicReference.compareAndSet(100, 101);atomicReference.compareAndSet(101, 100);},"t1").start();new Thread(() -> {try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(atomicReference.compareAndSet(100, 2019) &#43; "\t修改后的值:" &#43; atomicReference.get());},"t2").start();}
}

  • 初始值为100&#xff0c;线程t1将100改成101&#xff0c;然后又将101改回100
  • 线程t2先睡眠1秒&#xff0c;等待t1操作完成&#xff0c;然后t2线程将值改成2019
  • 可以看到&#xff0c;线程2修改成功

输出结果&#xff1a;

true 修改后的值:2019

3、ABA问题解决

要解决ABA问题&#xff0c;可以增加一个版本号&#xff0c;当内存位置V的值每次被修改后&#xff0c;版本号都加1

AtomicStampedReference

AtomicStampedReference内部维护了对象值和版本号&#xff0c;在创建AtomicStampedReference对象时&#xff0c;需要传入初始值和初始版本号&#xff0c;
当AtomicStampedReference设置对象值时&#xff0c;对象值以及状态戳都必须满足期望值&#xff0c;写入才会成功

示例

public class ABADemo {private static AtomicStampedReference atomicStampedReference &#61; new AtomicStampedReference(100,1);public static void main(String[] args) {new Thread(() -> {System.out.println("t1拿到的初始版本号:" &#43; atomicStampedReference.getStamp());//睡眠1秒&#xff0c;是为了让t2线程也拿到同样的初始版本号try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}atomicStampedReference.compareAndSet(100, 101,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()&#43;1);atomicStampedReference.compareAndSet(101, 100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()&#43;1);},"t1").start();new Thread(() -> {int stamp &#61; atomicStampedReference.getStamp();System.out.println("t2拿到的初始版本号:" &#43; stamp);//睡眠3秒&#xff0c;是为了让t1线程完成ABA操作try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("最新版本号:" &#43; atomicStampedReference.getStamp());System.out.println(atomicStampedReference.compareAndSet(100, 2019,stamp,atomicStampedReference.getStamp() &#43; 1) &#43; "\t当前 值:" &#43; atomicStampedReference.getReference());},"t2").start();}
}

1、初始值100&#xff0c;初始版本号1
2、线程t1和t2拿到一样的初始版本号
3、线程t1完成ABA操作&#xff0c;版本号递增到3
4、线程t2完成CAS操作&#xff0c;最新版本号已经变成3&#xff0c;跟线程t2之前拿到的版本号1不相等&#xff0c;操作失败

输出结果&#xff1a;

t1拿到的初始版本号:1
t2拿到的初始版本号:1
最新版本号:3
false 当前 值:100

AtomicMarkableReference

AtomicStampedReference可以给引用加上版本号&#xff0c;追踪引用的整个变化过程&#xff0c;如&#xff1a;A -> B -> C -> D - > A&#xff0c;通过AtomicStampedReference&#xff0c;我们可以知道&#xff0c;引用变量中途被更改了3次
但是&#xff0c;有时候&#xff0c;我们并不关心引用变量更改了几次&#xff0c;只是单纯的关心是否更改过&#xff0c;所以就有了AtomicMarkableReference
AtomicMarkableReference的唯一区别就是不再用int标识引用&#xff0c;而是使用boolean变量——表示引用变量是否被更改过

示例

public class ABADemo {private static AtomicMarkableReference atomicMarkableReference &#61; new AtomicMarkableReference(100,false);public static void main(String[] args) {new Thread(() -> {System.out.println("t1版本号是否被更改:" &#43; atomicMarkableReference.isMarked());//睡眠1秒&#xff0c;是为了让t2线程也拿到同样的初始版本号try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}atomicMarkableReference.compareAndSet(100, 101,atomicMarkableReference.isMarked(),true);atomicMarkableReference.compareAndSet(101, 100,atomicMarkableReference.isMarked(),true);},"t1").start();new Thread(() -> {boolean isMarked &#61; atomicMarkableReference.isMarked();System.out.println("t2版本号是否被更改:" &#43; isMarked);//睡眠3秒&#xff0c;是为了让t1线程完成ABA操作try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("是否更改过:" &#43; atomicMarkableReference.isMarked());System.out.println(atomicMarkableReference.compareAndSet(100, 2019,isMarked,true) &#43; "\t当前 值:" &#43; atomicMarkableReference.getReference());},"t2").start();}
}

1、初始值100&#xff0c;初始版本号未被修改 false
2、线程t1和t2拿到一样的初始版本号都未被修改 false
3、线程t1完成ABA操作&#xff0c;版本号被修改 true
4、线程t2完成CAS操作&#xff0c;版本号已经变成true&#xff0c;跟线程t2之前拿到的版本号false不相等&#xff0c;操作失败

输出结果&#xff1a;

t1版本号是否被更改:false
t2版本号是否被更改:false
是否更改过:true
false 当前 值:100

 


推荐阅读
  • 深入理解Java虚拟机的并发编程与性能优化
    本文主要介绍了Java内存模型与线程的相关概念,探讨了并发编程在服务端应用中的重要性。同时,介绍了Java语言和虚拟机提供的工具,帮助开发人员处理并发方面的问题,提高程序的并发能力和性能优化。文章指出,充分利用计算机处理器的能力和协调线程之间的并发操作是提高服务端程序性能的关键。 ... [详细]
  • Android中高级面试必知必会,积累总结
    本文介绍了Android中高级面试的必知必会内容,并总结了相关经验。文章指出,如今的Android市场对开发人员的要求更高,需要更专业的人才。同时,文章还给出了针对Android岗位的职责和要求,并提供了简历突出的建议。 ... [详细]
  • 本文详细介绍了Java中vector的使用方法和相关知识,包括vector类的功能、构造方法和使用注意事项。通过使用vector类,可以方便地实现动态数组的功能,并且可以随意插入不同类型的对象,进行查找、插入和删除操作。这篇文章对于需要频繁进行查找、插入和删除操作的情况下,使用vector类是一个很好的选择。 ... [详细]
  • Java中包装类的设计原因以及操作方法
    本文主要介绍了Java中设计包装类的原因以及操作方法。在Java中,除了对象类型,还有八大基本类型,为了将基本类型转换成对象,Java引入了包装类。文章通过介绍包装类的定义和实现,解答了为什么需要包装类的问题,并提供了简单易用的操作方法。通过本文的学习,读者可以更好地理解和应用Java中的包装类。 ... [详细]
  • 预备知识可参考我整理的博客Windows编程之线程:https:www.cnblogs.comZhuSenlinp16662075.htmlWindows编程之线程同步:https ... [详细]
  • Week04面向对象设计与继承学习总结及作业要求
    本文总结了Week04面向对象设计与继承的重要知识点,包括对象、类、封装性、静态属性、静态方法、重载、继承和多态等。同时,还介绍了私有构造函数在类外部无法被调用、static不能访问非静态属性以及该类实例可以共享类里的static属性等内容。此外,还提到了作业要求,包括讲述一个在网上商城购物或在班级博客进行学习的故事,并使用Markdown的加粗标记和语句块标记标注关键名词和动词。最后,还提到了参考资料中关于UML类图如何绘制的范例。 ... [详细]
  • HashMap的相关问题及其底层数据结构和操作流程
    本文介绍了关于HashMap的相关问题,包括其底层数据结构、JDK1.7和JDK1.8的差异、红黑树的使用、扩容和树化的条件、退化为链表的情况、索引的计算方法、hashcode和hash()方法的作用、数组容量的选择、Put方法的流程以及并发问题下的操作。文章还提到了扩容死链和数据错乱的问题,并探讨了key的设计要求。对于对Java面试中的HashMap问题感兴趣的读者,本文将为您提供一些有用的技术和经验。 ... [详细]
  • 本文分享了一个关于在C#中使用异步代码的问题,作者在控制台中运行时代码正常工作,但在Windows窗体中却无法正常工作。作者尝试搜索局域网上的主机,但在窗体中计数器没有减少。文章提供了相关的代码和解决思路。 ... [详细]
  • Java序列化对象传给PHP的方法及原理解析
    本文介绍了Java序列化对象传给PHP的方法及原理,包括Java对象传递的方式、序列化的方式、PHP中的序列化用法介绍、Java是否能反序列化PHP的数据、Java序列化的原理以及解决Java序列化中的问题。同时还解释了序列化的概念和作用,以及代码执行序列化所需要的权限。最后指出,序列化会将对象实例的所有字段都进行序列化,使得数据能够被表示为实例的序列化数据,但只有能够解释该格式的代码才能够确定数据的内容。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • 本文介绍了Java高并发程序设计中线程安全的概念与synchronized关键字的使用。通过一个计数器的例子,演示了多线程同时对变量进行累加操作时可能出现的问题。最终值会小于预期的原因是因为两个线程同时对变量进行写入时,其中一个线程的结果会覆盖另一个线程的结果。为了解决这个问题,可以使用synchronized关键字来保证线程安全。 ... [详细]
  • Java学习笔记之面向对象编程(OOP)
    本文介绍了Java学习笔记中的面向对象编程(OOP)内容,包括OOP的三大特性(封装、继承、多态)和五大原则(单一职责原则、开放封闭原则、里式替换原则、依赖倒置原则)。通过学习OOP,可以提高代码复用性、拓展性和安全性。 ... [详细]
  • 本文讨论了clone的fork与pthread_create创建线程的不同之处。进程是一个指令执行流及其执行环境,其执行环境是一个系统资源的集合。在调用系统调用fork创建一个进程时,子进程只是完全复制父进程的资源,这样得到的子进程独立于父进程,具有良好的并发性。但是二者之间的通讯需要通过专门的通讯机制,另外通过fork创建子进程系统开销很大。因此,在某些情况下,使用clone或pthread_create创建线程可能更加高效。 ... [详细]
  • 本文介绍了Python爬虫技术基础篇面向对象高级编程(中)中的多重继承概念。通过继承,子类可以扩展父类的功能。文章以动物类层次的设计为例,讨论了按照不同分类方式设计类层次的复杂性和多重继承的优势。最后给出了哺乳动物和鸟类的设计示例,以及能跑、能飞、宠物类和非宠物类的增加对类数量的影响。 ... [详细]
  • OO第一单元自白:简单多项式导函数的设计与bug分析
    本文介绍了作者在学习OO的第一次作业中所遇到的问题及其解决方案。作者通过建立Multinomial和Monomial两个类来实现多项式和单项式,并通过append方法将单项式组合为多项式,并在此过程中合并同类项。作者还介绍了单项式和多项式的求导方法,并解释了如何利用正则表达式提取各个单项式并进行求导。同时,作者还对自己在输入合法性判断上的不足进行了bug分析,指出了自己在处理指数情况时出现的问题,并总结了被hack的原因。 ... [详细]
author-avatar
00我就是我00乐乐
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有