一 概述
我们讲了 N 篇关于锁的知识,确实,锁是解决并发问题的万能钥匙,可是并发问题只有锁能解决吗?今天要出场一个大 BOSS:CAS 无锁算法,可谓是并发编程核心中的核心!
温故
首先我们再回顾一下原子性问题的原因,参考【漫画】JAVA并发编程 如何解决原子性问题。
两个线程同时把 count=0 加载到自己的工作内存,线程 B 先执行 count++ 操作,此时主内存已经变化成了 1,但是线程 A 依旧以为 count=0,这是导致问题的根源。
所以解决方案就是:不能让线程 A 以为 count=0,而是要和主内存进行一次 compare(比较),如果内存中的值是 0,说明没有其他线程更新过 count 值,那么就 swap(交换),把新值写回主内存。如果内存中的值不是0,比如本案例中,内存中 count 就已经被线程 B 更新成了 1,比较 0!=1,因此 compare 失败,不把新值写回主内存。
二 CAS 概念
CAS (compareAndSwap),中文叫比较交换,一种无锁原子算法。
CAS 算法包含 3 个参数 CAS(V,E,N),V 表示要更新变量在内存中的值,E 表示旧的预期值,N 表示新值。 仅当 V 值等于E 值时,才会将 V 的值设为 N。 如果 V 值和 E 值不同,则说明已经有其他线程做两个更新,那么当前线程不做更新,而是自旋。
2.1 模拟自旋
既然我们了解了 CAS 的思想,那可以手写一个简单的 CAS 模型:
// count 必须用 volatile 修饰 保证不同线程之间的可见性
private volatile static int count;
public void addOne() {
int newValue;
do {
newValue = count++;
} while (!compareAndSwapInt(expectCount, newValue)); //自旋 循环
}
public final boolean compareAndSwapInt(int expectCount, int newValue) {
// 读目前 count 的值
int curValue = count;
// 比较目前 count 值是否 == 期望值
if (curValue == expectCount) {
// 如果是,则更新 count 的值
count = newValue;
return true;
}
//否则返回false 然后循环
return false;
}
这个简单的模拟代码,其实基本上把 CAS 的思想体现出来了,但实际上 CAS 原理可要复杂很多哦,我们还是看看 JAVA 是怎么实现 CAS 的吧!
2.2 原子类
要了解 JAVA 中 CAS 的实现,那不得不提到大名鼎鼎的原子类,原子类的使用非常简单,而其中深奥的原理就是 CAS 无锁算法。
Java 并发包里提供的原子类内容很丰富,我们可以将它们分为五个类别:原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。
原子类的使用可谓非常简单,相信只要看一下 api 就知道如何使用,因此不过多解释。 此处只以 AtomicInteger 为例子,测试一下原子类是否名副其实可以保证原子性:
private static AtomicInteger count = new AtomicInteger(0);
private static int count1 = 0;
//省略代码 同时启动10个线程 分别测试AtomicInteger和普通int的输出结果
private static void add10K() {
int idx = 0;
while (idx++ < 10000) {
//使用incrementAndGet实现i++功能
count.incrementAndGet();
}
countDownLatch.countDown();
}
private static void add10K1() {
int idx = 0;
while (idx++ < 10000) {
count1++;
}
countDownLatch.countDown();
}
通过测试可以发现,使用 AtomicInteger 可以保证输出结果为 100000,而普通 int 则不能保证。
三 CAS 源码分析
据此,我们又可以回归正题,JAVA 是怎么实现 CAS 的呢?跟踪一下 AtomicInteger 中的 incrementAndGet() 方法,相信就会有答案了。 首先关注一下 AtomicInteger.java 中这么几个东西:
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 数据在内存中的地址偏移量,通过偏移地址可以获取数据原值
private static final long valueOffset;
static {
try {
// 计算变量 value 在类对象中的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value; // 要修改的值 volatile 保证可见性
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
Unsafe,是 CAS 的核心类,由于 Java 方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据。 变量 valueOffset,表示该变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的。 变量 value 必须用 volatile 修饰,保证了多线程之间的内存可见性。
当然具体实现我们还是得瞧瞧 getAndAddInt 方法:
// 内部使用自旋的方式进行 CAS 更新(while 循环进行 CAS 更新,如果更新失败,则循环再次重试)
public final int getAndAddInt(Object var1, long var2, int var4) {
// var1 为当前这个对象,如 count.getAndIncrement(),则 var1 为 count 这个对象
// 第二个参数为 AtomicInteger 对象 value 成员变量在内存中的偏移量
// 第三个参数为要增加的值
int var5;
do {
// var5 获取对象内存地址偏移量上的数值 v 即预期旧值
var5 = this.getIntVolatile(var1, var2);
// 循环判断内存位置的值与预期原值是否相匹配
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
此时我们还想继续了解 compareAndSwapInt 的实现,点进去看,首先映入眼帘的是四个参数:1、当前的实例 2、实例变量的内存地址偏移量 3、预期的旧值 4、要更新的值
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
还想继续刨根问底,会发现点不动了。因为用 native 修饰的方法代表是底层方法,当然如果你非得一探究竟你也可以找找对应的unsafe.cpp 文件进行深度解析 C 代码:
个人认为没必要深究,毕竟术业有专攻,你只需要知道其实核心代码就是一条 cmpxchg 指令。 cmpxchg:即“比较并交换”指令。与我们上面说的思想是一样的:将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
总之,你只需要记住:CAS 是靠硬件实现的,从而在硬件层面提升效率。实现方式是基于硬件平台的汇编指令,在 intel 的 CPU 中,使用的是 cmpxchg 指令。 核心思想就是:比较要更新变量的值 V 和预期值 E(compare),相等才会将 V 的值设为新值 N(swap)。
四 CAS 限制因素
CAS 和锁都解决了原子性问题,和锁相比,由于其非阻塞的,它对死锁问题天生免疫,并且,线程间的相互影响也非常小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,他要比基于锁的方式拥有更优越的性能。
但是,CAS 真的有那么好吗?又到挑刺时间了!
要让我们失望了,CAS 并没有那么好,主要表现在三个方面:
- 循环时间太长
- 只能保证一个共享变量原子操作
- ABA 问题。
循环时间太长 :如果 CAS 长时间地不成功,我们知道会持续循环、自旋。必然会给 CPU 带来非常大的开销。在 JUC 中有些地方就限制了 CAS 自旋的次数,例如 BlockingQueue 的 SynchronousQueue。
只能保证一个共享变量原子操作: 看了 CAS 的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用 CAS 也不错。例如读写锁中 state 的高低位。
ABA 问题 :这可是个面试重点问题哦!认真听好!
CAS 需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是 A,变成了 B,然后又变成了 A,那么在 CAS 检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的 ABA 问题。 某些情况我们并不关心 ABA 问题,例如数值的原子递增,但也不能所有情况下都不关心,例如原子化的更新对象很可能就需要关心 ABA 问题,因为两个 A 虽然相等,但是第二个 A 的属性可能已经发生变化了。
对于 ABA 问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加 1,即 A —> B —> A,变成 1A —> 2B —> 3A。
原子类之 AtomicStampedReference 可以解决 ABA 问题,它内部不仅维护了对象值,还维护了一个 Stamp(可把它理解为版本号,它使用整数来表示状态值)。当 AtomicStampedReference 对应的数值被修改时,除了更新数据本身外,还必须要更新版本号。当 AtomicStampedReference 设置对象值时,对象值以及版本号都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。
// 参数依次为:期望值 写入新值 期望版本号 新版本号
public boolean compareAndSet(V expectedReference, V
newReference, int expectedStamp, int newStamp);
//获得当前对象引用
public V getReference();
//获得当前版本号
public int getStamp();
//设置当前对象引用和版本号
public void set(V newReference, int newStamp);
说理论太多也没用,还是亲自实验它是否能解决 ABA 问题吧:
private static AtomicStampedReference<Integer> count = new AtomicStampedReference<>(10, 0);
public static void main(String[] args) {
Thread main = new Thread(() -> {
int stamp = count.getStamp(); //获取当前版本
log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
boolean isCASSuccess = count.compareAndSet(10, 12, stamp, stamp + 1);
log.info("CAS是否成功={}",isCASSuccess);
}, "主操作线程");
Thread other = new Thread(() -> {
int stamp = count.getStamp(); //获取当前版本
log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
count.compareAndSet(10, 12, stamp, stamp + 1);
log.info("线程{} 增加后版本{}",Thread.currentThread(),count.getStamp());
// 模拟ABA问题 先更新成12 又更新回10
int stamp1 = count.getStamp(); //获取当前版本
count.compareAndSet(12, 10, stamp1, stamp1 + 1);
log.info("线程{} 减少后版本{}",Thread.currentThread(),count.getStamp());
}, "干扰线程");
main.start();
other.start();
}
输出结果如下:
线程Thread[主操作线程,5,main] 当前版本0
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 当前版本0
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 增加后版本1
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 减少后版本2
[主操作线程] INFO - CAS是否成功=false
五 总结
JAVA 博大精深,解决并发问题可不仅仅是锁才能担此大任。CAS 无锁算法对于解决原子性问题同样是势在必得。而原子类,则是无锁工具类的典范,原子类包括五大类型(原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器)。
CAS 是一种乐观锁,乐观锁会以一种更加乐观的态度对待事情,认为自己可以操作成功。而悲观锁会让线程一直阻塞。因此 CAS 具有很多优势,比如性能佳、可以避免死锁。但是它没有那么好,你应该考虑到 ABA 问题、循环时间长的问题。因此需要综合选择,适合自己的才是最好的。