深入理解CAS (自旋锁)

Posted 流楚丶格念

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解CAS (自旋锁)相关的知识,希望对你有一定的参考价值。

文章目录

0. 导言

背景:

我们都知道, 在java语⾔之前, 并发就已经⼴泛存在并在服务器领域得到了⼤量的应⽤ 。所以硬件⼚商⽼早就在芯⽚中加⼊了⼤量直至并发操作的原语, 从⽽在硬件层⾯提升效率。在intel的CPU中, 使⽤cmpxchg指令。

在Java发展初期,java语⾔是不能够利⽤硬件提供的这些便利来提升系统的性能的。⽽随着java不断的发展,Java本地⽅法(JNI)的出现,使得java程序越过JVM直接调⽤本地⽅法提供了⼀种便捷的⽅式, 因⽽java在并发的⼿段上也多了起来。⽽在Doug Lea提供的cucurenct包中, CAS理论是它实现整个java包的基⽯。

下面一起来探究下CAS:

说起自旋锁就要从多线程下的锁机制说起,由于在多处理器系统环境中有些资源因为其有限性,有时需要互斥访问(mutual exclusion),这时会引入锁的机制,只有获取了锁的进程才能获取资源访问。即每次只能有且只有一个进程能获取锁,才能进入自己的临界区,同一时间不能两个或两个以上进程进入临界区,当退出临界区时释放锁。

设计互斥算法时总是会面临一种情况,即没有获得锁的进程怎么办?

通常有2种处理方式:

一种是没有获得锁的调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,这就是下面要说:自旋锁。他不用将线城阻塞起来(NON-BLOCKING),他是乐观锁。

另一种是没有获得锁的进程就阻塞(BLOCKING)自己,继续执行线程上的其他任务,这就是 ——互斥锁(包括内置锁Synchronized还有ReentrantLock等等),他是悲观锁。

1. 什么是CAS

CAS,compare and swap的缩写,中文翻译为比较并交换。

CAS 操作中包含三个操作数 —— 内存位置 (V) 、预期原值 (E) 和新值(N)。

这三个字母缩写的含义: Variable:变量;Expect:预期;New:新

如果内存位置的值与预期原值相匹配, 那么处理器会⾃动将 该位置值更新为新值 。否则,处理器不做任何操作。⽆论哪种情况,它都会在 CAS 指令之前返回该位置的值 (在 CAS 的⼀些特殊情况 下将仅返回 CAS 是否成功,而不提取当前值) 。

CAS 用同步的方式是从地址 V 读取值 E, 执⾏多步计算来获得新值 N, 然后使⽤ CAS 将 V 的值从 E 改为 N。如果 V 处的值 尚未同时更改, 则 CAS 操作成功。

CAS 允许算法执⾏读-修改-写操作,而无需害怕其他线程同时修改变量,因为如果其他线程修改变量, 那么 CAS 会检测它(并失败) , 算法可以对该操作重新计算。

执⾏流程如下图所示:

过程核心:CAS 会判断位置 V 应该包含值E; 如果包含该值, 则将 N 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

2. 保证原子操作

任何技术的出现都是为了解决某些特定的问题, CAS 要解决的问题就是保证原子操作

原子操作是什么?
原子就是最小不可拆分的,原子操作就是最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,知道操作完成。

在多线程环境下,原子操作是保证线程安全的重要手段。

举个栗子来说明一下:

例如:假设有两个线程在工作,都想对某个值做修改,就拿自增操作来说吧,要对一个整数 i 进行自增操作,需要基本的三个步骤:

1、读取 i 的当前值;
2、对 i 值进行加 1 操作;
3、将 i 值写回内存;

假设两个进程都读取了 i 的当前值,假设是 0,这时候 A 线程对 i 加 1 了,B 线程也 加 1,最后 i 的是 1 ,而不是 2。这就是因为自增操作不是原子操作,分成的这三个步骤可以被干扰。如下面这个例子,10个线程,每个线程都执行 10000 次 i++ 操作,我们期望的值是 100,000,但是很遗憾,结果总是小于 100,000 的。

代码如下

class Plus implements Runnable
    static int count = 0;
    public static void add()
        count++;
    

    @Override
    public void run() 
        for(int k = 0;k<10000;k++)
            add();
        
    


