您当前的位置:首页 > 计算机 > 编程开发 > Java

CAS底层原理以及使用场景详解,包含重点知识(ABA、原子性问题、与synchronized区别等分析)

时间:05-13来源:作者:点击数:

在讲解CAS前,我们先来看两个简单例子。

第一个例子,运用synchronized实现多线程同步

public class cas_test {
    public static void main(String[] args) throws Exception {
        A a = new A();

        long startTime = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000000; i++) {
                a.increase();
            }
        });
        t1.start();

        for (int i = 0; i < 10000000; i++) {
            a.increase();
        }
        t1.join();

        long endTime = System.currentTimeMillis();
        System.out.println(String.format("%sms", endTime - startTime));

        System.out.println(a.getNum());
    }
}
class A {
    int num = 0;
    public int getNum() {
        return num;
    }
    public synchronized void increase() {
        num++;
    }
}

代码很简单,有两个线程,一个是main线程,一个t1线程。它们分别调用类A中的同步方法increase对成员变量num操作。结果是20000000,耗时851ms

851ms
20000000

第二个例子,我们用原子类操作AtomicInteger 展示

所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。如AtomicBoolean,AtomicUInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。

class A {
    AtomicInteger atomicInteger = new AtomicInteger();
    int num = 0;
    public int getNum() {
        return atomicInteger.get(); //get()得到当前值
    }
    public synchronized void increase() {
        atomicInteger.incrementAndGet(); //incrementAndGet以原子方式将当前值加一。
    }
}

在运行,结果是20000000,耗时733ms

733ms
20000000

从中我们可以看出在这里使用原子性操作比synchronized要快一些,但并不意味着原子性操作就比synchronized要好,因为它们有不同的适用场景,可为什么在这里原子性效率比synchronized高呢?让我们来分析下

我们知道synchronized就是一种悲观锁,悲观的认为每次去拿数据的时候别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,也被称为“独占锁”。

而另一个更加有效的锁就是乐观锁,乐观的认为每次去拿数据的时候别人不会修改,所以不会上锁,我们也习惯称为“无锁”。但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,没有的话就更改数据,否则不断自旋等待机会。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

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

一、什么是CAS?

CAS是英文单词Compare and Swap的缩写,翻译过来就是比较并替换。它是一条CPU并发原语,用于判断内存中某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

在前面的介绍中我们知道AtomicInteger这种原子性操作底层实现就是利用了“CAS”机制

先进入incrementAndGet()方法源码中看看

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
-------------------------------------------------------------------------
//Unsafe类中的compareAndSwapInt,是一个本地方法
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

通过查看源码就知道原子性操作底层就是CAS(compareAndSwap)实现的。但这里理解困难的是,难点就是compareAndSwapInt(Object var1, long var2, int var4, int var5);这四个参数如何理解,如果有人读过ConcurrentHashMap源码的话也会发现其底层实现也用到了CAS机制。

例如在初始化数组的时候,就要通过CAS来操作。它的实现过程多线程的时候,当有线程运行到红线部分时,会判断SIZECTL是否等于sc,是则将SIZECTL值设为-1。成功返回true,失败返回false。所以就算有线程同时运行到红线时,有一个线程改变了SizeCtl内存值,另外一个线程就会返回false,保证了ConcurrentHashMap在初始化数组大小时的线程安全。

在这里插入图片描述

通过这些理解我们就大概能知道这四个参数的含义,CAS – compareAndSwapInt(Object obj, long offset, int expect, int update)这四个参数含义分别是:

  • obj:将要修改的值的对象
  • offset:指定字段在内存中的值
  • expect:期望内存中的值,也就是想要修改的值(一般是旧值)
  • update:如果offset==expect,就更新内存中offset位置的值为update(一般是新值)

到这里我们就知道了,CAS是一种无锁机制,如果offset==expect(说明暂时没有任何线程修改内存中的offset处的值),将offset修改为update。如果offset !=expect(说明内存中的值已经被其他线程修改了),返回false,失败的线程就会进行自旋(CAS也是一种自选锁)

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

