文章目录
说在前面
本篇文章是基于前面所总结过的多线程知识点. 主要跟这两篇文章有较大的关系: 多线程到底可能会带来哪些风险 和 多线程常见的锁策略及synchronized底层工作过程. 所以建议对这两个知识点还不太熟悉的同学可以先看看这两篇文章的总结.
什么是CAS?
CAS是操作系统给JVM提供的一种非常轻量级的原子操作机制.
说到原子操作, 相信都首先会想到线程安全那一块吧, 不错, CAS就是CPU提供的一个特殊指令, 其英文全名是: compare and swap. 可以将其理解成是一个CAS涉及到比较和交换的操作, 这一步操作是一个原子的硬件指令来完成的.
CAS典型的应用场景
1. 使用CAS实现原子类
就比如Java标准库中提供的 java.util.concurrent.atomic.AtomicInteger
包, 里面的类都是基于这种方式来实现的. 这里以 AtomicInteger 类来举例, 其中的 getAndIncrement() 方法相当于 i++ 操作, getAndDecrement() 方法相当于 i-- 操作.
public class Main {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.getAndIncrement(); //相当于i++操作
atomicInteger.getAndDecrement(); //相当于i--操作
}
}
2. 使用CAS实现自旋锁
基于CAS可以实现更加灵活的锁, 主要是通过CAS看当前锁是否被某一个线程持有, 如果这个锁已经是被别的线程所持有, 那么就会进行自旋等待; 如果这个锁没有被别的线程所持有, 那么就会把指定线程设为是当前尝试加锁的线程.
CAS的ABA问题
这个问题还是比较常见的, 且经常在一些面试题中会遇见, 所以还是非常有必要对这一块知识进行掌握的.
1. 一个ABA问题的例子
如下图, 有两个线程 t1 和 t2, 在线程 t1 获取数据A到修改数据A(这里会使用CAS来判定当前值是否是数据A的值)的这段时间里面, 另外一个线程 t2 可能就会先将数据A改成数据B, 最后再修改成数据A, 这样的话, 当线程 t1 来进行判定的话, 就会以为这时候的数据与原来的数据一样, 可能就会导致出现一些错误.
2. ABA问题导致出现的BUG
这里先举一个经典的银行取钱的例子:
假如到银行机器上取款100, 已知目前卡中有金额200, 如果在点击取款的时候屏幕突然出现卡住的情况, 如果此时再次点击, 则这时候会出现两个线程都是要执行扣100的操作(在内部已经是开始进行并发操作了).
这时候我们希望的结果是: 线程1在执行扣款操作的时候, 线程2是会进行阻塞等待的. 当线程1操作完毕之后, 线程2在检查卡中金额的时候会发现与前面读取到的金额不相同, 这时候线程2就会执行失败, 不会取出金额.
当然上面是希望的执行过程, 但是在实际中可能还会出现一种情况, 也就是ABA问题: 当线程1执行完毕后, 卡中金额变成100. 这时候突然出现一个线程3, 这个线程在执行的操作是有另外一台机器正在往这张卡里汇钱100, 执行结束后卡中金额又回到了200, 当执行线程2的时候, 发现卡中金额与前面读取到的金额是一样的, 那么线程2又会取出100, 再次执行扣款操作. 类似的这种例子在生活中还是不时可以见到的, 这样的BUG会给用户带来一些不好的体验. 所以接下来就来总结针对ABA问题的解决方案.
3. ABA问题的解决方案
要想解决这个问题, 就得先回到上面例子中存在问题的地方, 上面出现异常的主要原因是在线程1和线程2的执行中间出现了一个线程3的干扰, 那么, 我们要想解决它, 就必须获取到这个操作的中间过程, 由此就引入了"版本号"的概念.
在每一次操作后都更新一下版本号, 例如上面的例子: 当还没进行扣款的时候, 此时为版本1, 当线程1执行后扣款100的时候, 先判断是否是版本1, 再将版本1更新为版本2, 当线程3汇款100的时候, 此时再将版本更新为版本3, 当线程2又要执行扣款的时候, 不再是通过卡中金额来判断是否扣款, 而是通过判断版本号来判断发出版本1和版本3是不同版本的, 那么此时线程2就会执行失败, 也就不会执行扣款了.
通过更新版本号这样的操作就可以很好地解决ABA问题, 当然, 这样的操作也并不需要我们自己来实现, 在Java标准库中就提供了 AtomicStampedReference<E>
类, 在这个类中就有提供版本管理的功能, 原理知道后, 在开发中直接拿来使用即可.