多线程(二)CAS算法和ABA问题

前提要点
JMM,在每个线程启动的时候,JVM虚拟机都会为这个线程开辟一个内存空间,他们进行数据运算都是在自己的内存空间中进行的,而变量的可见性是多线程共同合作计算的基础,在多线程编程中,大量的使用了Volatile关键字,根据JMM规范,它能保证内存数据在计算时的可见性和机制指令在CPU执行期间的排序机制,但无法保证原子性。而volatile关键字是怎么做到这一点的呢?
在这里插入图片描述

CAS算法

简洁
CAS简洁而不简单,简洁到一句话就能说完CAS算法是什么,比较并交换。如下图,使用原子类型对象说明问题。比较当前在线程内存工作空间中操作的数据的值跟主内存中的是否一样,如果一样再改变它,不一样则不改变。在这里插入图片描述
简洁而不简单
当点开这个方法的底层代码的时候,会发现有两个重要的机制保证了CAS算法的成功执行。如下图,它调用的是unsafe类的一个compareAndSwapInt()方法,this代表的是当前对象valueoffset则是内存地址偏移量,expect代表的是期望值,update则是更新值。
在这里插入图片描述
unsafe类
Unsafe类位于JDK的rt包(Run Time运行时刻)sun.misc包下。其实不应该对这个包陌生,所有面向对象语言都会有一个机制,那就是运行时类型识别(RTTI ),还有Java的异常掷出机制,这在一些比较严谨的编程场合经常使用到,比如连接资源的时候将释放连接资源写在try-catch-finally的finally语句块中,保证它一定能够被释放,以及之后在多线程知识中的重入锁(ReentrantLock)中,为了保证锁一定会被释放也会将代码写入到finally语句块中。rt包就是jdk将这些在运行期间处理的库。
首先来看一看这个unsafe长什么样,如下截图:
在这里插入图片描述
在这个类里面大量的使用了native关键字。一般来说,在java的底层源码中才会出现的一个关键字,这是一个申明调用其他语言的关键字。这也是CAS为什么能够保证内存可见性的原因,结论如下:

CAS的底层到底是什么?
CAS的全称是Compare-And-Swap ,它是一条CPU并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是,则更改为新的值,这个过程是原子的。调用Unsafe类中的CAS方法,JVM就会帮助我们实现CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成,用于完成某一个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成数据不一致问题。
总结:
1、Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsage类存在于sun.misc包中,其内部方法操作可以像C指针一样直接操作内存,因为Java中CAS操作的执行都依赖于Unsafe类的方法。
Unsafe类中所有的方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应的任务。
2、变量valueOffset,表示的是改变量在内存中的偏移地址,因为Unsafe就是根据内存偏移地址来获取数据的。
3、总之,在进行多线程编程的时候,变量volatile修饰,就保证了多线程之间的内存可见性。

自旋
CAS到这里还没完,后面还有两个大的概念,首先就是自旋。不妨把unsafe类中一个使用了CAS的方法来看,截图如下:
在这里插入图片描述
这段代码的逻辑大致如下:var1是一个需要进行原子操作的变量,这里可以假设就是原子类,AtimicInteger,var2则是这个对象在内存中的地址,var4则是需要变动的数量,也就是加1操作,而var5则是这个对象通过内存寻址在主内存中找到的当前真实值。
所以,代码逻辑是:用该对象当前的值与主存真实值var5比较,如果相同,更新var5+var4并且返回true
如果不同,继续取值然后再比较,直到将数据更新完成。
也就是说,如果内存中的值和当前对象的值不一样的时候,这个循环判断会一直进行下去,不会终止!
没错,实际生产中的情况就是,多线程编程如果没有加锁,那么判断数据不能用 if else这种在单线程中的语句,只能用while判断。这就是自旋锁

所以,原子类的getAndIncrement方法(变量加1方法)底层其实是CAS思想,靠的是Unsafe类的CPU指令原语来保证原子性,也就是说原子性靠的是Unsafe类。
模拟一次多线程操作数据时的情景:

假设线程A和线程B同时执行getAndAddInt操作(分别跑在不同的CPU上):
1、AtomicInteger里面的value原始值为3,也就是主内存中AtomicInteger的Value为3,由于变量是被volatile修饰的,根据JMM模型线程A和线程B都各自持有一份值为3的value副本跟别到各自的工作内存。
2、线程A通过getIntVolatile(var1,var2)来拿到value3的值,这时线程A被挂起。
3、线程B也通过getInVolatile(var1,var2)方法获取到value的值为3,此时刚好线程B没有被挂起并且执行compareAndSwapInt方法比较内存值也是3,成功修改内存值为4,线程B收工。
4、这时线程A恢复,执行compareAndSwaoInt方法比较,发现自己手里的数值3跟主内存的数字不一样,说明已经被修改过了,那线程A本次修改失败,只能重新读取再来一遍。
5、线程A重新获取value的值,因为变量value被volatile修饰,所以其他线程对它的修改,线程A总是可见的,线程A继续执行compareAndSwapInt进行比较交换,直到成功。

如下图,线程之间一直在频繁的读取跟写入数据到主存,由于变量ai是内存可见的,所以一个线程在发现数据已经改变的时候,只能通过循环去保证数据一致性。再次强调:volatile关键字能保证内存可见性,但是不能保证原子性,所以a的计算会出现线程不安全问题,而ai是用原子类包装之后的int类型,已经完全满足了JMM模型,所以变量ai的计算不会出现线程安全问题。
在这里插入图片描述
好了,关于CAS最后再去看一看在native方法(使用C/C++语言编写,给java调用的方法)是怎么完成的。
下载OpenJDK,unsafe的C++文件目录是\openjdk\hotspot\src\share\vm\prims\unsafe.cpp
简单看一下即可:
在这里插入图片描述

synchronized是对修饰的方法,对整段代码进行了加锁,拿它对比一下CAS,sync的方式能保证数据一致性的同事并发性会下降,CAS不加锁,也能保证一致性,但其实是进行了多次比较。所以,CAS还是会有它的缺点:自旋循环的消耗性能,只能保证一个变量,还有最重要的就是会引出ABA问题。

ABA问题

直接看代码:
在这里插入图片描述
原子变量的初始值是1,Thread-A在很短的时间内将它改为了2,然后又把它改回了1,而Thread-B当他去对比主物理内存时却发现期望值和真实值是一样的,CAS算法的运行条件完全满足,之后他将变量的值改为了100
客观的分析一下这个问题,加入我们的业务需求只需要数据的最终一致性,那么这时候的CAS算法是能够试行的,但是业务若需要按顺序或者说按照某种特定的运算规则,更或者说凡是需要追溯数据历史数据的业务,这种单纯的比较值是会造成线程安全问题的。怎么解决呢?强大的JUC并发编程包提供了一个类似于提交版本号的原子引用类AtomicStampedReference,代码如下:
在这里插入图片描述
带版本号的原子引用,感觉有点类似于乐观锁的机制。

下一篇将介绍数据容器的线程安全问题,以及如何解决这些数据容器在并发写操作的时候经常出现的线程安全报错 java.util.ConcurrentModificationException,也即Java并发修改异常。

猜你喜欢

转载自blog.csdn.net/XiaoA82/article/details/103326457