【Java 核心原理】-> JUC -> 深入理解 CAS -> Unsafe类 & ABA问题

Ⅰ CAS 是什么?

CAS(Compare and Swap),即比较再交换

我们先来看一个方法,AtomicIntegercompareAndSet()

在这里插入图片描述
我们来看一下这个方法的参数。
在这里插入图片描述
一个叫 except 一个叫 update,也就是说,这个方法做的是,如果当前的值是我所期望的那个值 except ,我就将它改成新的值 update,然后返回 true,反之则不修改,返回false

什么时候这个值会不符合预期呢?我们知道Java中对一个变量的操作,都是由每个线程对变量做一个快照,把值复制到自己的工作内存中,然后在自己的工作内存中进行修改,修改完成之后再将新的值写回主内存。

所以一个线程得到这个变量的原始值可能是 5 ,但是这个线程去做其他事的时候,可能还有别的线程对这个变量进行了修改,当这个线程再回来时,就会发现这个值被修改了,except 不对,所以不更新值。

(如果对变量修改的工作过程有疑惑的同学,可以去看我的这篇文章。👉 【Java底层原理】-> JUC -> 详解 volatile

我们先来操作一下 compareAndSet, 后面再进行更深的讲解。
在这里插入图片描述

可以看到,因为前后的值都是一样的,所以这里直接做了更新。

再用上面的过程套一下就是,main 线程先从主内存拿到了一个值,也就是6,复制到了自己的工作内存中。我们中间没有写什么代码,毕竟单线程,就当它做了一些事情把,然后 main 线程回来的时候,发现当前主内存中 atomicInteger 依然是 6,和自己存在工作内存中的相同,然后就更改它为 18322。

我们再写一行做个对比。

在这里插入图片描述
显然没有成功,因为经过上一条语句主内存的值被改了,所以肯定不是 6 了。

所以 CAS 就一句话,如果线程的期望值和物理内存的真实值相同(compare),就进行更新(或者说 swap),否则就不更新。

Ⅱ CAS 底层原理

在我的这篇文章里 【Java底层原理】-> JUC -> 详解 volatile),我写了一个例子,就是使用 AtomicInteger 类的 getAndIncrement() 方法,可以实现多线程的线程安全的 num++ 操作,但是这个方法连 synchronized 都没有用,是怎么做到的?我们接下来就从这个方法入手看一看它的底层。

这是 getAndIncrement() 的源码,我圈起来了两个东西,一个是 unsafe,一个是 valueOffset

在这里插入图片描述
this 就是当前对象,很好理解。valueOffset内存偏移量,也就是这个变量在内存中的物理地址。我们再往下看一层。

在这里插入图片描述

这已经到了 unsafe类了,这个类在哪呢?我可以给大家看一下。

在jre的 lib 包中,有一个 jar 包,rt.jar

在这里插入图片描述
熟悉 JVM 的朋友们可能就反应过来了,这是启动类加载器加载的jar包。
在这里插入图片描述
unsafe 类是一个了不得的类,它是启动类加载器加载的。是Java 娘胎里带着的一个类。

再继续看我圈起来的方法。它是一个 native 的方法,意味着这是 C++ 的代码。
在这里插入图片描述
有了一个简单的结构认识之后,我们来看看 AtomicInteger 中的 unsafe 成员。

在这里插入图片描述

所以我们知道,AtomicInteger 通过用 volatile修饰的value,保证了所有线程对它的可见性,只要 value 修改了,其他的线程都会知道。

那怎么保证原子性呢?靠的就是 unsafe,可以说 Unsafe 类是 CAS 的核心类。 由于Java是无法访问底层系统的,需要通过本地方法(native)来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据

注意 Unsafe 的所有方法都是 native 修饰的,也就是说该类中的方法都直接调用操作系统底层资源执行相应任务。

AtomicInteger在多线程环境下操作不需要加锁也可以保证线程安全,正是因为它使用了 Unsafe 类。

