CAS的无锁思想
众所周知,Java中对并发控制的最常见方法就是锁,锁能保证同一时刻只能有一个线程访问临界区的资源,从而实现线程安全。然而,锁虽然有效,但采用的是一种悲观的策略。它假设每一次对临界区资源的访问都会发生冲突,当有一个线程访问资源,其他线程就必须等待,所以锁是会阻塞线程执行的。
当然,凡事都有两面,有悲观就会有乐观。而无锁就是一种乐观的策略,它假设线程对资源的访问是没有冲突的,同时所有的线程执行都不需要等待,可以持续执行。如果遇到冲突的话,就使用一种叫做CAS (比较交换) 的技术来鉴别线程冲突,如果检测到冲突发生,就重试当前操作到没有冲突为止。
Unsafe类
简单讲一下这个类。Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。
这个类尽管里面的方法都是public的,但是我们在应用代码中并没有办法使用它们,JDK API文档也没有提供任何关于这个类的方法的解释。总而言之,对于Unsafe类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然JDK库里面的类是可以随意使用的。
从第一行的描述可以了解到Unsafe提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。
举个例子,比如:
public native long staticFieldOffset(Field paramField);
这个方法可以用来获取给定的paramField的内存地址偏移量,这个值对于给定的field是唯一的且是固定不变的。
CAS概述
CAS的全称是Compare-and-Swap,也就是比较并交换,是并发编程中一种常用的算法。设计并发算法时常用到的一种技术,java.util.concurrent 包全完建立在CAS之上,没有CAS也就没有此包,可见CAS的重要性。当前的处理器基本都支持CAS,只不过不同的厂家的实现不一样罢了。
CAS靠硬件实现,是一条CPU的原子指令,基于汇编指令CMPXCHG(Intel x86)实现,这个指令带Lock前缀,其作用是让CPU先比较两个值是否相等,然后原子性地更新某个内存地址的值。我们一般说的CAS在x86处理器上的大概写法是:
lock cmpxchg a, b, c
对于Lock指令,具体有两种实现方法。对于早期的CPU,总是采用的是锁总线的方式。一旦遇到了Lock指令,就由仲裁器选择一个核心独占总线。其余的CPU核心不能再通过总线与内存通讯。从而达到“原子性”的目的。这种方式的确能解决问题,但是非常不高效。为了个原子性结果搞得其他CPU核心都不能干活了,因此从Intel P6 CPU开始就做了一个优化,改用缓存锁。如果是P6后的CPU,并且数据已经被CPU缓存了,并且是要写回到主存的,则可以用cache locking处理问题,否则还是得锁总线。因此,lock到底用锁总线,还是用cache locking,完全是看当时的情况。总线锁与缓存锁的具体逻辑见:cas锁的是总线还是缓存行。
CAS有三个参数:V、A、B。内存值V、旧的预期值A、要修改的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做并返回false。CAS指令执行时,当且仅当V的值等于预期值A时,才会将V的值设为B。在多线程的情况下,当多个线程同时使用CAS操作一个变量时,只有一个会成功并更新值,其余线程均会失败,但失败的线程不会被挂起,而是不断的再次循环重试。正是基于这样的原理,CAS即时没有使用锁,也能发现其他线程对当前线程的干扰,从而进行及时的处理。可见CAS其实是一个乐观锁。
CAS操作大概有如下几步:
- 读取旧值为一个临时变量
- 对旧值的临时变量进行操作或者依赖旧值临时变量进行一些操作
- 判断旧值临时变量是不是等于旧值,等于则没被修改,那么新值写入;不等于则被修改,此时放弃或者从步骤1重试。
那么步骤3实际上就是比较并替换,这个操作需要是原子性的,不然无法保证比较操作之后还没写入之前有其他线程操作修改了旧值。那么这一步实际上就是CAS(CompareAndSwap),其是需要操作系统底层支持,对于操作系统会转换为一条指令。
Tips:原子操作,顾名思义,就是说像原子一样不可再细分不可被中途打断。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch(切换到另一个线程)
CAS 也是通过 Unsafe 实现的,看下 Unsafe 下的三个方法:
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);
public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);
就拿中间这个比较并交换 Int 值为例好了,如果我们不用 CAS,那么代码大致是这样的:
public int i = 1;
public boolean compareAndSwapInt(int j) {
if (i == 1) {
i = j;
return true;
}
return false;
}
当然这段代码在并发下是肯定有问题的,有可能线程1运行完了if判断语句,正准备运行赋值语句时时间片用完,切换到线程2运行了,线程2把i修改为10,然后线程切换回去,线程1由于先前已经满足了if判断,所以导致两个线程同时修改了变量 i。
解决办法也很简单,给 compareAndSwapInt 方法加锁同步就行了,这样,compareAndSwapInt 方法就变成了一个原子操作。CAS也是一样的道理,比较交换也是一次原子操作,不会被外部打断,先根据 paramObject和paramLong获取到内存当中当前的内存值V,在将内存值V和原值A作比较,要是相等就修改为要修改的值B,由于CAS都是硬件级别的操作,因此效率会高一些。
用CAS分析AtomicInteger原理
java.util.concurrent.atomic 包下的原子操作类都是基于 CAS 实现的,下面拿 AtomicInteger 分析一下,首先是 AtomicInteger 类变量的定义:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
//这里value用volatile关键字修饰,这一点很重要
private volatile int value;
...
}
关于这段代码中出现的几个成员属性:
- Unsafe是CAS的核心类,前面已经讲过了。
- valueOffset表示的是变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的原值的。
- value 是用volatile修饰的,这是非常关键的。
下面找一个方法来研究一下 AtomicInteger 是如何实现的,比如我们常用的addAndGet方法:
/**
* Atomically adds the given value to the current value.
*
* @param delta the value to add
* @return the updated value
*/
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
/**
* Gets the current value.
*
* @return the current value
*/
public final int get() {
return value;
}
AtomicInteger类的addAndGet方法调用了Unsafe类的getAndAddInt方法。
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
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;
}
这段代码如何在不加锁的情况下通过 CAS 实现线程安全,我们不妨考虑一下方法的执行:
- 假设AtomicInteger 里面的value原始值为 3,即主内存中AtomicInteger的value为3,根据Java内存模型,线程1和线程2各自持有一份value的副本,值为3。
- 线程1运行到getAndAddInt方法的第三行获取到当前的value为3,线程切换。
- 线程2开始运行,获取到value 为3,利用CAS对比内存中的值也为3,比较成功,修改内存,此时内存中的value改变比方说是4,线程切换。
- 线程1恢复运行,利用CAS比较发现自己的value为3,内存中的value为4,得到一个重要的结论:此时 value 正在被另外一个线程修改,所以我不能去修改它。
- 线程1的compareAndSet失败,循环判断,因为value是volatile修饰的,所以它具备可见性的特性,线程2对于value 的改变能被线程1看到,只要线程1发现当前获取的value是4,内存中的value也是4,说明线程2对于value的修改已经完毕并且线程1可以尝试去修改它。
- 最后说一点,比如说此时线程 3 也准备修改value了,没关系,因为比较交换是一个原子操作不可被打断,线程3修改了value,线程1进行 compareAndSet的时候必然返回的 false,这样线程1会继续循环去获取最新的value并进行compareAndSet,直至获取的value和内存中的value 一致为止。
整个过程中,利用 CAS 机制保证了对于 value 的修改的线程安全性。
CAS的缺点
CAS 看起来很美,但这种操作显然无法涵盖并发下的所有场景,并且 CAS 从语义上来说也不是完美的,
- ABA的问题,就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了,因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。也可以使用携带类似时间戳的版本AtomicStampedReference。
- 循环时间长开销大导致的性能问题,我们使用时大部分时间使用的是 while(true)方式对数据的修改,直到成功为止。优势就是相应极快,但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。
总结
CAS是整个编程重要的思想之一。整个计算机的实现中都有CAS的身影。微观上看汇编的CAS是实现操作系统级别的原子操作的基石。从编程语言角度来看 CAS是实现多线程非阻塞操作的基石。宏观上看,在分布式系统中,我们可以使用CAS的思想利用类似Redis的外部存储,也能实现一个分布式锁。
从某个角度来说架构就将微观的实现放大,或者底层思想就是将宏观的架构进行微缩。计算机的思想是想通的,所以说了解底层的实现可以提升架构能力,提升架构的能力同样可加深对底层实现的理解。计算机知识浩如烟海,但是套路有限。抓住基础的几个套路突破,从思想和思维的角度学习计算机知识。
参考文档
https://cloud.tencent.com/developer/article/1347598
https://zhuanlan.zhihu.com/p/42139837