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

posted @ 2024-03-09 18:17  欢乐豆123  阅读(48)  评论(0编辑  收藏  举报