CAS
思想产生的背景
在 jdk1.5
之前 Java
语言是靠 synchronized
关键字保证同步的,而 synchronized
的锁机制存在以下问题:
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题
- 一个线程持有锁会导致其它所有需要此锁的线程挂起
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险
volatile
是不错的机制,但是 volatile
不能保证原子性。因此对于同步最终还是要回到锁机制上来。在此情况下,CAS
思想就产生了
CAS
的无锁思想
在谈论无锁概念时,总会关联起乐观锁与悲观锁
- 对于乐观锁而言,他们认为事情总会往好的方向发展,总是认为坏的情况发生的概率特别小,可以无所顾忌地做事
- 对于悲观锁而言,他们总会认为发展事态如果不及时控制,以后就无法挽回了,即使无法挽回的局面几乎不可能发生
这两种锁机制在并发编程中就如同加锁与无锁的策略,即加锁是一种悲观策略,无锁是一种乐观策略
- 对于加锁的并发程序来说,它们总是认为每次访问共享资源时总会发生冲突,因此必须对每一次数据操作实施加锁策略
- 无锁则总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为
CAS
的技术思想来保证线程执行的安全性,这项CAS
技术思想就是无锁策略实现的关键
CAS
详解
CAS
的全称是 Compare-and-Swap
,中文翻译成比较并交换。是并发编程中常用的算法,算法核心思想是函数 CAS(V,E,B)
,包含 3
个参数
V
:内存中的值E
:旧的预期值B
:要修改的值
如果 V
值等于 E
值,则将 V
的值设为 B
。若 V
值和 E
值不同,则说明已经有其他线程做了更新,则当前线程什么都不做
通俗的理解就是 CAS
操作需要我们提供一个预期值,当预期值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行 CAS
操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作
由于 CAS
操作属于乐观派,它总认为自己可以成功完成操作,当多个线程同时使用 CAS
操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作
基于这样的原理,CAS
操作即使没有锁,同样知道其他线程对共享资源操作影响,并执行相应的处理措施。同时从这点也可以看出,由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说无锁操作天生免疫死锁
CPU
指令对 CAS
的支持
或许我们可能会有这样的疑问,假设存在多个线程执行 CAS
操作并且 CAS
的步骤很多,有没有可能在判断 V
和 E
相同后,正要赋值时,切换了线程,更改了值。造成了数据不一致呢?
答案是否定的,因为 CAS
是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS
是一条 CPU
的原子指令,不会造成所谓的数据不一致问题
Unsafe
类
Unsafe
类存在于 sun.misc
包中,其内部方法操作可以像 C
的指针一样直接操作内存,单从名称看来就可以知道该类是非安全的,毕竟 Unsafe
拥有着类似于 C
的指针操作,因此总是不应该首先使用 Unsafe
类,Java
官方也不建议直接使用的 Unsafe
类
但我们还是很有必要了解该类,Unsafe
类是 Java
实现 CAS
的基石,但它的作用不仅仅是实现 CAS
,还有操作直接内存等作用,实际上 Unsafe
类也是 java.util.concurrent
包的实现的基石。关于 Unsafe
的详细理解,可以看这篇文章:Unsafe类的原理详解与使用案例
注意 Unsafe
类中的所有方法都是 native
修饰的,也就是说 Unsafe
类中的方法都直接调用操作系统底层资源执行相应任务
内存管理,Unsafe
类中存在直接操作内存的方法
// 分配内存指定大小的内存
public native long allocateMemory(long bytes);
// 根据给定的内存地址address设置重新分配指定大小的内存
public native long reallocateMemory(long address, long bytes);
// 用于释放allocateMemory和reallocateMemory申请的内存
public native void freeMemory(long address);
// 将指定对象的给定offset偏移量内存块中的所有字节设置为固定值
public native void setMemory(Object o, long offset, long bytes, byte value);
// 设置给定内存地址的值
public native void putAddress(long address, long x);
// 获取指定内存地址的值
public native long getAddress(long address);
// 设置给定内存地址的long值
public native void putLong(long address, long x);
// 获取指定内存地址的long值
public native long getLong(long address);
// 设置或获取指定内存的byte值
// 其他基本数据类型(long,char,float,double,short等)的操作与 putByte 及 getByte 相同
public native byte getByte(long address);
public native void putByte(long address, byte x);
// 操作系统的内存页大小
public native int pageSize();
Unsafe
里的 CAS
操作相关
CAS
是一些 CPU
直接支持的指令,也就是我们前面分析的无锁操作,在 Java
中无锁操作 CAS
基于以下 3
个方法实现
// 第一个参数 o 为给定对象,offset为对象内存的偏移量,通过这个偏移量迅速定位字段并设置或获取该字段的值
// expected 表示期望值,x表示要设置的值,下面3个方法都通过CAS原子指令执行操作
public final native boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
public final native boolean compareAndSwapLong(Object o, long offset,long expected,long x);
挂起与恢复
将一个线程进行挂起是通过 park
方法实现的,调用 park
后,线程将一直阻塞直到超时或者中断等条件出现。unpark
可以终止一个挂起的线程,使其恢复正常。Java
对线程的挂起操作被封装在 LockSupport
类中,LockSupport
类中有各种版本 pack
方法,其底层实现最终还是使用 Unsafe.park()
方法和 Unsafe.unpark()
方法
// 线程调用该方法,线程将一直阻塞直到超时,或者是中断条件出现
public native void park(boolean isAbsolute, long time);
// 终止挂起的线程,恢复正常.java.util.concurrent包中挂起操作都是在LockSupport类实现的,其底层正是使用这两个方法
public native void unpark(Object thread);
CAS
在 Java
中的应用
Atomic
系列
从 jdk 1.5
开始提供了 java.util.concurrent.atomic
包,在该包中提供了许多基于 CAS
实现的原子操作类,用法方便,性能高效。原子更新基本类型主要包括 AtomicBoolean,AtomicInteger,AtomicLong
,这里我们以 AtomicInteger
为例进行分析。AtomicInteger
主要是针对 int
类型的数据执行原子操作,它提供了原子自增方法、原子自减方法以及原子赋值方法等
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 获取指针类 Unsafe
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 下述变量 value 在 AtomicInteger 实例对象内的内存偏移量
private static final long valueOffset;
static {
try {
// 通过 unsafe 类的objectFieldOffset()方法,获取value变量在对象内存中的偏移
// 通过该偏移量valueOffset,unsafe类的内部方法可以获取到变量value对其进行取值或赋值操作
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex); }
}
// 当前AtomicInteger封装的int变量value
private volatile int value;
// 最终会设置成newValue,使用该方法后可能导致其他线程在之后的一小段时间内可以获取到旧值,有点类似于延迟加载
public final void lazySet(int newValue) {
unsafe.putOrderedInt(this, valueOffset, newValue);
}
// 设置新值并获取旧值,底层调用的是CAS操作即unsafe.compareAndSwapInt()方法
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
// 如果当前值为 expect,则设置为update(当前值指的是value变量)
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
// 当前值加1返回旧值,底层CAS操作
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// 当前值减1,返回旧值,底层CAS操作
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
//当前值增加delta,返回旧值,底层CAS操作
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
//当前值加1,返回新值,底层CAS操作
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
//当前值减1,返回新值,底层CAS操作
public final int decrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, -1) - 1;
}
// 当前值增加delta,返回新值,底层CAS操作
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
// 省略一些不常用的方法....
}
CAS
的底层实现原理
CAS
的底层实现,是通过 lock cmpxchg
汇编指令来实现的。cmpxchg
用来实现比较交换,lock
前缀指令用来保证多 cpu
环境下指令所在缓存行的独占同步,保证了原子性,有序性和可见性
从底层汇编指令看起来,CAS
实现的 ‘无锁算法’,并不是真正的消除同步,而是将同步限定在单个指令上面,或者说锁定单个指令,不过这相比于常见的 synchronized
和 ReentrantLock
这些锁来讲,确实可以算作 '无锁了’
CAS
存在的问题
CAS
看起来很美,但这种操作显然无法涵盖并发下的所有场景,并且 CAS
从语义上来说也不是完美的
ABA
问题
CAS
需要再操作值的时候,检查值有没有发生变化,如果没有发生变化则更新。但是一个值,如果原来为 A
,变成了 B
,又变成了 A
,那么使用 CAS
进行 compareandset
的时候,会发现它的值根本没变化过,但实际上是变化过的。ABA
问题的解决思路就是使用版本号,1A->2B->3A
,在 Atomic
包中(jdk 1.5
),提供了一个现成的 AtomicStampedReference
类来解决 ABA
问题,使用的就是添加版本号的方法
循环时间长开销大
由于线程并不会阻塞,如果 CAS
自旋长时间不成功,这会给 CPU
带来非常大的执行开销。如果 JVM
能支持处理器提供的 pause
指令,那么效率会有一定的提升。pause
指令有两个作用:
- 它可以延迟流水线执行指令(
de-pipeline
),使CPU
不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零 - 它可以避免在退出循环的时候因内存顺序冲突(
Memory Order Violation
)而引起CPU
流水线被清空(CPU Pipeline Flush
),从而提高CPU
的执行效率
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环 CAS
的方式来保证原子操作,由于 CAS
底层只能锁定单个指令,均是针对单个变量的操作,对多个共享变量操作时意味着多个指令,此时 CAS
就无法保证所有操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a
,合并一下 ij=2a
,然后用 CAS
来操作 ij
从 jdk 1.5
开始,提供了 AtomicReference
类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS
操作
CAS
与 synchronized
jdk 1.6
之前
- 在
jdk 1.6
之前synchronized
被称为重量锁,因为synchronized
会进行比较复杂的加锁、解锁和唤醒操作,这其中涉及到线程状态的转换,比较耗费时间 CAS
作为非阻塞的轻量级的乐观锁,通过CPU
指令实现,没有线程状态的切换,CAS
在资源竞争不激烈的情况下性能高,而如果资源竞争激烈的话CAS
可能导致大量线程长时间空转(自旋),这样同样会消耗大量CPU
资源
jdk 1.6
之后
jdk 1.6
之后对 synchronized
进行了一系列 ‘锁升级’ 的优化。其中 synchronized
的很多底层实现就是采用了 CAS
操作
- 对于线程冲突较少时使用的偏向锁和轻量级锁也没有了线程状态切换,可以获得和
CAS
类似的性能 - 线程冲突严重的情况下,
synchronized
的性能仍然远高于CAS
。这使得synchronized
没有那么 ‘重’ 了,实际的synchronized
的性能已经非常好了,所以目前volatile
的应用范围已经不大了
synchronized
的锁升级的优化:https://blog.csdn.net/weixin_43767015/article/details/105544786