站在巨人肩上操作CAS(一):CAS的原理
CAS的定义
- JDK 1.5的时候,Java支持了Atomic类,这些类的操作都属于原子操作;
- 帮助最大限度地减少在多线程场景中对于一些基本操作的复杂性;
- 而Atomic类的实现都依赖与 CAS(compare and swap) 算法
乐观锁和悲观锁
悲观锁
常见的悲观锁
- 独占锁:synchronized
悲观锁的实现
- 在多线程的场景下,当线程数量大于CPU数的时候,就需要进行分片处理,即把CPU的时间片,分配给不同的线程轮流执行,也就是我们常说的多线程的并发执行(间隔性执行);
- 在某个线程执行完时间片之后,就会进行CPU切换;切换过程:清空寄存器和缓存数据,重新加载新的线程所需要的数据(线程私有的:JVM运行时所需的数据:程序计数器、虚拟机栈和本地方法栈);
- 此时之前的线程就被挂起,加入到阻塞队列中,在一定的时间和条件下,通过调用 notify() 或 notifyAll() 唤醒,进入就绪状态(runnable),等待CPU调度。
总结:
- 总是假设最坏的情况,并且只有在确保其他线程不会造成干扰的情况下才会执行;
- 导致其他所有需要锁的线程挂起,等待持有锁的线程释放锁,再次进行锁的竞争(公平锁 / 非公平锁)。
缺点
- 线程会被频繁的挂起,唤醒之后需要重新抢占锁
- 场景:如果一个线程需要某个资源,但是这个资源的 占用时间很短 ,当线程第一次抢占这个资源时,可能这个资源被占用,如果此时挂起这个线程,但是可能立刻就发现资源已被刚才的线程的释放,然后又需要花费很长的时间重新抢占锁,时间代价就会非常的高。
乐观锁
思路
- 每次 不加锁 而是假设 没有冲突 而去完成某种 操作,如果因为冲突 失败就重试 ,直到成功为止;
- 当某个线程不让出CPU时,当前线程就一直 while 循环去尝试获取锁,如果失败就重试,直到成功为止;
- 适用场景:当数据争用不严重时,乐观锁效果更好。
乐观锁应用
CAS
CAS(Compare and Swap)算法
非阻塞算法
核心参数
- 主内存中存放的 V 值:线程共享(对应JVM的共享内存区:堆和方法区)
- 线程上次从内存中读取的 V值 A:线程私有,存放在线程的栈帧中
- 需要写入内存中并改写V值的B:线程对A值进行操作之后的值,想要存入主内存V中
- V值在主内存中保存,所有线程在对V值操作时,需要先从主存中读取V值到线程到工作内存A中;
- 然后对A值操作之后,把结果保存在B中,最后再把B值赋给主内存的V;
- 多线程共用V值都是这样的过程;
- 核心点:将B值赋给V之前,先比较A值和V值是否相等(判断,当前线程在计算过程中,是否有其他线程已经修改了V值),不相等表示此时的V值已经被其他线程改变,需要重新执行上面的过程;相等就将B值赋给V。
如果没有CAS这样的机制,那在多线程的场景下,两个线程同时对共享数据进行修改,很容易就出错,导致某一个线程的修改被忽略。
核心代码
//原子操作
if (A == V) {
V = B;
return B;
} else {
return V;
}
变量 V
如何保证线程每次操作V时,都去主内存中获取 V 值?
- 自然是在CAS的实现代码中,变量 V是用 volatile原语 修饰的:保证其可见性;
如何保证在进行 V 和 A 值比较相等之后,而在 B值赋给 V 之前,V值不会被其他线程所修改?
也就是说如何保证比较和替换这两个步骤的原子性?
看一下CAS原理:
CAS原理
- 底层的实现其实时通过调用 JNI(Java Native Interface 本地调用)的代码实现的;(其实 JNI 就是为了支持 Java 去调用其他语言)
具体看一下:AtomicInteger类的 compareAndSet() 方法:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
调用的时 sun.misc 包下 Unsafe 类的 compareAndSwapInt()方法:(本地方法)
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
本地方法会调用 JDK中的几个cpp文件(这是在widowsX86中的,不同操作系统调用的文件不同,文件中的实现方式也不同);
具体的C++代码我就不贴了,知道上层的实现思路就行;
底层大致思路:通过对 cmpxchg 指令添加 lock 前缀(windowsX86中的实现),确保对主内存中 V 值的 读-改-写操作原子执行;
不同的操作系统有不同的实现方式,但大致的思路是类似的,目的也都是保证比较赋值操作是原子操作。
更详细的底层实现,以及相关的一些关于CPU锁的内容,可以去看一下《Java 并发编程艺术》。
最终的实现作用:
- 确保对主内存中 V 值的 读-改-写操作原子执行;
- 禁止该指令与之前和之后的读(比较)和写(赋值)指令重排序。
CAS缺点
ABA问题
问题描述
CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
解决方案
版本号
- ABA问题的解决思路就是使用版本号;
- 在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A
AtomicStampedReference
compareAndSet() 方法源码:
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
- 跟其他的Atomic类不同的是, 这个多了一个引用的比较;
- 这个会先检查 当前引用是否等于预期引用 ,并且当前标志是否等于预期标志,如果全部相等,则以 原子方式 将该 引用 和该标志的 值 设置为给定的更新值;
- 所以如果有其他线程修改了, 那么引用就会改变, 当前线程就执行失败重试操作;
循环时间长
问题描述
因为CAS机制是: 一直进行失败重试, 直到成功为止, 那么自旋CAS 如果长时间不成功, 就会给 CPU 带来非常大的执行开销;
解决方案
- 在 JVM 中去支持处理器提供的 pause 指令 那么效率会有一定的提升;
- pause指令的作用:
- 延迟流水线执行指令(de-pipeline), 使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作
问题描述
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性了
解决方案
- 加锁
- 把多个共享变量合并成一个共享变量或者把多个变量放在一个对象中, 用 AtomicReference 类来进行CAS操作
总结
-
Java中的CAS操作: 结合 volatile 实现 (volatile 读和 volatile 写)
-
而Java中的 CAS 最终调用的是 处理器提供的高效机器级别的原子指令, 这些原子指令 保证 以原子的方式 对主内存中的值 执行 读-写-改等一系列操作;
-
所以最本质的就是支持原子性 读-写-改指令的计算机
Java 中的 JUC
- volatile 变量的 读/写 + CAS 实现线程之间的通信
- 这其实就是 concurrent 包可以实现的基础, JUC包下源码实现的通用模式:
- 声明共享变量为 volatile ;
- 使用CAS的 原子条件 更新来实现线程之间的同步;
- 配合以 volatile 的 读/写 和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。
- AQS,非阻塞数据结构 和 原子变量类(java.util.concurrent.atomic包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的.
JUC包实现的示意图:
image