Java并发编程之CAS原理分析
Java并发编程之CAS原理分析
背景
在高并发场景下,多线程访问共享资源经常会引发并发安全问题,如竞态条件(Race Condition)。JDK5之前通常使用 synchronized 或 Lock 实现同步,但这些互斥锁较为重量级,会带来性能损耗。
对于某些场景,可以利用 JUC 提供的 CAS 机制实现无锁方案,类似乐观锁的非阻塞同步方式,提升效率并保证线程安全。
一、如何解决并发安全?
1. synchronized 加锁
最粗暴的方式就是使用 synchronized 关键字了,但它是一种独占形式的锁,属于悲观锁机制,性能会大打折扣。
2. volatile
volatile 貌似也是一个不错的选择,但 volatile 只能保持变量的可见性,并不保证变量的原子性操作。详细请参考文章《volatile关键字》
3. CAS
相比之下,CAS(Compare And Swap)是一种乐观锁机制。它不需要加锁,是一种无锁的原子操作(读和写两者同时具有原子性)。
二、悲观锁和乐观锁
在学习CAS之前,我们先了解一下什么是悲观锁和乐观锁。
1. 悲观锁
对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
2. 乐观锁
对于乐观锁来说,它总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。
3. 使用场景
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;
悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
三、什么是CAS
CAS(Compare-And-Swap 或 Compare-And-Set)是一种常见的无锁并发机制,广泛应用于多线程编程中,用于实现原子操作。
1. CAS 的工作原理
如下图:
CAS 操作包含三个基本的参数:
1) 内存位置(V):需要操作的共享变量的内存地址。
2) 期望值(A):预期该内存位置上当前存储的值。
3) 新值(B):准备更新到内存位置的新值。
工作流程如下:
- CAS 操作会比较内存位置(V)中的值是否等于期望值(A)。
- 如果相等,则将内存位置的值更新为新值(B),并返回 true,表示更新成功。
- 如果不相等,则不做任何修改,并返回 false,表示更新失败。
说明:
1) 如果内存中某个值是否和预期的值不相同,则说明其他线程已经修改了共享变量,需要重新读取当前值并重试操作。
2) CAS操作是原子的,即在同一时刻只有一个线程能够成功执行CAS操作。
2. 关键思想
1)乐观锁
CAS 的核心思想是乐观锁,即假设在进行更新时没有其他线程修改该值,直接尝试更新。如果检测到有冲突,则重新尝试,直到成功。
2)无锁操作
与传统的锁机制不同,CAS 不会阻塞线程,而是不断尝试更新共享资源,从而提高并发性能。
3. Java中CAS的应用
Java中大量使用了CAS机制来实现多线程下数据更新的原子化操作,比如AtomicInteger、ConcurrentHashMap当中都有CAS的应用。java.util.concurrent 包很多功能都是建立在 CAS 之上,如 ReentrantLock(可重入锁) 内部的 AQS,各种原子类,其底层都用 CAS来实现原子操作。
1)AtomicInteger 类
在 Java 中,AtomicInteger 使用 CAS 来保证线程安全。以下是一个简单的 CAS 示例:
1 public class CasTest { 2 public static void main(String[] args) { 3 AtomicInteger atomicInteger = new AtomicInteger(100); 4 5 // 尝试将值从100改为200 6 boolean success = atomicInteger.compareAndSet(100, 200); 7 // 输出: true 8 System.out.println("CAS 操作成功: " + success); 9 10 // 尝试将值从100改为300,失败 11 success = atomicInteger.compareAndSet(100, 300); 12 13 // 输出: false 14 System.out.println("CAS 操作成功: " + success); 15 } 16 }
2) 源码分析
Java中并没有直接实现CAS,CAS相关的实现是借助C/C++调用CPU指令来实现的,效率很高,但Java代码需通过JNI才能调用。
Unsafe 类提供的 CAS(Compare-And-Swap)方法(如 compareAndSwapXXX)的底层实现是基于 CPU 指令 cmpxchg(或类似的原子操作指令)。这使得 CAS 操作能够在多线程环境下实现无锁的并发控制,从而保证线程安全。
AutomicInteger的部分源码如下:
1 public class AtomicInteger extends Number implements java.io.Serializable { 2 private static final long serialVersionUID = 6214790243416807050L; 3 4 5 private static final Unsafe unsafe = Unsafe.getUnsafe(); 6 7 // valueOffset 是相对于 AtomicInteger 实例的内存地址,指向 value 字段的内存位置。 8 private static final long valueOffset; 9 10 // 用来存储 AtomicInteger 对象的当前值。在 AtomicInteger 类中,所有的操作都是围绕这个 value 变量进行的 11 private volatile int value; 12 13 14 static { 15 try { 16 // value 字段是存储整数的变量 17 valueOffset = unsafe.objectFieldOffset 18 (AtomicInteger.class.getDeclaredField("value")); 19 } catch (Exception ex) { throw new Error(ex); } 20 } 21 22 public final boolean compareAndSet(int expect, int update) { 23 return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 24 } 25 26 //... 27 28 29 }
Unsafe#compareAndSwapInt():
1 public final native boolean compareAndSwapInt(Object o, long offset, 2 int expected, 3 int x);
compareAndSwapInt 使用 CAS 来实现非阻塞的线程安全更新。它的操作流程如下:
- 读取对象的字段:首先,它会读取对象 o 中偏移量为 offset 的整数字段的当前值。
- 比较期望值:将这个当前值与 expected(期望值)进行比较。
1> 如果当前值与期望值相同,则执行步骤 3。
2> 如果不相同,则直接返回 false,表示 CAS 失败。
- 更新值:如果当前值等于 expected,则将该字段的值更新为 x(新值),并返回 true,表示 CAS 操作成功。
说明:关于这个字段的访问,字段的访问:通过 o 表示的对象,以及 offset 这个偏移量,程序能够定位到 o 对象中的某个整数字段。偏移量 offset 告诉 JVM,字段的内存地址相对于对象的起始地址有多远。
五、CAS的优点
1. 高性能
避免了加锁,减少了线程上下文切换的开销,适合高并发场景。
2. 非阻塞
线程不会被阻塞,只有在冲突时重试。
六、CAS的缺点
1. 自旋开销
如果竞争非常激烈,CAS 操作需要反复重试,可能会造成CPU极大的开销,从而影响性能。
解决方法:限制自旋次数,防止进入死循环
最后来说下关于自旋锁的实现,实现自旋锁可以基于CAS实现,先定义lockValue对象默认值1,1代表锁资源空闲,0代表锁资源被占用,代码如下:
1 public class SpinLock { 2 3 //lockValue 默认值1 4 private AtomicInteger lockValue = new AtomicInteger(1); 5 6 //自旋获取锁 7 public void lock(){ 8 9 // 循环检测尝试获取锁 10 while (!tryLock()){ 11 // 空转 12 } 13 14 } 15 16 //获取锁 17 public boolean tryLock(){ 18 // 期望值1,更新值0,更新成功返回true,更新失败返回false 19 return lockValue.compareAndSet(1,0); 20 } 21 22 //释放锁 23 public void unLock(){ 24 if(!lockValue.compareAndSet(1,0)){ 25 throw new RuntimeException("释放锁失败"); 26 } 27 } 28 29 }
2. ABA问题
1) 什么是ABA问题?
如果某个值从 A 改成 B,又改回 A,CAS 可能无法检测到这种变化,从而导致问题。
2)ABA问题产生的过程
初始值为 A,有两个线程(线程1 和 线程2)同时对这个值进行 CAS 操作:
线程1:期望值为 A,欲将值更新为 B。
线程2:期望值为 A,欲将值更新为 B。
- 线程1 抢先执行,获得了 CPU 时间片。线程2 由于某些原因阻塞了。线程1读取当前值,发现与期望值 A 一致,然后将值更新为 B。
- 此时,线程3 出现,期望值为 B,欲将值更新为 A。线程3 读取当前值 B,发现相等,于是将值更新回 A。
- 这时,线程2 恢复执行。它读取当前值,发现依然是 A(与它最初期望的值一致),于是将值更新为 B。
3)分析
线程2 并不知道在它阻塞期间,值已经从 A 变为 B,再变回 A。虽然线程2 完成了操作,但它误以为值从未改变过,而实际上,值已经经历了变化(A -> B -> A)。这种情况下,虽然 CAS 操作成功了,但可能引发潜在的问题,因为实际的值变化过程被忽略了。
4) 实际业务中ABA问题产生的影响举例
小明在提款机取款,初始值是100元,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):期望值是100,欲将值更新为50。
线程2(提款机):期望值是100,欲将值更新为50。
线程1成功执行,线程2某种原因阻塞了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2恢复执行,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。
分析:
- 这里执行不会出错的原因是因为并发读的存在,导致数据被改掉后持有旧数据去更新。
- CAS只关心当前值和预期值是否相等,但不关心该值在中间是否经历过其他的变化。
- 在ABA问题中,线程可能会在某个时间点读取到了一个旧的值,然后在稍后的时间点才进行CAS操作。因此,尽管在读取值时并没有发生并发问题,但由于在读取值和进行CAS操作之间发生了其他线程的修改,导致了ABA问题的发生。
5)如何解决ABA问题?
ABA 问题可以通过引入 版本号 或 时间戳 来解决。例如,Java 提供了 AtomicStampedReference,通过为每个值附加一个版本号来检测是否发生了 ABA 问题。比如:每次改变时版本号加1,即A —> B —> A,变成1A —> 2B —> 3A。
参考链接:
https://segmentfault.com/a/1190000040042588
https://juejin.cn/post/6844903796129136647