并发(七)–CAS
CAS的全称是Compare And Swap,即比较交换,当然还有一种说法:Compare And Set,调用原生CAS操作需要确定三个值:
- 要更新的字段
- 预期值
- 新值
其中,要更新的字段(变量)有时候会被拆分成两个参数:1.实例 2.偏移地址。
之前关于i++的多线程问题,其调用如下:
AtomicInteger atomicInteger = new AtomicInteger();
public void addMyAtomic(){
atomicInteger.getAndIncrement();
}
public final int getAddIncrement(){//这个getAddIncrement解决了关于i++在多线程环境下线程安全的问题
return unsafe.getAndAddInt(0:this,valueOffset,i:1);
//这个this表示当前对象 valueOffset表示内存偏移量:内存地址
}
那么又引出一个问题:unsafe类是什么?==>在rt.jar\sun.misc\Unsafe.class
==>CAS凭什么能报保证原子性?依靠的是底层的Unsafe类。
public class AtomicInteger extends Number implements java.io.sserializable{
private static final long serialVersionUID =614....L;
private static final Unsafe = Unsafe.getUnsafe();
private static final long valueOffset;//表示内存地址偏移量
static{
try{
valueOffset = unsafe.ObjectFieldOffset(AutomicInteger.class.getDeclaredField(name:"value"));
}catch(Exception ex){throw new Enor(ex)}
}
private volatile int value;
}
(1)Unsafe:是CAS的核心类。由于java方法无法直接访问底层系统,需要通过本地(native)方法来访问
(也就是只要native修饰的方法,java是无能为力的),Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存。因为java中CAS操作的执行依赖于Unsafe类的方法。
注意:Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应的任务。
(2)**变量valueOffset:**表示该变量在内存中的偏移地址。因为Unsafe就是根据内存偏移地址获取数据的。
public final int getAndIncrement(){
return unsafe.getAndAddInt(0:this,valueOffset,i:1)}
(3)变量value用volatile修饰,保证了多线程之间内存的可见性。
再次强调:由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
也许你看到这里,会觉得云里雾里,不知道我在说什么,没关系,继续硬着头皮看下去。
我们先来看看compareAndSet的源码。
compareAndSet源码浅析:
首先,调用这个方法需要传递两个参数,一个是预期值,一个是新值,这个预期值就是旧值(是值,不是字段),新值就是我们希望修改的值(是值,不是字段)。
我们来看看这个方法的内部实现:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
调用了unsafe下的compareAndSwapInt方法
,除了传递了我们传到此方法的两个参数之外,又传递了两个参数,这两个参数就是我上面说的实例和偏移地址,this代表是当前类的实例,即AtomicInteger类的实例
,这个偏移地址又是什么鬼呢,说的简单点,就是确定我们需要修改的字段在实例的哪个位置
。
知道了实例,知道了我们的需要修改的字段是在实例的哪个位置,就可以确定这个字段了。不过,这个确定的过程不是在Java中做的,而是在更底层做的。
偏移地址是在本类的静态代码块中获得的:
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
unsafe.objectFieldOffset接收的是Field类型的参数,得到的就是对应字段的偏移地址了,这里就是获得value字段在本类,即AtomicInteger中的偏移地址。
我们在来看看value字段的定义:
private volatile int value;
volatile是为了保证内存的可见性。
我们可以看下这两个方法的定义:
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public native long objectFieldOffset(Field var1);
这两个方法被native标记了。
我们来为compareAndSwapInt方法做一个比较形象的解释:
当我们执行compareAndSwapInt方法,传入10和100,Java会和更底层进行通信:老铁,我给你了字段的所属实例和偏移地址,你帮我看下这个字段的值是不是10,如果是10的话,你就改成100,并且返回true,如果不是的话,不用修改,返回false把。
其中比较的过程就是compare,修改的值的过程就是swap,因为是把旧值替换成新值,所以我们把这样的操作称为CAS。
我们再来看看incrementAndGet的源码。
incrementAndGet源码浅析:
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
//这个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;
}
incrementAndGet方法会调到用getAndAddInt方法,这里有三个参数:
- var1:实例。
- var2:偏移地址。
- var4:需要自增的值,这里是1。
public final int getAndIncrement(){
return unsafe.getAndAddInt(0:this,valueOffset,i:1)}
和这个对比,那么其实this就是var1,valueOffset就是var2,i:1就是var4.先get到当前的值,比较var5是否==var5,是的话就
var5+var4.若while(!true)则跳出循环,否则再do while。
(var1:AtomicInteger对象本身
var2:该对象值的引用地址
var4:需要变动的数量
var5:是通过car1,var2找出主存内存中真实的值
用该对象当前的值与var5比较,若相同就更新var5+var4,并且返回true;若不相同,继续取值然后在比较,直到更新完成。)
getAndAddInt方法内部有一个while循环,循环体内部根据实例和偏移地址获得对应的值,这里先称为A,再来看看while里面的判断内容,JDK和更底层进行通讯:嘿,我把实例和偏移地址给你,你帮我看下这个值是不是A,如果是的话,帮我修改成A+1,返回true,如果不是的话,返回false吧。
这里要思考一个问题:为什么需要while循环?
比如同时有两个线程执行到了getIntVolatile方法,拿到的值都是10,其中线程A执行native方法,修改成功,但是线程B就修改失败了啊,因为CAS操作是可以保证原子性的,所以线程B只能苦逼的再一次循环,这一次拿到的值是11,又去执行native方法,修改成功。
像这样的while循环,有一个高大上的称呼:CAS自旋。
让我们试想一下,如果现在并发真的很高很高,会出现什么事情?大量的线程在进行CAS自旋,这太浪费CPU了吧
。所以在Java8之后,对原子操作类进行了一定的优化,这个我们后面再说。
可能大家对于原子操作类的底层实现,还是比较迷茫,还是不知道unsafe下面的方法到底是什么意思,毕竟刚才只是简单的读了下代码,俗话说“纸上得来终觉浅,绝知此事要躬行”,所以我们需要自己调用下unsafe下面的方法,来加深理解。
Unsafe:
Unsafe:不安全的,既然有这样的命名,说明这个类是比较危险的,Java官方也不推荐我们直接操作Unsafe类,但是毕竟现在是学习阶段,写写demo而已,只要不是发布到生产环境,又有什么关系呢?
Unsafe下面的方法还是比较多的,我们选择几个方法来看下,最终我们会利用这几个方法来完成一个demo。
- objectFieldOffset:接收一个Field类型的数据,返回偏移地址。
- compareAndSwapInt:比较交换,接收四个参数:实例,偏移地址,预期值,新值。
- getIntVolatile:获得值,支持Volatile,接收两个参数:实例,偏移地址。
这三个方法在上面的源码浅析中,已经出现过了,也进行了一定的解释,这里再解释一下,就是为了加深印象,我在学习CAS的时候,也是反复的看博客,看源码,突然恍然大悟。我们需要用这三个方法来完成一个demo:写一个原子操作自增的方法,自增的值可以自定义,没错,这个方法上面我已经分析过了。下面直接放出代码:
public class MyAtomicInteger {
private volatile int value;
private static long offset;//偏移地址
private static Unsafe unsafe;
static {
try {
Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafeField.setAccessible(true);
unsafe = (Unsafe) theUnsafeField.get(null);
Field field = MyAtomicInteger.class.getDeclaredField("value");
offset = unsafe.objectFieldOffset(field);//获得偏移地址
} catch (Exception e) {
e.printStackTrace();
}
}
public void increment(int num) {
int tempValue;
do {
tempValue = unsafe.getIntVolatile(this, offset);//拿到值
} while (!unsafe.compareAndSwapInt(this, offset, tempValue, value + num));//CAS自旋
}
public int get() {
return value;
}
}
public class Main {
public static void main(String[] args) {
Thread[] threads = new Thread[20];
MyAtomicInteger atomicInteger = new MyAtomicInteger();
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
atomicInteger.increment(1);
}
});
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("x=" + atomicInteger.get());
}
}
运行结果:
你可能会有疑问,为什么**需要用反射来获取theUnsafe**,其实这是JDK为了保护我们,让我们无法方便的获得unsafe,如果我们和JDK一样来获得unsafe会报错:
@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");//如果我们也以getUnsafe来获得theUnsafe,会抛出异常
} else {
return theUnsafe;
}
}
ABA问题(也就是CAS的缺点):
最主要的缺点就是ABA问题:
compareAndSet方法,上面已经写过一个demo,大家可以也试着分析下源码,我就不再分析了,我之所以要再次提到compareAndSet方法,是为了引出一个问题。
假设有三个步骤:
- 修改150为50
- 修改50为150
- 修改150为90
请仔细看,这三个步骤做的事情,一个变量刚开始是150,修改成了50,后来又被修改成了150!(又改回去了),最后如果这个变量是150,再改成90。这就是CAS中ABA的问题。
第三步,判断这个值是否是150,有两种不同的需求:
- 没错啊,虽然这个值被修改了,但是现在被改回去了啊,所以第三步的判断是成立的。
- 不对,这个值虽然是150,但是这个值曾经被修改过,所以第三步的判断是不成立的。
针对于第二个需求,我们可以用AtomicStampedReference来解决这个问题,AtomicStampedReference支持泛型,其中有一个stamp的概念。下面直接贴出代码:
public static void main(String[] args) {
try {
AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<Integer>(150, 0);
Thread thread1 = new Thread(() -> {
Integer oldValue = atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
if (atomicStampedReference.compareAndSet(oldValue, 50, 0, stamp + 1)) {
System.out.println("150->50 成功:" + (stamp + 1));
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);//睡一会儿,是为了保证线程1 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
Integer oldValue = atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
if (atomicStampedReference.compareAndSet(oldValue, 150, stamp, stamp + 1)) {
System.out.println("50->150 成功:" + (stamp + 1));
}
});
thread2.start();
Thread thread3 = new Thread(() -> {
try {
Thread.sleep(2000);//睡一会儿,是为了保证线程1,线程2 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
Integer oldValue = atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
if (atomicStampedReference.compareAndSet(oldValue, 90, 0, stamp + 1)) {
System.out.println("150->90 成功:" + (stamp + 1));
}
});
thread3.start();
thread1.join();
thread2.join();
thread3.join();
System.out.println("现在的值是" + atomicStampedReference.getReference() + ";stamp是" + atomicStampedReference.getStamp());
} catch (Exception e) {
e.printStackTrace();
}
}
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。
- 循环时间长开销很大。
- 只能保证一个共享变量的原子操作。
- ABA问题。
循环时间长开销很大:
我们可以看到getAndAddInt方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。(也就是,未成功时,会一直do…while来循环)
只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
什么是ABA问题?ABA问题怎么解决?
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题
。ABA问题的解决思路就是使用版本号。在变量前面加上版本号,每次变量更新的时候把版本号+1,那么A->B->A就变成了1A->2B->3A。,Java1.5开始,并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性
。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
为什么要用CAS而不是用synchronized?
synchronized加锁,同一时间段内只允许一个线程来访问,一致性得到了保障,但是并发性下降。
CAS底层用到了do。。while(也就是getAndAddInt()方法),这里没有加锁,但是可以反复使用CAS比较,直到比较成功,这样既保证了一致性又提高了并发性。
下面写一个例子,来帮助理解CAS:
假设A和B两个线程同时执行getAndAddInt操作(分别跑在不同的cpu上):
1,AtomicInteger里面的value原始值为3,即主存中AtomicInteger的value为3,根据JMM模型,线程A和B各自持有一份值为3的value的副本分别到各自的工作内存。
2,线程A通过getAndAddInt(var1,var2)拿到value值为3,这时线程A被挂起
3,线程B也通过getAndAddInt(var1,var2)方法获取到value值为3,此时刚好线程B没有被挂起并执行compareAndSwapInt方法,比较内存值也为3,成功修改内存值为4,线程B打完收工,一切ok。
4,这时候线程A恢复,执行compareAndAwapInt方法比较,发现自己手里的值数字是3,和主存中的数字4不一致,说明该值已经被其他线程抢先一步修改过了,则A线程本次修改失败,只能重新获取重新来一遍了。
5,线程A重新获取value值,因为value被volatile修饰,所以其线程对它的修改,线程A总是能够看到,线程A继续执行comapreAndSwapInt进行比较替换,直到成功。
感谢 并参考:
https://mp.weixin.qq.com/s/GPEHpzQvAXNvH0i__Hok4Q