Java并发编程之CAS原理分析

Java并发编程之CAS原理分析

   背景 

   在高并发的业务场景下,我们经常需要处理多线程对共享资源的访问和修改。这个时候就可能会导致并发安全问题比如竟态条件(Race Condition)等。因此,线程安全问题是必须考虑的,在JDK5之前,可以通过synchronized或Lock来保证同步,从而达到线程安全的目的。但synchronized或Lock方案属于互斥锁的方案,比较重量级,加锁、释放锁都会引起性能损耗问题。

   而在某些场景下,我们是可以通过JUC提供的CAS机制实现无锁的解决方案,或者说是它基于类似于乐观锁的方案,来达到非阻塞同步的方式保证线程安全。

   一. 如何解决并发安全?

   1.  synchronized 加锁

   最粗暴的方式就是使用 synchronized 关键字了,但它是一种独占形式的锁,属于悲观锁机制,性能会大打折扣。

   2. volatile

   volatile 貌似也是一个不错的选择,但 volatile 只能保持变量的可见性,并不保证变量的原子性操作。

   3. CAS

   相比之下,CAS(Compare And Swap)是一种乐观锁机制。它不需要加锁,是一种无锁的原子操作(读和写两者同时具有原子性)。

   二. 悲观锁和乐观锁

   这一篇文章主要是介绍CAS的,上面提到它是一种乐观锁的实现机制,有乐观锁,那自然就有悲观锁。下面来简单介绍一下。

   1. 悲观锁

   对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

   2. 乐观锁

   对于乐观锁来说,它总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

   由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。

   3. 使用场景

   乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;

   悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

   三、什么是CAS
   CAS 是什么,它的英文全称是 Compare-And-Swap,中文叫做“比较并交换”,它是一种思想、一种算法。

   用CAS更新失败的线程并不会阻塞,会快速失败并返回一个失败的状态,允许你再次尝试。

   本质上来讲CAS是一种无锁的解决方案,也是一种基于乐观锁的操作,可以保证在多线程并发中保障共享资源的原子性操作,相对于synchronized或Lock来说,是一种轻量级的实现方案。

  Java中大量使用了CAS机制来实现多线程下数据更新的原子化操作,比如AtomicInteger、CurrentHashMap当中都有CAS的应用。但Java中并没有直接实现CAS,CAS相关的实现是借助C/C++调用CPU指令来实现的,效率很高,但Java代码需通过JNI才能调用。比如,Unsafe类提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。这里举例几个:

1 public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
2 
3 public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
4 
5 public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

 

   分析:

   我们拿public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);进行分析,这个方法是比较内存中的一个值(整型)和我们的期望值(var4)是否一样,如果一样则将内存中的这个值更新为var5,参数中的var1是值所在的对象,var2是值在对象(var1)中的内存偏移量,参数var1和参数var2是为了定位出值所在内存的地址。

 

 

   四、CAS的原理
   CAS 的思想很简单:三个参数,一个当前内存值 V、旧的预期值 A、即将更新的值 B,当且仅当预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false。

  说明:如果内存中某个值是否和预期的值不相同,则说明其他线程已经修改了共享变量,需要重新读取当前值并重试操作。

   CAS操作是原子的,即在同一时刻只有一个线程能够成功执行CAS操作。

    CAS操作是一种无锁的操作,避免了传统锁机制中的竞争和阻塞,从而提高了并发性能。

    java.util.concurrent 包很多功能都是建立在 CAS 之上,如 ReenterLock(可重入锁) 内部的 AQS,各种原子类,其底层都用 CAS来实现原子操作。

    五、CAS的缺点

    CAS高效地实现了原子性操作,但在以下三方面还存在着一些缺点:

    1. 只能保证一个共享变量原子操作

    CAS只能针对一个共享变量使用,如果多个共享变量就只能使用锁了,但如果存在多个共享变量,或一整个代码块的逻辑需要保证线程安全,CAS就无法保证原子性操作了,此时就需要考虑采用加锁方式(悲观锁)保证原子性,或者有一个取巧的办法,把多个共享变量合并成一个共享变量进行CAS操作(比如读写锁中state的高低位)。

    2. 循环时间长,开销大

    如果CAS操作失败,就需要循环进行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 }

   3. ABA问题

   在多线程场景下CAS会出现 ABA 问题,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下:

   1) 线程1,期望值为A,欲更新的值为B

   2)线程2,期望值为A,欲更新的值为B

   线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。

   举个例子:

   小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
   线程1(提款机):获取当前值100,期望更新为50,
   线程2(提款机):获取当前值100,期望更新为50,
   线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
   线程3(默认):获取当前值50,期望更新为100,
   这时候线程3成功执行,余额变为100,
   线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
   此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。

   分析:

   1)这里执行不会出错的原因是因为并发读的存在,导致数据被改掉后持有旧数据去更新。

   2)在ABA问题中,线程可能会在某个时间点读取到了一个旧的值,然后在稍后的时间点才进行CAS操作。因此,尽管在读取值时并没有发生并发问题,但由于在读取值和进行CAS操作之间发生了其他线程的修改,导致了ABA问题的发生。   

   3)如何解决ABA问题?

  要解决A B A问题也非常简单,只要追加版本号即可,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A,在Java中提供了AtomicStampedRdference可以实现这个方案。

 

  参考链接:

  https://segmentfault.com/a/1190000040042588

  https://juejin.cn/post/6844903796129136647

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