再总结一下,CAS 是一条CPU并发原语,它的功能是判断内存某个位置的值是否为期望值,如果是则改为新的值,这个过程是原子 的。

什么是原语呢?原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程不允许被中断,而 CAS 通过调用 Unsafe 类,使得 JVM 来实现出 CAS汇编指令,这是一种完全基于硬件的功能。

正因为 CAS 是一条CPU 的原子指令,所以不会造成数据不一致的问题。

好那我们再来看一下 Unsafe 类中的 getAndAddInt 方法。

在这里插入图片描述
我和 AtomicInteger 类的方法放在一起,让参数显得更加清晰一点。

在这里插入图片描述
所以在 while 循环中,var5 是在获取传来的对象对应的地址里的值。这个值是 主内存 中的值。也就是说,361 行,是得到了变量在内存中的值(相当于 var5 就是期望值),然后 while 的条件语句里,做一个比较,注意哦,这里 var2 的地址里的值,和刚才取的可能就不一样了,因为可能有其他线程改了变量的值,变量是 volatile 的,所以所有线程都能知道变量被更改了这个消息。那么在 362 行的条件语句,和 var5 的对比就会失败,这个语句的值就是 true (注意前面的 !),这意味着交换失败,该线程就会进入 while 循环中。

为了防止大家迷糊,我这里再用 AtomicIntegercompareAndSet() 方法和 Unsafe 类的 compareAndSwapInt()方法(也就是362行里的方法)做一个对比。

在这里插入图片描述
再强调一次,通过类和地址偏移量(var1, var2)得到的是 当前 变量的值,var 5 是线程第一次从内存中得到的值也就是期望值,就像我前面说的例子,可能这个线程中间去干了点别的什么事,多线程的资源竞争是很激烈的,毫秒之间可能就有其他线程改变了这个变量的值,然后因为有 volatile,所以此线程在进行 compareAndSwapInt 的时候,得到的 当前 变量的值,已经和可能几毫秒前自己获取的值不同了,这时候修改失败,该线程进入 while 循环中,不断从内存中获取值,然后争分夺秒地在进行 compareAndSwapInt,只有等它修改成功了一次,也就是终于抢在别的线程之前改了这个变量,它才可以从循环中跳出来。

这里用的其实就是自旋锁的思路。

那么一道面试题来了,请你谈一谈,为什么AtomicInteger要用CAS而不是synchronized?

答案就是 synchronized 加锁,同一时间段只能有一个线程来访问,一致性是得到了保证,但是并发性下降了,而CAS并没有加锁,而是通过循环不断比较,这样既保证了并发性,也保证了一致性。

Ⅲ CAS 的缺点

我直接先总结一下,再逐条解释。

  1. 循环时间长开销很大;
  2. 只能保证一个共享变量的原子操作;
  3. 会引发ABA问题。

A. 循环时间长开销很大

这个很好理解,再回到刚才我们的源码。

在这里插入图片描述
一个 do while 循环非常引人注目。在上面我也描述了一下CAS的过程,就是要抢在其他的线程之前去修改这个值,如果一直没抢到,每轮判断都发现完蛋,值已经被改了,这个线程就要在循环中不能出来。

那么,如果一个线程非常倒霉,从头到尾都没有抢到,那它就要一直一直循环,一直尝试。所以如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

B. 只能保证一个共享变量的原子操作

我们还是看源码,在比较的时候,传递的是 this。

在这里插入图片描述
所以对一个共享变量执行操作的时候,我们可以用循环CAS的方式来保证原子操作,但是多个共享变量操作时,循环CAS就不能保证操作的原子性了,只能通过锁来保证。

