上文讲解 synchronized 当提到自旋锁时, 讲到当其他线程进入竞争, 偏向锁状态被消除, 就会进入轻量级锁状态(自适应的自旋锁) , 而 Java 中自旋锁其实背后原理就是 CAS 来实现的, 本文我们就来重点讲解一下 CAS 背后的机制.
关注收藏, 开始学习吧
CAS, 全称Compare and swap,字面意思: “比较并交换”, 能够比较和交换某个寄存器中的值, 和内存中的值是否相等. 如果相等, 则把另一个寄存器中的值和内存进行交换.
CAS 是单条 CPU 指令, 是不可拆分的.
我们假设内存中的原数据是V, 旧的预期值为A, 需要修改的新值为B.
一个 CAS 会涉及到以下操作:
一个 CAS 伪代码
// 下面写的代码并不是原子的, 真实的 CAS 是一个原子级的硬件指令完成的.
// 这个伪代码只是为了辅助理解 CAS 的工作流程.
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
当多个线程同时对某个资源进行CAS操作, 只能有一个线程操作成功, 但是并不会阻塞其他线程, 其他线程只会收到操作失败的信号. 这对于我们编写线程安全代码, 又提供了一个全新的思路, 在之前我们通过上锁来实现线程安全, 而基于 CAS 又能实现一套 “无锁编程”, 不过 CAS 也有着下面的一些问题.
由于上述问题, 导致其使用范围还是有一定的局限性.
针对不同的操作系统, JVM 用到了不同的 CAS 实现原理, 简单来讲:
简而言之, 是因为硬件予以了支持, 软件层面才能做到.
标准库中提供了 java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger
类. 其中的 getAndIncrement
相当于 i++ 操作.
原子类中提供了自增, 自减, 自增任意值, 自减任意值这些操作, 都是基于 CAS 按照无锁编程来实现的.
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
现在假设有两个线程同时调用 getAndIncrement().
两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环. 在循环里重新读取 value 的值赋给 oldValue.
最后 线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 利用原子类, 我们不需要使用重量级锁, 就可以高效的完成多线程的自增操作, 实现无锁编程.
CAS 是通过识别当前是否会出现 “插队” 情况, 如果没有操作插队, 此时是安全的, 可以直接修改. 如果有操作进行插队, 就重新读取内存中最新的值, 再次尝试修改即可. 这与采取加锁保证线程安全的方法是不同的.
在之前的锁策略文章中, 在讲自旋锁时, 我们其实就提到了 CAS. 自旋锁其实就是基于 CAS 实现更灵活的锁, 能获取到更多的控制权.
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
当该锁已经处于加锁状态, while 循环这里就会返回 false, 循环条件成立, CAS 不会进行实际的交换操作, 进入下一轮循环, 一直到该锁被持有线程释放.
CAS 的关键要点, 是比较 内存 和 寄存器 中的值, 通过比较是否相等来判定内存中的值是否发生了改变.
但是, 如果内存中的值没有变化, 就一定没有别的线程修改吗?
这就要说一下 CSA 中的 ABA 问题.
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A. 接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A.
到这一步, t1 线程已经无法区分当前这个变量还是之前那个 A, 还是经历了一个变化过程之后的 A.
在当前情况下, 即使出现了 ABA 问题, 也没啥太大的影响. 但是如果遇到一些极端的场景, 就不一定了.
假设 A 有 100 存款. 他想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
正常的过程
异常的过程
这个时候, 扣款操作会被执行了两次, 这就是 ABA 问题搞的鬼.
由于 CAS 只是简单的判定 “值是否相同”, 但实际上想判定的是 “这个值有没有变化过”. 所以我们可以约定一个版本号来衡量内存中的值是否发生改变.
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号. 真正修改的时候:
对比理解上面的转账例子
假设 A 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作. 我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 我们给余额搭配一个版本号, 初始设为 1.
在 Java 标准库中提供了 AtomicStampedReference
类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能.
✨ 本文重点讲述了多线程中 CAS 知识, CAS 也是多线程开发中的一种典型思路, 但是本文中没有花时间介绍 Java 中提供的 CAS 的 api 怎么使用, 实际开发中, 一般是不会直接使用 CAS 的, 都是使用库里已经基于 CAS 封装好的组件, 比如原子类去实现.
✨ 想了解更多的多线程知识, 可以收藏一下本人的多线程学习专栏, 里面会持续更新本人的学习记录, 跟随我一起不断学习.
✨ 感谢你们的耐心阅读, 博主本人也是一名学生, 也还有需要很多学习的东西. 写这篇文章是以本人所学内容为基础, 日后也会不断更新自己的学习记录, 我们一起努力进步, 变得优秀, 小小菜鸟, 也能有大大梦想, 关注我, 一起学习.
再次感谢你们的阅读, 你们的鼓励是我创作的最大动力!!!!!