CAS全称Compare and swap,是一种比较特殊的CPU指令. 字面意思:"比较并交换",
一个CAS涉及到以下操作:
我们假设内存中的原数据为V,旧的预期值A,需要修改的新值B.
1.比较A和V是否相等(比较)
2.如果相等,将B写入V.(交换)
3.返回操作是否成功.
伪代码
下面写的代码不是原子的,真实的CAS是一个原子的硬件指令完成的.这个伪代码只是辅助理解CAS的工作流程.
boolean CAS(address, expectValue, swapValue) {
if(&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
其中,address表示内存地址,expectValue和swapValue表示寄存器中的值.
流程就是比较address内存地址中的值是否与expect寄存器中的值相同.如果相同,就把swap寄存器中的值和address中的值进行交换(说是交换,实际上是赋值,其实完了之后我们往往只关注内存中的值,寄存器中的值,就不需要了),返回true. 如果不同,无事发生,返回false.
针对不同的操作系统,JVM用到了不同的CAS实现原理(操作系统对指令进行封装,JVM又对操作系统提供的api又封装了一层.),简单来讲:
java的CAS利用的是unsafe这个类提供的CAS操作;(这样的操作,涉及到一些系统底层内容,使用不当,可能会带来风险,一般不建议直接用CAS)
unsafe的CAS依赖了的是jvm针对不同的操作系统实现的Atomic::cmpxchg
Atomic::cmpxchg的实现使用了汇编的CAS操作,并使用cpu硬件提供的lock机制保证其原子性.
简而言之,是因为硬件予以支持,软件层面才能做到.
标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的.典型的就是AtomicInteger类.其中getAndIncrement相当于i++操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
//相当于i++操作(但实际上只是一个指令,它天然是原子的)
atomicInteger.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
//发现value与oldValue不同.意味着CAS之前另一个线程修改了value
//通过该方式,能意识到被修改
while ( CAS(value, oldValue, oldValue + 1) != true) {
oldValue = value;
}
//发现value被修改过,就重新读取新的value到OldValue中
return oldValue;
}
}
假设两个线程同时调用getAndIncrement
1.两个线程都读取value的值到oldValue中.(oldValue是一个局部变量,在栈上.每个线程都有自己的栈)
2.线程1先执行CAS操作,由于oldValue和value的值相同,直接对value进行赋值.
注意:
CAS是直接读内存的,而不是操作寄存器.
CAS的读内存,比较,写内存是一条硬件指令,是原子的.
3.线程2再执行CAS操作,第一次CAS的时候发现oldValue和value不相等,不能进行赋值.因此需要进入循环.
在循环里重新读取value的值赋给oldValue
4.线程2接下来第二次执行CAS,此时oldValue和value相同,于是直接执行赋值操作.
5.线程1和线程2返回各自的oldValue的值即可.
通过形如上述代码就可以实现一个原子类.不需要使用重量级锁,就可以高效的完成多线程的自增操作.
基于CAS实现更灵活的锁,获取到更多的控制权.
public class SpinLock {
private Thread owner = null;
public void lock() {
//通过CAS看当前锁是否被某个线程持有.
//如果这个锁已经被别的线程持有,那么就自旋等待.
//如果这个锁没有被别的线程持有,那么就把owner设为当前尝试加锁的线程
while(!CAS(this.owner, null, Thread.currentThread())) {
//当owner不为null的时候,循环就会一直执行下去,通过这样的"忙等"来完成等待效果
}
//阻塞式的等.让线程不参与cpu调度了,此处自旋式的等,没有放弃cpu
//不会参与到调度,也就没有了调度开锁了.但缺点就是消耗了更多的cpu
public void unlock() {
this.owner = null;
}
}
}
ABA的问题:
假设存在两个线程t1,t2.有一个共享变量num,初始值为A.
接下来,线程t1想使用CAS把num变成Z,那么就需要
先读取num的值,记录到oldNum变量中
使用CAS判定当前num的值是否为A,如果为A,就要修改成Z.
但是,在t1执行这两个操作之间,t2线程可能把num的值从A改成了B,又从B改成了A.
线程t1的CAS期望num不变就修改.但是num的值已经被t2给改了.只不过又改成A了.这个时候t1究竟是否要更改num的值为Z呢?
到这一步,t1线程无法区别当前这个变量始终是A,还是经历了一个变化过程.
这就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手机.
大部分情况下,t2线程这样的一个反复横跳改动,对于t1是否修改num是没有影响的.但是不排除一些特殊情况.
假设一个人有1000元存款,他想从中取出500块钱.
取钱的时候ATM卡了,按了一下没反应(t1),又按了一下(t2)还是没反应
按理来说这应该是正常的.
1.存款1000,线程1获取到当前存款值1000,期望更新为500;线程2获取到当前存款值1000,期望更新为500.
2.线程1执行成功.存款被改为500,线程2阻塞等待中.
3.轮到线程2执行了,发现当前存款为500,与之前读到的1000不同,执行失败.
但如果出现了极端情况:比如中间有人给你转了500.
这个时候线程2发现当前存款为1000,与1000相同,又扣款了一次.
这个时候,就被扣款了两次,这都是ABA问题搞的鬼!!
1.约定数据的变化是单向的(只能增加或者只能减少),不是双向的(既能增加又能减少)
2.给要修改的值,引入版本号.在CAS比较当前值与旧值的同时,也要比较版本号是否符合预期.
(1)CAS操作在读取旧值的同时,也要读取版本号
(2)真正修改的时候,
如果当前版本号和读到的版本号相同,就修改数据,并把版本号+1
如果当前版本号高读到的版本号.就操作失败(认为数据已经被修改过了)
1.讲解下你自己了解的CAS机制
全程Compare and swap, 即"比较并交换".相当于通过一个原子的操作,同时完成"读取内存,比较是否相等,修改内存"这三个步骤.本质上需要CPU指令的支撑
2.ABA问题怎么解决
给要修改的数据引入版本号.在CAS比较当前值和旧值的同时,也要比较版本号是否符合预期.如果返现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增;如果发现当前版本号比之前大,则视为操作失败