关于CAS你知道多少
CAS,即 Compare And Swap,比较并交换 。是 Java rt.jar 下
java.util.concurrent.atomic
包下类的核心原理,可能这个包你并不是很熟悉,但是你应该听过AutomicInteger
这个类。
CAS 的前世
首先我们来探讨一下究竟是什么机缘巧合出现了 CAS
这个东东,说到 CAS 就不得不说 JMM(Java内存模型)(以下引用摘自 深入理解Java虚拟机)
Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。它的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,在线程的工作内存中被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接读写主内存中的变量。
JMM(Java Memory Model,Java内存模型)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。
由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而Java内存模型中的所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程的工作内存中存储着主内存中的 变量副本拷贝
,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
下图就是对上面文字的一个直观“翻译”:
以一段代码来简要说明线程T1和T2各自工作内存的不可见:
public class Test {
public static void main(String[] args) {
User user = new User();
// 第一个线程:T1修改 age 的值
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 2秒后修改 age 的值
user.updateAge();
System.out.println(Thread.currentThread().getName() + "\t add number to:" + user.getAge());
}, "thread T1").start();
// 第二个线程:main线程
while (user.getAge() == 18) {
// main线程一直等待
}
System.out.println(Thread.currentThread().getName() + "\t is over");
}
}
class User {
private Integer age = 18;
public void updateAge() {
this.age = 24;
}
public Integer getAge() {
return age;
}
}
执行上述代码,我们发现程序一直是运行状态,因为两个线程的工作内存中的变量的副本值都是 18,所以T1可以正常执行并且修改了主内存中变量的值,T2则一直在 while 循环中,之后的语句也不会输出。
那怎么上述问题呢?
- 第一种方式,给变量加
volatile
关键字(不在本文讨论范围内),暂时只需知道这个关键字可以保证主内存变量的 可见性 以及 禁止指令重排序,但是 不保证原子性。 - 第二种方式,将变量声明为 AtomicInteger 类型即可。即只修改User类即可,如下
class User {
private AtomicInteger age = new AtomicInteger(18);
public void updateAge() {
age.compareAndSet(18, 24);
}
public Integer getAge() {
return age.get();
}
}
上面就用到了 J.U.C 包下的原子类 AtomicInteger,本文的主角登场了!鼓掌欢迎!!!
CAS的今生
有了上面的一点基础知识,那么我们就从 AutomicInteger
这个类着手,来探讨一下到底什么是 CAS
!
AutomicInteger
这个类的核心方法都是基于 Unsafe
类的方法实现的,Unsafe 类是 CAS 的核心类,由于 Java方法无法直接访问底层系统,需要通过本地方法(native)来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据。
那么我们猫一眼 getAndIncrement 这个方法(类似 i++)的代码,其他方法类似
// AutomicInteger.getAndIncrement() 方法
public final int getAndIncrement() {
// this:当前对象;valueOffset:偏移量,可以理解为变量的主内存地址;1:就是加1操作
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// Unsafe 类的方法
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取当前对象this内存地址偏移量valueOffSet处的变量值(主内存中的)到自己的工作内存
var5 = this.getIntVolatile(var1, var2);
// 如果当前对象this(var1)内存地址偏移量valueOffSet(var2)处的
// 变量值(主内存中的)与自己工作内存中的变量值(var5)相等则执行var5 + var4
// 注意:这里是取反逻辑
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
上面代码的注释解释的也比较详细了,getAndAddInt方法的执行过程是:线程先拿到主内存中变量的值的拷贝,然后尝试比较自己工作内存中的变量值与主内存中的变量值是否相等,如果相等就将主内存变量设置为新值,否则一直循环尝试,这也就带来了一些问题,下面就来说说CAS的缺点吧。
CAS的缺点
- 循环时间长开销大
比如 getAndAddInt() 方法中的 do while 循环,如果 CAS 失败,会一直进行尝试,如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。 - 只能保证一个共享变量的原子操作
就拿AtomicInteger来说,它只能用来定义int类型的变量,不能定义引用类型的变量,不要慌,JDK也为我们提供了原子引用类AtomicReference<V>,有了泛型的支持,我们就可以定义任何类型的原子引用类了,比如AtomicReference<Integer>、AtomicReference<User>等等。 - ABA问题
通俗来讲就是 线程T1 和 T2 都持有主内存中变量的副本拷贝 A,T1 将主内存的值修改为 B,然后又改回 A,然后 T2 在进行 CAS 的时候发现变量的值并没有改变,然后 T2 操作成功。但是尽管 T2 最后的操作是成功的,但是并不代表这个过程是没问题的。比如下面代码这样的操作我们发现最终操作是成功的,但是中间过程却有猫腻:
public class ABATest {
public static void main(String[] args) {
AtomicReference<Character> atomicChar = new AtomicReference<>('A');
new Thread(() -> {
atomicChar.compareAndSet('A', 'B');
atomicChar.compareAndSet('B', 'A');
}, "thread T1").start();
new Thread(() -> {
// sleep 1 秒,模拟T1执行完
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicChar.compareAndSet('A', 'C') + ", char = " + atomicChar.get());
}, "thread T2").start();
}
}
// 输出
true, char = C
那么如何解决 ABA
这个问题呢?
ABA问题的核心是当线程从主内存拷贝变量的副本到自己的工作内存中执行操作,然后写回主内存这期间一旦主内存中的变量被其他线程修改了又改回了原来的值,这期间变量发生任何变化我们是无从得知的,那既然这样的话,我们考虑可不可以给变量一个时间戳或者版本号呢?只要有线程操作了主内存中的变量,就将这个变量的版本号+1。其实大神们已经给我们提供了解决方案,那就是 AtomicStampedReference
带有时间戳的原子引用类,我们可以使用这个类来代替 AtomicInteger
,体现在代码上就是下面这样:
public class ResolveABATest {
public static void main(String[] args) {
// 设置初始值为 A,版本号是 1
AtomicStampedReference<Character> atomicStampedChar = new AtomicStampedReference<>('A', 1);
new Thread(() -> {
// 期望值为 A,设置为 B ;期望版本号为 1,设置为 2
atomicStampedChar.compareAndSet('A', 'B', 1, 2);
// 期望值为 B,设置为 A ;期望版本号为 2,设置为 3
atomicStampedChar.compareAndSet('B', 'A', 2, 3);
}, "thread T1").start();
new Thread(() -> {
// sleep 2 秒,模拟T1执行完
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 期望值为 A,设置为 C ;期望版本号为 1,设置为 2
boolean result = atomicStampedChar.compareAndSet('A', 'C', 1, 2);
System.out.println("修改结果:" + result + ", 当前版本号 " + atomicStampedChar.getStamp());
}, "thread T2").start();
}
}
// 输出
修改结果:false, 当前版本号 3
通过上面的代码,我们可以了解到:只要合理设置版本号,就可以规避 ABA 问题。
好了,今天的分享就到这里吧!如有不正确之处还望指出!
那么,关于 CAS 你现在知道多少了呢?