class Solution 
    public static void main(String[] args) throws InterruptedException
        Thread[] threads = new Thread[10];
        for(int i = 0;i<10;i++)
            threads[i] = new Thread(new Plus());
            threads[i].start();
        
        for(int i = 0;i<10;i++)
            threads[i].join();
        
        System.out.println(Plus.count);
    


既然这样,那怎么办。没错,也许你已经想到了,可以加锁或者利用 synchronized 实现,例如,将 add() 方法修改为如下这样:

运行结果显而易见:

既然这样,那怎么办。没错,也许你已经想到了,可以加锁或者利用 synchronized 同步锁实现,例如,将 add() 方法修改为如下这样:

public static synchronized void add()
    count++;

运行结果就没问题了
或者,加锁操作,例如下面使用 SynchronizedLock 同步锁,ReentrantLock (可重入锁)实现。

同步锁:

private static Object lock = new Object();
public static void add()
    synchronized (lock)
        count++;
    

可重入锁:

private static Lock lock = new ReentrantLock();
public static void add()
    lock.lock();
    count++;
    lock.unlock();

结果也都没问题:

关于原子操作的其他方法可以参考我都另一篇博文:https://blog.csdn.net/weixin_45525272/article/details/125818885

2.1 CAS 实现自旋锁

既然用锁或 synchronized 关键字可以实现原子操作,那么为什么还要用 CAS 呢,因为加锁或使用 synchronized 关键字带来的性能损耗较大,而用 CAS 可以实现乐观锁,它实际上是直接利用了 CPU 层面的指令,所以性能很高。

上面也说了,CAS 是实现自旋锁的基础,CAS 利用 CPU 指令保证了操作的原子性,以达到锁的效果。

至于自旋呢,看字面意思也很明白,自己旋转,翻译成人话就是循环,一般是用一个无限循环实现

这样一来,一个无限循环中,执行一个 CAS 操作,当操作成功,返回 true 时,循环结束;当返回 false 时,接着执行循环,继续尝试 CAS 操作,直到返回 true。

扩展:其实 JDK 中有好多地方用到了 CAS ,尤其是java.util.concurrent包下,比如 CountDownLatch、Semaphore、ReentrantLock 中,再比如 java.util.concurrent.atomic 包下,相信大家都用到过 Atomic* ,比如 AtomicBoolean、AtomicInteger 等。

例如:在AtomicInteger 我们可以看到CAS 应用的蛛丝马迹

2.2 AtomicBoolean 中的CAS

这里拿 AtomicBoolean 来举个例子,因为它简单,如果是AtomicInteger 再深入翻jdk1.8找不到了,后面直接调用起了本地方法:

所以我们也是只能看AtomicBoolean :

这是 AtomicBoolean 的部分代码,我们看到这里面又几个关键方法和属性:

public class AtomicBoolean implements java.io.Serializable 
    private static final long serialVersionUID = 4654671469794556979L;
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static 
        try 
            valueOffset = unsafe.objectFieldOffset
                (AtomicBoolean.class.getDeclaredField("value"));
         catch (Exception ex)  throw new Error(ex); 
    
    private volatile int value;

    /**
     * Returns the current value.
     *
     * @return the current value
     */
    public final boolean get() 
        return value != 0;
    
    
    /**
     * Atomically sets the value to the given updated value
     * if the current value @code == the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return @code true if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(boolean expect, boolean update) 
        int e = expect ? 1 : 0;
        int u = update ? 1 : 0;
        return unsafe.compareAndSwapInt(this, valueOffset, e, u);
    

1、使用了 sun.misc.Unsafe 对象,这个类提供了一系列直接操作内存对象的方法,只是在 jdk 内部使用,不建议开发者使用;

2、value 表示实际值,可以看到 get 方法实际是根据 value 是否等于0来判断布尔值的,这里的 value 定义为 volatile,因为 volatile 可以保证内存可见性,也就是 value 值只要发生变化,其他线程是马上可以看到变化后的值的

关于volatile 关键字,大家可以查看我的另一篇博文:https://yangyongli.blog.csdn.net/article/details/125819551

3、valueOffset 是 value 值的内存偏移量,用 unsafe.objectFieldOffset 方法获得,用作后面的 compareAndSet 方法;

4、compareAndSet 方法,这就是实现 CAS 的核心方法了,在使用 AtomicBoolean 的这个方法时,只需要传递期望值和待更新的值即可,而它里面调用了 unsafe.compareAndSwapInt(this, valueOffset, e, u) 方法,它是个 native 方法,用 c++ 实现,具体的代码就不贴了,总之是利用了 CPU 的 cmpxchg 指令完成比较并替换,当然根据具体的系统版本不同,实现起来也有所区别,感兴趣的可以自行搜一下相关文章。

2.3 CAS使用场景

(1)CAS 适合简单对象的操作,比如布尔值、整型值等;
(2)CAS 适合冲突较少的情况,如果太多线程在同时自旋,那么长时间循环会导致 CPU 开销很大;

比如 AtomicBoolean 可以用在这样一个场景下,系统需要根据一个布尔变量的状态属性来判断是否需要执行一些初始化操作,如果是多线程的环境下,避免多次重复执行,可以使用 AtomicBoolean 来实现,伪代码如下:

private final static AtomicBoolean flag = new AtomicBoolean();
if(flag.compareAndSet(false,true))
	init();

比如 AtomicInteger 可以用在计数器中,多线程环境中,保证计数准确。

3. 锁的分类

锁可以分为乐观锁(CAS)和悲观锁(synchronized)。

3.1 乐观锁

CAS(自旋锁):

CAS是从乐观的角度出发,尝试用新值更新内存值,更新时会判断内存值是否被别人修改过,如果没有则直接更新。如果被修改过,则重新获取最新值再继续尝试更新,直到更新成功为止,所以CAS方式也称自旋锁。

3.2 悲观锁

Synchronized(同步锁):
Synchronized是从悲观的角度出发,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。因此Synchronized我们也将其称之为悲观锁。jdk中ReentrantLock也是一种悲观锁。

4. CAS存在的问题

4.1 ABA问题

因为CAS需要在操作值的时候检查下值有没有发⽣变化, 如果没有发⽣变化则更新, 但是如果⼀个值原来是A, 变成了B, ⼜变成了A, 那 么使⽤CAS进⾏检查时会发现它的值没有发⽣变化, 但是实际上却变化了。

ABA问题的解决思路就是使⽤版本号。在变量前⾯追加上版本号, 每次变量更新的时候把版本号加⼀, 那么A-B-A 就会变成1A-2B-3A。

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

4.2 循环时间长开销大

⾃旋CAS如果长时间不成功,会给CPU带来⾮常⼤的执⾏开销。如果JVM能⽀持处理器提供的pause指令那么效率会有⼀定的提升

pause指令有两个作⽤:

  • 第⼀它可以延迟流⽔线执⾏指令 (de-pipeline) ,使CPU不会消耗过多的执⾏资源, 延迟的时间取决于具体实 现的版本,在⼀些处理器上延迟时间是零。

  • 第二它可以避免在退出循环的时候因内存顺序冲突 (memory order violation) ⽽引起CPU流 ⽔线被清空 (CPU pipeline flush) ,从⽽提⾼CPU的执⾏效率。

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

当对⼀个共享变量执⾏操作时,我们可以使用循环CAS的⽅式来保证原⼦操作,但是对多个共享变量操作时,循环CAS就⽆法保证操作的 原⼦性。

这个时候就可以⽤锁,或者有⼀个取巧的办法,就是把多个共享变量合并成⼀个共享变量来操作。⽐如有两个共享变量i=2,j=a, 合并⼀下ij=2a,然后⽤CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引⽤对象之间的原⼦性,你可以把多个变量 放在⼀个对象⾥来进⾏CAS操作(详细的大家可以查阅资料)。

以上是关于深入理解CAS (自旋锁)的主要内容,如果未能解决你的问题,请参考以下文章

深入理解CAS

Java:java学习笔记之锁机制的简单理解和使用

Java:java学习笔记之锁机制的简单理解和使用

多线程 CAS 机制解析及应用( 原子类 . 自旋锁 )解决 ABA 问题

Java并发编程——深入理解自旋锁

深入Java多线程锁策略