这个大家可以想一想,CAS的循环,操作的是一个对象,先不考虑AtomicInteger是不是封装的只能有一个对象,我们的循环每次要取得当前的值,然后比较成功了再设置值,都是有时间消耗的。如果有两个对象都要进行取值和赋值,那可能你对第一个对象比较之后发现是好的,可以进行swap,这时候再对另一个变量进行判断,这个时候第一个变量可能已经被更改了。
在这里插入图片描述
这两步必然是要紧密地结合在一起,对一个变量进行CAS,所以如果有两个变量,那只能通过加锁来做了,先对一个进行CAS,再对另一个。

C. ABA 问题

① ABA 问题是什么

ABA 问题可能是 CAS 中最难想到的一个问题,面试中如果答好了这一题会非常加分。

那么什么是ABA问题呢?简单用一个词描述,就是 狸猫换太子。

比如一个线程 1 从内存 取出了值 A,这时候另一个线程 2 也从内存中取出了 A,线程 2 将值从 A 变成了 B,由于线程 1 比较慢,线程 2 又将 B 读出,然后改成了 A,再次将 A 写回到了主内存中,这时候线程 1 读到了 A,和自己应该读到的一样,线程 1 把 A 取出,进行操作成功。

这个过程里,线程 2 把变量从 A 变成了 B,又从 B 变回了 A,这时候线程 1 读到的虽然也是 A,但是已经不是原来的那个 A 了,尽管线程 1 操作成功,但不代表这个过程是没有问题的。

因为 CAS 要执行交换(Swap)的前提是,比较(Compare)的时候,该变量没有被其他线程动过。有的程序可能这样无伤大雅,但是有问题是确实有问题。

这就是 ABA 问题。

② ABA 问题的解决

在前面我们用了 AtomicInteger,那不是 Integer 怎么办?是我自己定义的类型可不可以做原子操作?

当然可以,这时候我们就需要一个AtomicReference类了。

为了做演示,我先定义一个Book类。

在这里插入图片描述
然后我们可以通过原子引用的泛型,把我们自己定义的类传过去。

在这里插入图片描述

我写一个简单的compareAndSet.

在这里插入图片描述
同样的,我再把这行执行一次就失败了。
在这里插入图片描述
演示了一个简单的case之后,我们来看怎么用原子引用解决ABA问题。

其实非常简单,就是盖一个时间戳。不管是时间戳还是版本号,只要是一个在变的,每次都不一样的值就可以。

你只要把太子换走一次,就会记一次数,我最后除了要看太子是不是太子,再看看记的数,就可以知道你到底有没有做手脚了。

而且,这个版本号,并不需要我们来实现,JUC中还有一个类,AtomicStampedReference,实现的就是每次变更都会盖一个章的操作。

我先用普通的原子引用,做一个简单的ABA操作。

在这里插入图片描述
这个代码就是 t1 线程将 atomicReference 先改成 101,再改回100,t2 设定的等待 1 秒,确保 t1 发生了这个 ABA 操作,然后 t2 根据 100 来进行CAS。最后的结果是确实发生了ABA问题,100 已经不是那个 100,t2 成功修改了值。

现在我们来看一下带时间戳的怎么用。
在这里插入图片描述
AtomicStampedReference 有两个参数,一个是初始值,一个是初始的时间戳(或者版本号),我们设初始的版本号为1。

这次我用 t3 和 t4 来做演示,t3 来完成 ABA 操作。

在这里插入图片描述
首先我先让 t3 停一停,先不急改,让 t4 拿到和它相同的版本号并输出。这个在后面的运行中我们就会看到。

接着,让 t3 完成 ABA 操作。

在这里插入图片描述
compareAndSet 的构造方法有四个参数,分别是期望值,新值,期望的版本号,新的版本号。

在这里插入图片描述
我在 t3 的两次赋值之后,都输出了一个版本号。

现在我们写 t4 的。

在这里插入图片描述
现在我们来输出一下结果。
在这里插入图片描述
可以看到 t3 的版本号在增加,而最后 t4 修改的时候还是拿第一次版本号去修改的,所以修改失败了。

因此要解决ABA问题,使用AtomicStampedReference类就可以了。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值