Java并发之CAS详解

一、前言 

首先我们要了解Java内存模型(Java Memory Model)。JMM就是一套规范,描述了Java线程对变量的访问规则。

  在JVM中有一个main memory,而每个线程都有自己的working memory,一个线程对一个共享variable进行操作的时候,会先在自己的working memory里面建立一个copy,操作完成之后再写入main memory,如果有多个线程同时操作同一个variable,就可能会出现不可预知的结果,所以线程安全就是为了避免这种情况的发生。
  volatile是不错的机制,volatile关键字作用:1.volatile可以保证变量的可见性。2.保证有序性。(防止指令重排)。【当一个线程修改了共享变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会强制从主内存中拉取最新的变量值。】但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。在java中,确保线程安全的方法有两种:一种是使用内置锁(synchronized),一种是使用原子类(java.util.concurrent包下的),对于原子类,它所用的机制就是CAS机制


二、什么是CAS?

  CAS:Compare and Swap,即比较再交换。

  jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。

  CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

  通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

  现代cpu提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而compareAndSet()就是用这些代替了锁定,compareAndeSet()是调用native方法来完成cpu指令的操作。 

来看看AutomicInteger的源码

private volatile int value; //毫无疑问,没有锁的机制下,必须借助volatile保证线程间的数据可见性
public final int get(){
return value;
}
// 来看看++i是怎么实现的:
public final int increamentAndGet(){
for (;;){ // 无限循环来获取数据
int current = get();
int next = current + 1 ;
if (compareAndSet(current,next))
return next;
}
}

  由此可见,AtomicInteger.incrementAndGet的实现用了乐观锁技术,调用了类sun.misc.Unsafe库里面的 CAS算法,用CPU指令来实现无锁自增。所以,AtomicInteger.incrementAndGet的自增比用synchronized的锁效率倍增。


三、CAS存在的问题

1. ABA 问题

  谈到 CAS,基本上都要谈一下 CAS 的 ABA 问题。CAS 由三个步骤组成,分别是“读取-比较-写回”。考虑这样一种情况,线程1和线程2同时执行 CAS 逻辑,两个线程的执行顺序如下:

  • 时刻1:线程1执行读取操作,获取原值 A,然后线程被切换走

  • 时刻2:线程2执行完成 CAS 操作将原值由 A 修改为 B

  • 时刻3:线程2再次执行 CAS 操作,并将原值由 B 修改为 A

  • 时刻4:线程1恢复运行,将比较值(compareValue)与原值(oldValue)进行比较,发现两个值相等。 

然后用新值(newValue)写入内存中,完成 CAS 操作

  如上流程,线程1并不知道原值已经被修改过了,在它看来并没什么变化,所以它会继续往下执行流程。对于 ABA 问题,通常的处理措施是对每一次 CAS 操作设置版本号。

ABA问题的解决思路其实也很简单,就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A了。

  从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

 2.循环时间长开销大

  自旋CAS(不成功,就一直循环执行,直到成功) 如果长时间不成功,会给 CPU 带来非常大的执行开销。

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

  当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。


四、CAS 与 Synchronized 的使用情景   

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态与内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。

  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

posted @   danielzzz  阅读(1666)  评论(0编辑  收藏  举报
(评论功能已被禁用)
编辑推荐:
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示