CAS概述
CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。CAS也是现在面试经常问的问题。
volatile不保证原子性问题
根据我前面volatile文章,volatile并不能保证原子性操作,在并发情况下对i++操作,volatile也会发生丢数据的情况,主要是因为线程在对各自的工作非常对数据进行修改后,准备写到主内存的时候,别的线程已经抢先一步对主内存的数据进行修改,导致数据的丢失。那么如何解决这种问题呢?
使用原子整型类解决volatile对i++操作丢数据问题
代码如下
public class AtomicTest02 {
// 相当于i = 0,
AtomicInteger atomicInteger = new AtomicInteger(0);
public void addPlus() {
// 相当于i++
atomicInteger.getAndIncrement();
}
public static void main(String[] args) {
AtomicTest02 atomicTest02 = new AtomicTest02();
// 启动20个线程对atomicInteger进行++操作
for (int i = 0; i< 20; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
atomicTest02.addPlus();
}
}
}).start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(atomicTest02.atomicInteger.get());
}
}
运行结果:
20000
没有出现丢数据了,所以在多线程下,可以使用atomicd等原子类来防止丢数据
CAS工作原理
假如线程在自己的工作空间对数据进行修改后,准备写入主内存时,线程先判断(比较)主内存现有的值是不是自己修改前的那个值,如果是的话那么就直接将数据写入(交换)主内存,不是的话那么就重新读入 主内存的值在进行操作,直到准备写入的时候主内存的值是自己预期的值,这就是比较并交换的简单思想.
CAS代码示例一
public class AtomicTest01 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
// 第一次修改,期望值是0,就修改为1
boolean b = atomicInteger.compareAndSet(0, 1);
System.out.println("修改状态:" + b + "结果:" + atomicInteger.get());
//第二次修改,期望值也是0,就修改为2
boolean c = atomicInteger.compareAndSet(0,2);
System.out.println("修改状态:" + c + "结果:" + atomicInteger.get());
}
}
运行结果:
第一次修改状态:true结果:1
第二次修改状态:false结果:1
从结果可以看出 第一次修改成功,把值从0修改为1,但是第二次修改预期值是0,但是实际的值已经从0修改为了1,所以第二次修改就没有修改成功
CAS底层原理
- CAS的全程是Compare-And-Swap,它是一条CPU并发原语
- 它的功能是判断内存某个位置是否是预期值,如果是则更改为新的值,这个过程是原子性的
- CAS并发原语在 java的体现就是sun.mic.Unsafe类个各个方法,调用Unsafe类的方法,JVM会帮助我们实现CAS汇编指令。这是一个完全依赖于硬件的功能,通过它实现原子性操作。由于CAS是一种系统原语,由若干指令组成,该原语执行必须连续的不许中断。
Unsafel类
是CAS的核心类,由于java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe类相当于一个后面,基于该类可以直接操作特定的内存数据,Unsafe类存在于sun.mic包中,其内部方法操作可以像C的指针一样直接操作 内存,因为 java中的CAS操作的执行依赖于UnSafe类的方法
我们来看下源码:
AtomicInteger
类的getAndIncrement()
方法,类似i++
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
unsafe类的getAndAddInt方法
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
-
从源码可以看出,
getAndIncrement()
方法调用了Unsafe类的getAndAddInt(Object var1, long var2, int var4)
方法,- 第一个参数:var1是指AtomicInteger对象
- 第二个参数:var2表示AtomicInteger对象的内存地址偏移量,即该对象在内存地址的位置
- 第三个参数:var4递增的值,在该方法是1
该
-
Unsafe类的getAndAddInt方法体是一个do…while循环,该方法主要是判断当前的对象的预期值(即var5)在主内存中是否一样,通过
compareAndSwapInt(var1, var2, var5, var5 + var4)
来判断对象的预期值是否一样,如果不一样则一直执行do…while循环,直到预期值和主内存的值一致才结束
CAS的缺点
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。
- 循环时间长开销很大。
- 只能保证一个共享变量的原子操作。
- ABA问题。
-
循环时间长开销很大
我们可以看到getAndAddInt方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。 -
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。 -
ABA问题
-
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?
-
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。
- ABA问题的解决
Java并发包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
AtomicReference和AtomicStampedReference
AtomicReference:是原子引用类,如果我们自定义的类要解决原子操作可以使用此类(使用泛型),
AtomicStampedReference:是带有版本号的原子引用类,使用此类可以解决CAS的ABA问题
代码示例:
// 原子引用类测试,解决ABA问题
public class AtomicStampedReferenceTest {
public static void main(String[] args) throws InterruptedException {
// 注意构造方法的2个参数,第一个参数是引用值,第二个参数是版本号
AtomicStampedReference<Integer> data = new AtomicStampedReference<>(0,1);
// 该线程执行一次ABA操作,即把data从0,改为1,在从1改为0
new Thread(new Runnable() {
@Override
public void run() {
// 版本号
int stamp = data.getStamp();
System.out.println("线程t1修改前的版本号是:" + stamp);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程t1先把值改为1,再把值改为0");
// 把值从0改为1
// 参数1:期望的引用值,参数2:新的引用值,参数3:期望的版本号,参数3:新的版本号
data.compareAndSet(0,1, stamp,stamp + 1);
// 在把值从1改为0,
int stamp1 = data.getStamp();
data.compareAndSet(1, 0, stamp1,stamp1 + 1);
}
}, "t1").start();
// 主线程
int stamp = data.getStamp();
System.out.println("主线程修改前的版本号是:" + stamp);
Thread.sleep(2000);
boolean b = data.compareAndSet(0, 2019, stamp, stamp + 1);
System.out.println("主线程是否修改成功:" + b + " 当前的版本号是:"+data.getStamp()+" 当前的值是:"+ data.getReference());
}
}
运行结果:
主线程修改前的版本号是:1
线程t1修改前的版本号是:1
线程t1先把值改为1,再把值改为0
主线程是否修改成功:false 当前的版本号是:3 当前的值是:0
从结果可以看出,主线并没有修改成功,虽然data的值依然 是0,但是因为data的版本号已经不是预期值了,已经被线程t1进行了一次ABA操作,
所以可以利用AtomicStampedReference类的带有版本号的特征来解决CAS的ABA问题。