CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。CAS也是现在面试经常问的问题,本文将深入的介绍CAS的原理。
再介绍CAS之前,我们先来看一个例子:
package com.cjian.JUC;
import java.util.concurrent.CountDownLatch;
/**
* @description:
* @author: CJ
* @time: 2020/12/7 18:29
*/
public class CasDemo01 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
int threadSize = 100;
CountDownLatch latch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
new Thread(()->{
for (int j = 0; j < 10; j++) {
request();
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("count:" + count);
}
private static void request() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}
这里我们通过指令枪,让100个线程一起去执行10次的count++,结果并未如我们所预想的1000,绝大部分结果是一个小于1000的数,
有的老铁会说了,加一个volatile(JUC——volatile关键字的使用以及底层原理浅析),上篇文章最后总结也说明了,volatile解决了共享变量的可见性和有序性问题,并未解决原子性问题,这里的count++恰恰就是一个非原子性的操作,在执行期间分为了三步:取值,+1操作,再赋值。
上面这个例子我们可以通过加入同步锁来解决,但性能较差,因此我们可以使用juc下的原子操作类:
package com.cjian.JUC;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @description:
* @author: CJ
* @time: 2020/12/7 18:29
*/
public class CasDemo01_Atomic {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
int threadSize = 100;
CountDownLatch latch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
new Thread(()->{
for (int j = 0; j < 10; j++) {
request();
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("count:" + count);
}
private static synchronized void request() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count.getAndIncrement();
}
}
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
getAndAddInt方法如下,也就是本文将要介绍的CAS
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
compareAndSwapInt 是Unsafe类的方法,Unsafe是CAS核心类,由于java方法无法访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为java中CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类中所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。
CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用Unsafe中的CAS方法,JVM会帮我们实现出CAS汇编指令。
这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致性问题。
那CPU如何实现原子操作?
CPU 处理器速度远远大于在主内存中的,为了解决速度差异,在他们之间架设了多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离CPU越近就越快,将频繁操作的数据缓存到这里,加快访问速度
现在都是多核 CPU 处理器,每个 CPU 处理器内维护了一块字节的内存,每个内核内部维护着一块字节的缓存,当多线程并发读写时,就会出现缓存数据不一致的情况。
此时,处理器提供:
- 总线锁定
当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。
缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
- 缓存锁定
后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现的。
现代的处理器基本都支持和使用的缓存锁定机制。
注意:
有如下两种情况处理器不会使用缓存锁定:
(1)当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定。
(2)有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定。
intel手册对lock前缀的说明如下:
- 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
- 禁止该指令与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
上面的第1点保证了CAS操作是一个原子操作,第2点和第3点所具有的内存屏障效果,保证了CAS同时具有volatile读和volatile写的内存语义。
CAS的思想:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。
CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
compareAndSwapInt是由C++语言编写的,底层方法为Atomic::cmpxchg(cmpxchg是汇编指令,作用是比较并交换操作数)
具体源码可参考本文下面的链接
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题:
- 循环时间长开销很大。
- 只能保证一个变量的原子操作。
- ABA问题。
循环时间长开销很大:
CAS 通常是配合无限循环一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。
只能保证一个变量的原子操作:
当对一个变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个变量操作时,CAS 目前无法直接保证操作的原子性。但是我们可以通过以下两种办法来解决:1)使用互斥锁来保证原子性;2)将多个变量封装成对象,通过 AtomicReference 来保证原子性。
什么是ABA问题?ABA问题怎么解决?
CAS 的使用流程通常如下:1)首先从地址 V 读取值 A;2)根据 A 计算目标值 B;3)通过 CAS 以原子的方式将地址 V 中的值从 A 修改为 B。
但是在第1步中读取的值是A,并且在第3步修改成功了,我们就能说它的值在第1步和第3步之间没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
通过一个列子来展示下ABA问题:
定义一个atomicInteger 的共享变量,线程main先去修改值,然后睡眠一秒,线程other将atomicInteger自增1后再自减1,然后线程A再去执行compareAndSet,发现执行成功
package com.cjian.JUC;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @description: 模拟ABA问题
* @author: CJ
* @time: 2020/12/7 18:29
*/
public class CasABADemo01 {
static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread().getName() + ",初始值:" + atomicInteger.get());
int oldValue = atomicInteger.get();
int newValue = oldValue + 1;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean flag = atomicInteger.compareAndSet(oldValue, newValue);
System.out.println("操作线程" + Thread.currentThread().getName() + ",cas操作:" + flag);
}, "main").start();
new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicInteger.incrementAndGet();
System.out.println("干扰线程" + Thread.currentThread().getName() + ",increment值:" + atomicInteger.get());
atomicInteger.decrementAndGet();
System.out.println("干扰线程" + Thread.currentThread().getName() + ",decrement值:" + atomicInteger.get());
}, "other").start();
}
}
操作线程main,初始值:0
干扰线程other,increment值:1
干扰线程other,decrement值:0
操作线程main,cas操作:true
这就是ABA问题,该如何解决呢?Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,修改如下:
package com.cjian.JUC;
import java.util.concurrent.atomic.AtomicStampedReference;
/**
* @description: 解决ABA问题
* @author: CJ
* @time: 2020/12/7 18:29
*/
public class CasABADemo02 {
static AtomicStampedReference<Integer> atomicInteger = new AtomicStampedReference(new Integer(0), 0);
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread().getName() + ",初始值:" + atomicInteger.getReference());
int oldValue = atomicInteger.getReference();
int oldStamp = atomicInteger.getStamp();
int newValue = oldValue + 1;
int newStamp = oldStamp + 1;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean flag = atomicInteger.compareAndSet(oldValue, newValue, oldStamp, newStamp);
System.out.println("操作线程" + Thread.currentThread().getName() + ",cas操作:" + flag);
}, "main").start();
new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicInteger.compareAndSet(atomicInteger.getReference(), atomicInteger.getReference() + 1,
atomicInteger.getStamp(), atomicInteger.getStamp() + 1);
System.out.println(
"干扰线程" + Thread.currentThread().getName() + ",increment值:" + atomicInteger.getReference() + ",版本号:"
+ atomicInteger.getStamp());
atomicInteger.compareAndSet(atomicInteger.getReference(), atomicInteger.getReference() - 1,
atomicInteger.getStamp(), atomicInteger.getStamp() + 1);
System.out.println(
"干扰线程" + Thread.currentThread().getName() + ",decrement值:" + atomicInteger.getReference() + ",版本号:"
+ atomicInteger.getStamp());
}, "other").start();
}
}
操作线程main,初始值:0
干扰线程other,increment值:1,版本号:1
干扰线程other,decrement值:0,版本号:2
操作线程main,cas操作:false
本文参考: