Java 并发编程之 CAS 第三篇 - CAS 的缺点
通过前两篇的文章介绍, 我们知道了 CAS 是什么以及查看源码了解 CAS 原理. 那么在多线程并发环境中, 的缺点是什么呢? 这篇文章我们就来讨论讨论
本篇是《凯哥 (凯哥 Java:kagejava) 并发编程学习》系列之《CAS 系列》教程的第三篇: CAS 的缺点有哪些? 怎么解决.
CAS 的缺点
一: do while 循环时间长的话开销大
从源码中 (见上图), 我们可以知道 do while 中的 while 返回 true 会一直循环下去(具体分析步骤见上一篇:《Java 并发编程之 CAS 二源码追根溯源》. 凯哥(凯哥 Java:kaigejava) 就不在这里赘述了). 如果并发量很多的话, 比如: 有十万个线程来并发处理, 这这种业务下, 很多线程都会修改共享变量, 要保证原子性的话, 循环会很长时间, 假设每个线程为了保证原子性, 循环耗时 0.001s 的话, 那么十万个线程都这么循环下来, 对 CPU 的消耗还是比较大的.
二: 只能保证一个共享变量的原子性
从源码中, 我们知道 Object var1 其实就是对象自己. 拿上一篇文章举的例子来说, 其实就是 atomicInteger 自己, 也就是共享变量. CAS 的 do while 只能一个 this 一个 this 的比较. 从这里就可以看出, CAS 只能保证一个共享变量的原子性. 但是如果用同步锁的话, 锁是可以锁对象也可以锁代码块. 锁操作的可以不是一个共享变量.
三: 会出现新的问题: ABA 问题
何为 ABA 问题呢?
先来看看现实生活的例子:
学校举行运动会, 标准操场一圈 400 米, 现在正在进行 1200 米比赛. 1200=400*3. 需要跑上三圈. 小明和小红比赛, 在刚开始的时候, 大家都看到小明, 小红都在起点, 但是小红速度比小明快 2/3. 这个时候, 小明爸爸拿着相机拍摄, 在起点时候, 拍摄小明, 3 分钟过后, 我们再来看起点, 是小红. 7 分钟之后, 在看起点是小明. 难道小红就跑了一圈吗? 这当然不对. 小红比小明快, 当我们第二次看到小明的时候, 小红其实三圈已经跑完了. 最终出现的情况就是: 小明 (小红) 小红小明 (小红) 小明. 最终获胜的当然是小红
这个例子或者不是很恰当. 但是凯哥是想通过这个例子告诉大家, 当线程如果出现这种情况的话, 会影响到数据结果的.
如下图:
说明:
A 线程执行一次耗时: 1 分钟
B 线程执行一次耗时 "29.5s
B 线程在 A 线程执行一次的时间内操作主内存的数据变化为: 202020192020
当 B 线程执行 2 次操作之后, 1 分钟到了. A 线程拿着自己工作区 copay 的副本值 i=2020 和主内存的值 i=2020. 正好相等, 这个时候会, 主内存的共享变量相对于 A 线程来说, 是没有变化的. 但是实际上是有变化的(B 线程确实操作过的. 如上面举例的, 小红已经跑完三圈了. 可是小明才跑第二圈呢), 如果这个时候在操作, 有可能导致数据出问题(赛跑最终结果是小红赢了, 而不是小明赢了).
所谓的 ABA 就是: 在某个监控点的时候数据是 A, 当过了时间 N 之后, 在监控的时候还是 A. 但是在时间 N 的这段时间内, 监控点的数据有可能不是 A 了, 变成过 B. 这样就更容易理解了吧.
ABA 问题演示代码:
代码说明:
初始的时候, 给了变量值为 2020. 也就是 V=2020. 如上图 1
在经过线程 A 一顿猛如虎的操作之后, 搞出来 2020,2021,2020.ABA 的效果处理. 如上图 2.
Sleep 了 1 秒是为了让线程 A 完成 ABA 操作的.
然后, 线程 2 在拿着自己副本的变量值 A=2020, 和主内存 V 进行比较. 发现一致, 就更新了 2019.
运行结果如下图:
从运行结果来看, 线程 2 也更新成功了. 但是, 这样是不对的. 因为我们已经知道线程 A 对共享变量操作过了. 那么针对 CAS 的这些缺点, 应该怎么解决呢? 欢迎继续学习下一篇. 凯哥将介绍三个怎么解决. 以及会讲解原子引用, 时间戳原子引用两个问题.
CAS 缺点解决办法
一: 循环长, 开销大解决方案
解决思路: ConcurrentHashMap(后面凯哥也会详细介绍的)类似的方法. 当多个线程竞争时, 将粒度变小, 将一个变量拆分为多个变量, 达到多个线程访问多个资源的效果, 最后再调用 sum 把它合起来.
二: 一个共享变量的解决方案
因为 CAS 只能一个共享变量一个共享变量的处理. 如果想要处理类是代码块或者对象的. 可以使用同步锁或者是多个变量放到一个对象里面. 然后在 CAS. 因为在 JUC 包下, 有支持对象的原子类, 如: AtomicReference(原子引用类).
原子引用
在 Java 中变量的类型分为八大基本类型或八大基本类型的对象类型或者是自定义的对象类型. 在并发中, atomicInteger 就是基本类型就是 int/Integer 的原子类. 那么自定义的对象怎么实现原子性呢? 这就要用到原子引用对象 - AtomicReference.
原子引用 demo:
我们来模拟凯哥心中女神变化过程(注: 女神同时只能存在一个, 不能存在多个, 要保持单一, 原子的).
在 X 年之前是刘亦菲, X+N 年后是林依晨, 现在是佟丽娅了. 我们知道, 这三个女神都是对象. 都有年龄, 用户名, 是个对象.
创建 user 对象
她们三个在凯哥心中活动如下:
那么请问在 21 和 23 行输入的结果是什么?
编辑
我们发现在 23 行依然输出的是林依晨. 而不是佟丽娅. 为什么呢? 分析思路见:《Java 并发编程之 CAS 一理解》篇文章的三: cas 代码演示部分.
我们修改之后再来看:
运行结果:
发现心中女神已经更新为佟丽娅了
三: ABA 问题解决
ABA 问题产生的根本原因是因为: 只是线程自己工作空间的变量预期值 (副本) 和主内存中的值进行了比较. 当值相等的时候, 就默认没有被其他线程更新过. 那么怎么解决这个问题呢?
是不是可以添加一个东西, 用来辅助呢? 添加一个标记, 或者一个版本号, 根据版本号 + 数值来进行判断呢? 当然可以了, JDK 中也是这么实现的. JDK 使用的是时间戳(stamp), 而不是我们说的版本号(version). 我们来看看时间戳原子引用(AtomicStampedReference<V>).
我们来看看这个类.
时间戳原子引用 demo
先看构造器:
参数说明:
initialRef: 初始值
initialStamp: 初始值的时间戳
再来看看 CompareAndSet 方法:
参数说明:
expectedReference: 预期值
newReference: 更新值
expectedStamp: 预期时间戳值
newStamp: 更改后时间戳值
我们发现这个 AtomicStampedReference 类和 AtomicReference 的方法中的区别就是时间戳原子引用类中的方法都添加了预期的时间戳值和修改后的时间戳的值这两个参数.
我们来看看, 使用带有时间戳的原子引用类解决 ABA 问题的代码:
1: 声明共享变量
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(127,1);
(需要说明, 如果用数值做 demo 的话, 主要 int 的取值范围. 如果大于 127, 就会始终返回 false. 因为 Integer(128) == Integer(128)返回的是 false)
线程一先修改执行一个 ABA 的过程:
编辑
执行完成之后, 当前的主内存中版本号应该是 3 了.
我们在用线程 2 来执行 compareAndSet:
此时, 在线程 2 中的版本号: tamp 应该是 1, 但是主内存中的版本号已经是 3 了. 所以执行后返回 false. 执行不成功的.
我们来看看运行结果和我们预期结果:
运行结果, 和我们预期结果是一致的. 说明, 添加这个时间戳 (版本号) 可以解决 ABA 问题
来源: https://www.cnblogs.com/kaigejava/p/12584550.html