二、CAS底层原理/原子性问题

在Unsafe类中定义的compareAndSwapInt,它是由native关键字修饰的,说明这是一个本地方法

native关键字说明其修饰的方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。Java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI接口调用其他语言来实现对底层的访问。

让我们看看hotspot源码中的compareAndSwapInt实现,对应unsafe.cpp文件里

在这里插入图片描述

(这里就不具体讲解C++代码)重点关注Atomic::cmpxchg()这个方法代码的实现,也就是compareAndSwapInt实现的关键方法。

“Atomic::cmpxchg” 方法在linux_x86的实现如下。

在这里插入图片描述

mp是“os::is_MP()”的返回结果,“os::is_MP()”是一个内联函数,用来判断当前系统是否为多处理器。

如果当前系统是多处理器,该函数返回1。否则,返回0。

再看LOCK_IF_MP()=方法

在这里插入图片描述

LOCK_IF_MP(mp)会根据mp的值来决定是否为cmpxchg指令添加lock前缀。

  1. 如果通过mp判断当前系统是多处理器(即mp值为1),则为cmpxchg指令添加lock前缀。
  2. 否则,不加lock前缀。

这是一种优化手段,认为单处理器的环境没有必要添加lock前缀,只有在多核情况下才会添加lock前缀,因为lock会导致性能下降。cmpxchg是汇编指令,作用是比较并交换操作数。

也就是说 这个方法判断mp是不是一个多核cpu,是的就把lock指令返回。mp在Atomic::cmpxchg中通过bool mp = os::is_MP()可以得出来。

在这里插入图片描述

lock指令通过锁缓存行/锁总线的方式,保证在多核CPU环境中cmpxchgq指令的原子性

1、通过总线锁

当一个处理器想要更新某个变量的值时,向总线发出LOCK#信号,此时其他处理器的对该变量的操作请求将被阻塞,发出锁定信号的处理器将独占共享内存,于是更新就是原子性的了。

2、通过缓存锁定(利用CPU缓存一致性)

当某个处理器想要更新主存中的变量的值时,如果该变量在CPU的缓存行中,执行写回主存操作时,CPU通过缓存一致性协议,通知其它处理器使其它处理器上的缓存失效并重新从主存读取,以此来保证原子性。

总结 CAS原子性问题: lock cmpxchgq 靠这两个关键指令

三、CAS优缺点

优点:

  1. 由于CAS是非阻塞的,可避免死锁,线程间的互相影响非常小。
  2. 没有锁竞争带来的系统开销,也没有线程间频繁调度的开销。

缺点(重点):

  1. 自旋循环时间长开销大
    如果某个线程通过CAS方式操作某个变量不成功,长时间自旋,则会对CPU带来较大开销。
    解决方式:限制自旋次数
  2. ABA问题
    假如线程T1取得在内存中offset处的值是A,那在赋值的过程中取到内存中的值还是A,此时我们能确定内存中的值A并没有被其他线程修改过吗?当然不行,我给你举一个例子
    在这里插入图片描述

线程T1和T2一开始取得内存中的旧值都是A,但是线程T2运行的快,将A->B->A,所以T1进行CAS的时候依旧可以通过,误认为内存中的值重来没有被修改过,这实际上是不对的,这就是“ABA”问题。

解决方式:Java1.5开始,JDK的Atomic包里提供了一个类AtomicStampedRefernce来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查标志stamped是否为预期标志,如果全部一致,则继续。

3.只可用来对单个变量进行同步。

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。

解决方式:从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

四、CAS和synchronized应用场景

CAS与Synchronized的使用情景:

1、对于资源竞争较少(线程冲突较轻)的情况, 乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发。而且使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。

2、对于资源竞争严重(线程冲突严重)的情况,悲观锁更有优势,因为乐观锁在执行更新时,频繁失败(自旋CAS),需要不断重试,浪费CPU资源。也就是说CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

补充:

synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门