什么是CAS和ABA问题?如何解决?
CAS(Compare and Swap)是一种轻量级的同步操作,也是乐观锁的一种实现,它用于实现多线程环境下的并发算法。CAS 操作包含三个操作数:内存位置(或者说是一个变量的引用)、预期的值和新值。如果内存位置的值和预期值相等,那么处理器会自动将该位置的值更新为新值,否则不进行任何操作。
在多线程环境中,CAS 可以实现非阻塞算法,避免了使用锁所带来的上下文切换、调度延迟、死锁等问题,因此被广泛应用于并发编程中。
CAS 示例
在 Java 中,CAS 操作被封装在 Atomic 类中,例如 AtomicInteger 类就是利用了 CAS 操作来实现线程安全的自增操作。同时,Java 还提供了一些工具类来支持 CAS 操作,例如 Unsafe 类,它提供了一些原始的 CAS 操作方法,供 JVM 内部使用,比如以下是基于 Unsafe 类的 CAS 示例:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;
public class CASDemo {
private volatile int value = 0;
private static Unsafe unsafe;
private static long valueOffset;
static {
try {
// 通过反射获取rt.jar包中的Unsafe类,默认Unsafe类是不能使用的
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
valueOffset = unsafe.objectFieldOffset(CASDemo.class.getDeclaredField("value"));
} catch (Exception e) {
e.printStackTrace();
}
}
public void addOne() {
int current;
do {
current = unsafe.getIntVolatile(this, valueOffset);
} while (!unsafe.compareAndSwapInt(this, valueOffset, current, current + 1));
}
public static void main(String[] args) throws InterruptedException {
final CASDemo casDemo = new CASDemo();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
casDemo.addOne();
}
}).start();
}
TimeUnit.SECONDS.sleep(5);
System.out.println(casDemo.value);
}
}
以上程序的执行结果为:
10000
程序开启了 10 个线程,每个线程调用 1000 次,最终执行的结果是 10000,说明以上程序是线程安全的。
CAS 执行流程
CAS 执行的具体流程如下:
-
将需要修改的值从主内存中读入本地线程缓存(工作内存);
-
执行 CAS 操作,将本地线程缓存中的值与主内存中的值进行比较;
-
如果本地线程缓存中的值与主内存中的值相等,则将需要修改的值在本地线程缓存中修改;
-
如果修改成功,将修改后的值写入主内存,并返回修改结果;如果失败,则返回当前主内存中的值;
-
在多线程并发执行的情况下,如果多个线程同时执行 CAS 操作,只有一个线程的 CAS 操作会成功,其他线程的 CAS 操作都会失败,这也是 CAS 的原子性保证。
ABA 问题
CAS 机制存在 ABA 问题。
所谓的 ABA 问题是指在并发编程中,如果一个变量初次读取的时候是 A 值,它的值被改成了 B,然后又其他线程把 B 值改成了 A,而另一个早期线程在对比值时会误以为此值没有发生改变,但其实已经发生变化了,这就是 ABA 问题。
比如:张三去银行取钱,余额有 200 元,张三取 100 元,但因为程序的问题,启动了两个线程,线程一和线程二进行比对扣款,线程一获取原本有 200 元,扣除 100 元,余额等于 100 元,此时李四给张三转账 100 元,于是启动了线程三抢先在线程二之前执行了转账操作,把 100 元又变成了 200 元,而此时线程二对比自己事先拿到的 200 元和此时经过改动的 200 元值一样,就进行了减法操作,把余额又变成了 100 元。这显然不是我们要的正确结果,我们想要的结果是余额减少了 100 元,又增加了 100 元,余额还是 200 元,而此时余额变成了 100 元,显然有悖常理,这就是著名的 ABA 的问题。
它的执行流程如下:
- 线程一:取款,获取原值 200 元,与 200 元比对成功,减去 100 元,修改结果为 100 元。
- 线程二:取款,获取原值 200 元,阻塞等待修改。
- 线程三:转账,获取原值 100 元,与 100 元比对成功,加上 100 元,修改结果为 200 元。
- 线程二:取款,恢复执行,原值为 200 元,与 200 元对比成功,减去 100 元,修改结果为 100 元。
最终的结果是 100 元。
解决 ABA
解决 ABA 问题的一种方法是使用带版本号的 CAS,也称为双重 CAS(Double CAS)或者版本号 CAS。具体来说,每次进行 CAS 操作时,不仅需要比较要修改的内存地址的值与期望的值是否相等,还需要比较这个内存地址的版本号是否与期望的版本号相等。如果相等,才进行修改操作。这样,在修改后的值后面追加上一个版本号,即使变量的值从 A 变成了 B 再变成了 A,版本号也会发生变化,从而避免了误判。
以下是一个使用 AtomicStampedReference 来解决 ABA 问题的示例代码:
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABADemo {
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(1, 0);
public static void main(String[] args) throws InterruptedException {
System.out.println("初始值:" + atomicStampedRef.getReference() + ",版本号:" + atomicStampedRef.getStamp());
// 线程 1 先执行一次 CAS 操作,期望值为 1,新值为 2,版本号为 0
Thread thread1 = new Thread(() -> {
int stamp = atomicStampedRef.getStamp();
atomicStampedRef.compareAndSet(1, 2, stamp, stamp + 1);
});
// 线程 2 先 sleep 1 秒,让线程 1 先执行一次 CAS 操作,然后再执行一次 CAS 操作,期望值为 2,新值为 1,版本号为 1
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int stamp = atomicStampedRef.getStamp();
atomicStampedRef.compareAndSet(2, 1, stamp, stamp + 1);
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("最终值:" + atomicStampedRef.getReference() + ",版本号:" + atomicStampedRef.getStamp());
}
}
以上程序的执行结果为:
初始值:1,版本号:0
最终值:1,版本号:2
从输出结果可以看出,即使变量的值从 1 变成了 2 再变成了 1,使用带版本号的 CAS 操作也能正确判断变量是否发生了变化。
小结
CAS 是一种轻量级的同步操作,也是乐观锁的一种实现,用它可以解决并发环境下的多线程安全问题。但 CAS 存在 ABA 问题,也就线程无法判断某个相同的值是否真的被改动?此时就需要使用带版本号的 CAS,也称为双重 CAS 或者版本号 CAS 来解决 ABA 问题。
本文已收录至《Java面试突击》,专注 Java 面试 100 年,查看更多:www.javacn.site