目录
前言
什么是CAS?
如何使用CAS?
CAS实现自旋锁
CAS的ABA问题
面试题
1.讲解下你自己理解的CAS机制
2.ABA问题怎么解决?
前言
在多线程中,多个线程同时对一个共享变量进行读写操作,那么就会出现线程安全问题,那就得使用synchronized进行加锁,但我们加锁,可能会出现死锁等问题。所以今天我们就来认识一种不需要加锁,也能实现原子性的算法--CAS。
什么是CAS?
CAS (Compare and Swap) 是一种无锁算法中的原子操作,在多线程环境下用于实现数据的原子更新。CAS包含了三个操作数:内存值V、旧的预期值A、和需要修改的新值B。当进行CAS操作时,会将内存值V和旧的预期值A进行比较,如果V和A相等,那就把内存值V更新为新值B;如果不相等,那么就不进行任何操作。
在多线程并发编程中,如果线程之间的锁冲突较低的情况下,那么就可以使用CAS,性能会优于synchronized,避免了线程上下文切换的开销。
CAS是由CPU上的一条指令完成的,具有原子性。
我们来看下CAS伪代码,方便理解:
/*** 比较并交换地址处的值* 此方法用于原子地比较地址处的值是否与期望值相等,如果相等,则将地址处的值替换为新值* 这是一个基本的同步原语操作,常用于实现线程安全的算法和数据结构* * @param address 要访问的内存地址* @param expectValue 期望值,即认为当前地址处应该具有的值* @param swapValue 如果地址处的值等于期望值,则用此值替换地址处的值* @return 如果地址处的值等于期望值,则返回true,否则返回false*/boolean CAS(address, expectValue, swapValue) {// 检查地址处的当前值是否与期望值相等if (&address == expectedValue){// 如果相等,则将地址处的值替换为新值,并返回操作成功&address = swapValue;return true;}// 如果地址处的值与期望值不相等,则返回操作失败return false;}
CAS其实是CPU中的指令,但被操作系统、JVM 等层层封装后提供给上层使用。在java中,CAS操作主要是通过
java.util.concurrent.atomic
包中的原子类来实现的,如AtomicInteger
、AtomicLong
等。
我们来看AtomicInteger的中getAndIncrement(相当于前置++)方法的伪代码:
/*** 原子整型类,提供原子操作的方法* 用于在多线程环境下安全地操作整型数值,防止数据不一致的情况*/class AtomicInteger {// 原子整型变量的当前值private int value;/*** 获取当前值并原子性地进行自增* * 此方法保证在多线程环境下安全地读取和更新整型变量的值* 使用CAS操作来实现原子性,直到CAS成功为止* * @return 当前值(自增前的值)*/public int getAndIncrement() {// 读取当前值,准备进行CAS操作int oldValue = value;// 使用CAS操作尝试将value值从oldValue递增为oldValue+1,直到成功为止while (CAS(value, oldValue, oldValue + 1) != true) {// CAS操作失败时,重新读取value值,确保数据一致性oldValue = value;}// 返回自增前的值return oldValue;}}
假设现在有线程t1和t2,要对某个变量进行++操作(分为读写存三个指令),那么没有使用CAS之前,当t1刚读取完值,此时切换到线程t2,当t2执行完指令,此时值为1,当由于此时t1线程原先读取到的旧值为0,那么此时t1线程继续执行指令,就会覆盖到t2的值,此时就会出现线程安全问题。
这里我们加上CAS操作之后,在t1线程读取完值后,此时切换到线程t2,t2在读取完内存值和期望的旧值之后,判断相等,将value更新为1。线程2执行完操作,切换到t1线程,此时t1线程判断内存值=1和期望的旧值=0,发现不相等,就不会进行交换操作,重新进行一次load操作。此时vaue为1,且oldvalue也为1,进行更新操作,value=2。
如何使用CAS?
在java中,给我们提供了java.util.concurrent.atomic供我们使用。我们可以在java8查看一下atomic包中相关的方法。
Overview (Java Platform SE 8 ) (oracle.com)
如果你想要对int类型的数据进行操作,那么就可以创建一个AtomicInteger类,若想对Boolean类型的数据进行操作,那就创建一个AtomicBoolean的原子类。
这里我们拿AtomicInteger为例
示例:假设现在有两个线程t1和t2,我们想使用使用者两个线程对count进行++操作,最终值为10000。
如果我们不进行任何加锁操作,那么就会有线程安全问题。
class Demo{private static int count=0;public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for(int i=0;i<5000;i++){count++;}});Thread t2=new Thread(()->{for(int i=0;i<5000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
那么我们就会得到一个小于10000的值
所以我们可以用AtomicInteger。由于是个原子类,所以我们需要创建一个原子类对象。
这里我们可以指定初始值或者默认值为0。
我们想要进行++操作,这里由于count是个类对象,不能直接++,需要调用其中的方法,这里前置++对应的方法是incrementAndGet(),后置++则是getAndIncrement()。
class Demo{public static void main(String[] args) throws InterruptedException {AtomicInteger count=new AtomicInteger(0);Thread t1=new Thread(()->{for(int i=0;i<5000;i++){count.getAndIncrement();}});Thread t2=new Thread(()->{for(int i=0;i<5000;i++){count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count.get());}
}
相同的,如果我们想要将count=10000减为0,那么我们可以使用getAndDecrement,
class Demo{public static void main(String[] args) throws InterruptedException {AtomicInteger count=new AtomicInteger(10000);Thread t1=new Thread(()->{for(int i=0;i<5000;i++){count.getAndDecrement();//count--}});Thread t2=new Thread(()->{for(int i=0;i<5000;i++){count.getAndDecrement();//count--}});t1.start();t2.start();t1.join();t2.join();System.out.println(count.get());}
}
当然,在AtomicInteger中也有很多其他的方法,以下是常用的方法:
这里就不做一一介绍,想了解其中其他方法,可以点击Java 平台 SE 8 (oracle.com) 查看。
我们知道,++和--是不具有原子性,那么这里getAndIncrement() 和getAndDecrement为什么具有原子性?
我们可以ctrl+鼠标左键进入getAndIncrement方法内部。
我们可以看到在 getAndIncrement内部又调用了getAndAddInt方法,而这个方法是属于U,这U就是unsafe类,这个类下的方法都是偏底层且危险的操作。
我们接着点击weakCompareAndSetInt方法。
点击compareAndSetInt方法查看。
可以看到compareAndSwapInt 方法是 native 修饰的本地方法,这个方法是 JVM 底层由 C/C++ 写的,我们是看不到的。
伪代码演示getAndIncrement:
/*** 原子整型类,提供原子操作的方法* 用于在多线程环境下安全地操作整型数值,防止数据不一致的情况*/class AtomicInteger {// 原子整型变量的当前值private int value;/*** 获取当前值并原子性地进行自增* * 此方法保证在多线程环境下安全地读取和更新整型变量的值* 使用CAS操作来实现原子性,直到CAS成功为止* * @return 当前值(自增前的值)*/public int getAndIncrement() {// 读取当前值,准备进行CAS操作int oldValue = value;// 使用CAS操作尝试将value值从oldValue递增为oldValue+1,直到成功为止while (CAS(value, oldValue, oldValue + 1) != true) {// CAS操作失败时,重新读取value值,确保数据一致性oldValue = value;}// 返回自增前的值return oldValue;}}
CAS实现自旋锁
在前面锁策略中,我们已经了解了什么是自旋锁,自旋锁就是当线程去获取锁时,此时锁被其他线程所持有,此时线程不会进入线程等待状态,而是会占用CPU资源,反复判断这个锁是否被释放,直到拿到锁为止。整个操作处于用户态,减少内核态的一些操作。
接下来,我们来使用CAS来实现自旋锁。
public class SpinLock{//用来记录锁被哪个线程持有,当owner为null时,说明没有线程持有锁,可被获取private Thread owner=null;//while循环来判断当前线程是否获取到锁,当owner为null时,说明没有线程持有锁,可被获取//反之,则说明此时锁被其他线程占用,无法获取到,返回false,取反之后就为true,进入死循环//直到获取到锁。当获取到锁后,owner被设置为当前线程,返回true,取反后就为false,退出循环public void Lock(){while(!cas(owner,null,Thread.currentThread())){}}/*** 解锁当前对象的锁状态* 通过将owner属性设置为null,表示当前对象不再被任何线程锁定*/public void unLock(){this.owner=null;}
}
CAS实现的自旋锁适用于那些临界区代码执行时间非常短、并且锁的竞争不是很激烈的场景。
CAS的ABA问题
cas是通过判断内存中的数据和寄存器中的数据是否相等来更新值的,但有一种潜在的情况:这个内存中的数据由A->B->A,也就是内存数据被修改了一次,最后又修改为原来的数据。这种情况会出现问题吗?
我们来举个例子:假设我有5000块钱,我想要给我的好兄弟通过支付宝转500块钱,当我找到我好兄弟的账号后点击转账500元,但此时由于网络延迟问题,我又点击了一次,当网络好转之后,显示转账成功,但是在后台可能提示我转账两次,实际我只想转一次,但点击两次那么支付宝的后台就会有两个线程在执行cas操作。
对于上面这种情况,一般是不会出现问题的。
如果我们在转账成功之前,由其他人给我转进500块钱,会发生什么?
可以看到,当我给我的好兄弟转了500之后,此时因为有其他人在此时又给我转了500,那在t1线程中,就判断我的钱还没有转,就又一次给我的好兄弟转了500,这样一共就给我的好兄弟转了1000块钱了。
那么如何防止这种情况发生呢?
造成ABA问题就是因为变量能加也能减,如果只能加不能减或者只能减不能加,就不会有这种情况,因此,我们引入一个变量:版本号,如果我们对余额修改一次,那么版本号就+1,当cas在执行的时候,通过判断当前版本号和预期的旧的版本号是否相等,若相等,则说明还没有进行转账,若不相等,说明中间穿插了其他的修改操作,不进行修改操作。
/*** Test类用于演示并发下的资金转账操作* 通过CAS操作保证原子性,防止数据一致性问题*/
class Test{// 账户余额,初始值为5000private int value=5000;// 版本号,用于CAS操作判断数据是否被修改private int version=0;/*** 转账方法,将指定金额添加到账户余额中* @param money 要转账的金额*/public void transfer(int money){// 读取当前版本号int oldVersion=version;// 尝试更新版本号,确保操作的原子性if(CAS(version,oldVersion,oldVersion+1)) {// 更新成功后,增加账户余额value+=money;}}
}
面试题
1.讲解下你自己理解的CAS机制
CAS全称Compare and Swap,即“比较并交换”,相当于通过一个原子的操作,同时完成“读取内存,比较是否相等,修改内存”这三个步骤,本质上需要CPU指令的支撑。
2.ABA问题怎么解决?
给要修改的数据引入版本号。在CAS比较数据当前值和旧值的同时,也要比较版本号是否符号预期。如果发现当前版本号和之前读入的版本号一致,就真正执行修改操作,并让版本号自增;如果单线当前版本号比之前读到的版本号大,就认为操作失败。
以上就是本篇所有内容,若有不足,欢迎指正~