【锁思想】Java开发者必读:深入理解自旋锁与CAS的关系及应用

TodoCoder大约 15 分钟Java锁思想编程思想读写锁

  大家好,我是Coder哥,在技术日新月异的今天,真正应该花费时间学习的是那些不变的编程思想,今天我们来接着上一篇文章来聊一下锁思想,我们上一篇”读写锁“详细的分析了读写锁解决线程饥饿的思想。那么今天我们再来聊另一个思想: 自旋

提到自旋锁,很多Java开发者第一时间可能会想到CAS,因为CAS 其实是面试中的常客,而且在底层原理和我们实际应用中也是比较常出现的一种思想。比如: ConcurrentHashMap,原子类,Mysql乐观锁的实现等,我们接下来会详细的探讨。为了能说明问题,我们从以下几个问题由浅入深的来一一解答一下,在看之前自己也多思考思考为什么。

  1. 自旋和CAS分别是什么以及他俩的关系是什么?
  2. 什么时候会用到自旋锁及CAS呢?
  3. 自旋及CAS的优点及缺点是什么?

自旋和CAS分别是什么以及他俩的关系是什么?

我们简单的来解释一下这两个都是啥意思以及他们之间的关系

自旋

首先,我们来看一下什么叫自旋?顾名思义,自旋可以理解为“自我旋转”,放到程序中就是"自我循环",比如while循环或者for循环。结合着锁来理解的话就是,先获取一次锁,如果获取不到锁,会不停的循环获取,直到获取到。不像普通的锁那样,如果获取不到锁就进入阻塞状态。

自旋和非自旋的获取锁的流程

我们来看一下自旋锁和非自旋锁的获取锁的过程

自旋锁和非自旋锁的获取锁的过程
自旋锁和非自旋锁的获取锁的过程

可以看到,自旋锁没获取到锁并不会释放CPU时间片,而是通过自旋等待锁的释放,也就是说,它会一直尝试获取锁,如果获取失败就再次尝试,直到成功为止。

CAS

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

CAS算法有3个基本操作数:

  • 内存地址V
  • 旧的预期值A
  • 要修改的新值B

在并发场景下,各个代码的执行顺序不能确定,为了保证并发安全,我们可以使用普通的互斥锁,比如Java的 synchronized, ReentrantLock等。而CAS的特点是避免使用互斥锁,当多个线程并发使用CAS更新同一个变量时,只有一个可以操作成功,其他都会失败。而且用CAS更新失败的线程并不会阻塞,会快速失败并返回一个失败的状态,允许你再次尝试。

自旋锁和CAS的关系是啥

其实他们是两个不同的概念

自旋是一种锁优化的机制,在锁优化中『自旋锁』指线程空转重试获取锁,避免线程上下文切换带来的开销。

CAS是一种乐观锁机制,cas是通过比较并交换,失败的时候可以直接返回false不用自旋的获取。只是一般应用场景下,cas都会带有重试机制(while或者for实现空转,不断尝试获取)。

如果非要加个关系的话 自旋锁 = 循环+CAS

什么时候会用到自旋锁及CAS呢?

  在一般并发场景下,加普通锁基本可以解决所有的安全问题,但是为什么还分各种锁,悲观/乐观,公平/非公平,读写锁呢?《可查看Java并发-锁思想 系列文章》open in new window因为在很多场景下,我们同步的代码块内容并不多,需要的执行时间也很短,如果我们每次都去切换线程状态,势必会带来上下文切换等开销,如果不切换线程而是让它自旋的尝试获取锁,等待其他线程释放锁,就可以避免上下文切换等开销,提就了效率。

所以Doug Lea 大神在 JUC 包中大量使用了 CAS 算法,该算法既能保证安全性,又不需要使用互斥锁,能大大提高锁的性能。下面我们可以看一下:

并发容器:ConcurrentHashMap

先来看一下并发容器 ConcurrentHashMap 的 putVal 方法的代码,如下所示:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) 
      throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
    ...
}

上面代码中有一个醒目的方法,它就是 casTabAt(...),这个方法名就带有 “CAS”,可以猜测它一定是和 CAS 密不可分了,下面给出 casTabAt 方法的代码实现:

private static final sun.misc.Unsafe U ;

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

该方法里面只有一行代码,即调用变量 U 的 compareAndSwapObject 的方法,可以看出,U 是 Unsafe 类型的,Unsafe 类下的 compareAndSwapInt、compareAndSwapLong、compareAndSwapObject 等是和 CAS 密切相关的 native 层的方法,底层原理就是通过 CPU 对 CAS 指令的支持实现的。

上面的 casTabAt(...) 方法,不仅被用在了 ConcurrentHashMap 的 putVal 方法中,还被用在了 computeIfAbsent、merge、compute、transfer 等重要的方法中,所以 ConcurrentHashMap 对于 CAS 的应用是比较广泛的。

这个是CAS在并发容器中的案例,除了这个其他的并发容器中也有,比如说:非阻塞并发队列 ConcurrentLinkedQueue 的 offer 方法里也有 CAS 的身影,这里就不贴代码了,有兴趣的可以自行查阅。

下面我们来看一下自旋在原子类中的应用:

AtomicLong 的实现

在 Java 1.5 版本及以上的并发包 java.util.concurrent 中,里面的原子类基本都是自旋锁的实现。

比如我们来看一个 AtomicLong 的实现,里面有一个 getAndIncrement 方法,源码如下:

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

可以看到它调用了一个 unsafe.getAndAddLong,所以我们再来看这个方法:

public final long getAndAddLong (Object var1,long var2, long var4){
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

    return var6;
}

我们看到上述方法中有对 var6 的赋值,调用了 unsafe 的 getLongVolatile(var1, var2) 方法,这是一个 native 方法,作用是获取变量 var1 中偏移量 var2 处的值。这里传入 var1 的是 AtomicLong 对象的引用,而 var2 就是 AtomicLong 里面所存储的数值(也就是 value)的偏移量 valueOffset,所以此时得到的 var6 实际上代表当前时刻下的原子类中存储的数值。

接下来重点来了,我们看到有一个 compareAndSwapLong 方法,这里会传入多个参数,分别是 var1、var2、 var6、var6 + var4,其实它们代表 object、offset、expectedValue 和 newValue。

  • 第一个参数 object 就是将要修改的对象,传入的是 this,也就是 AtomicLong 这个对象本身;
  • 第二个参数 offset,也就是偏移量,借助它就可以获取到 AtomicLong本身 value 的数值;
  • 第三个参数 expectedValue,代表“期望值”,传入的是刚才获取到的 var6;
  • 最后一个参数 newValue 是希望修改为的新值 ,等于之前取到的数值 var6 再加上 var4,而 var4 就是我们之前所传入的 delta,delta 就是我们希望原子类所改变的数值,比如可以传入 +1,也可以传入 -1。

所以 compareAndSwapLong 方法的作用就是,判断如果现在AtomicLong里 value 的值和之前获取到的 var6 相等的话,也就是没有被其他线程修改过,那么就认为是安全的,就把计算出来的 var5 + var4 给更新上去,那么就会退出循环。

但是如果var6的值被其他线程修改了,那么这里的 ”期望值“ 就不一样了,那么 this.compareAndSwapLong(var1, var2, var6, var6 + var4) 会返回 false, 就会继续执行循环体再次获取var6的值,这里是个死循环直到获取到最新的没被其他线程修改过的”期望值“ 为止,所以这里的compareAndSwapLong 其实就是CAS算法的实现,而do-while循环正是自旋的思想

除了这些,自旋和CAS在我们调用数据库的业务场景中也有应用:

数据库

在我们的数据库中,也存在对乐观锁《【锁思想】性能提升之道-悲观锁和乐观锁原理及场景分析》open in new window和 CAS 思想的应用。比如我们在更新数据的时候,可以利用 version 字段在数据库中实现乐观锁和 CAS 操作,而在获取和修改数据时都不需要加悲观锁。

具体思路如下:

在数据获取和计算完成后,我们会检查当前版本号与之前获取数据时的版本号是否相同。如果相同,表示在计算期间数据没有被更新,可以直接更新本次数据。如果版本号不同,说明在计算期间有其他线程修改了数据,我们可以选择重新获取数据、重新计算,然后再次尝试更新数据。

假设数据获取时的版本号为1,相应的SQL语句如下:

UPDATE student SET name = '小王', version = 2 WHERE id = 10 AND version = 1

通过这种方式,我们可以使用CAS(比较并交换)的思想来实现本次的更新操作。首先,它会比较版本号是否与最初获取的1相同,只有相同才会进行name字段的修改,并将版本号加一。

如业务有需要,在业务层的处理逻辑可以根据 UPDATE的返回值,循环几次,来自旋的获取,确保可以更新成功。

我们可以看出, CAS 和自旋思想 在并发容器数据库原子类及自旋的业务场景中都有很多和 CAS 相关的应用及代码,所以 CAS和自旋都 有着广泛的应用场景。

自旋及CAS的优点及缺点是什么?

从上面的介绍可以看出,自旋锁拿不到锁的时候会不停地尝试。那么,自旋锁这样不停尝试的好处是什么呢?

自旋锁及CAS的好处

首先,阻塞和唤醒线程的开销很高。如果同步代码块的内容不复杂,线程切换的开销可能比实际业务代码的执行开销还要大。这里可以参考《【锁思想】为什么synchronized的默认策略是非公平的? 》open in new window

在许多情况下,同步代码块的内容并不多,执行时间很短。如果我们仅仅为了这点时间就切换线程状态,实际上不如让线程保持原状态,自旋地尝试获取锁,等待其他线程释放锁。有时候,我们只需要稍等一下,就能避免上下文切换等开销,提高效率。

总结自旋锁的好处,就是通过循环不停地尝试获取锁,让线程始终处于Runnable状态,节省了线程状态切换的开销。

自旋锁及CAS的缺点

自旋时间过长

自旋锁可能会出现自旋时间过长。

由于单次CAS操作不一定能成功,通常需要结合循环来实现。有时甚至需要使用死循环,不断重试,直到线程竞争不激烈时才能成功修改。

然而,如果我们的应用场景本身就是高并发的,可能导致CAS一直无法成功。这样循环的时间会越来越长。同时,在此期间,CPU资源也会持续消耗,对性能产生很大影响。因此,我们需要根据实际情况来选择是否使用CAS。在超高并发场景下,通常CAS的效率并不高。

范围不能灵活控制

CAS 不能灵活控制线程安全的范围。

通常情况下,我们执行CAS操作时针对的是单个共享变量,这个变量可以是Integer、Long、对象等类型。但是我们不能同时对多个共享变量进行CAS操作,因为这些变量之间是独立的,简单地将原子操作组合在一起并不具备原子性。因此,如果我们想要对多个对象同时进行CAS操作并保证线程安全,会比较困难。

有一种解决方案是利用一个新的类来整合这组共享变量,新类中的多个成员变量就是之前的多个共享变量。然后,使用atomic包中的AtomicReference来对这个新对象进行整体CAS操作,这样就可以保证线程安全。

相比之下,如果我们使用其他的线程安全技术,调整线程安全的范围可能会更容易。例如,使用synchronized关键字时,如果想要将更多的代码加锁,只需要将更多的代码放入同步代码块中即可。

ABA 问题

CAS的最大缺点是ABA问题。

CAS的判断标准是当前值和预期值是否一致,如果一致,则认为在此期间该值没有发生变化,这在大多数情况下是没有问题的。

然而,在某些业务场景下,我们希望确切知道从上一次观察到该值到现在,该值是否发生过变化。例如,假设该值从A变为B,然后又从B变回A,我们不仅认为它发生了变化,而且认为它发生了两次变化。

在这种情况下,使用CAS无法观察到这两次变化,因为仅仅判断当前值和预期值是否相等是不够的。CAS检查的是值是否发生变化,而不是值是否和原来值不一样。如果变量的值从旧值A变为新值B再变回旧值A,由于最初的值A和现在的值A是相等的,CAS会认为变量的值在此期间没有发生变化。因此,CAS无法检测出值是否在此期间被修改过,它只能检查当前值和最初值是否相同。

举个例子,假设第一个线程获取的初始值是100,然后进行计算过程中,第二个线程将初始值改为200,然后又将其改回100。当第一个线程计算完毕并执行CAS时,它会比较当前值是否等于最初获取的初始值100,发现它确实等于100,因此线程一会认为在此期间值没有被修改过,并将其改为计算出的新值。然而,在此过程中,其他线程已经修改了该值,这就导致了ABA问题的发生。

那么如何解决这个问题呢?一个解决方案是添加一个版本号。

我们可以在变量值之外添加一个版本号,这样值的变化路径从ABA变为1A→2B→3A。通过比较版本号来判断值是否发生过变化比直接比较两个值是否相等更可靠,因此通过这种方式可以解决ABA问题。

在atomic包中,提供了AtomicStampedReference类,它专门用于解决ABA问题。它维护了一个类似<Object, int>的数据结构,其中的int就是用于计数的版本号。AtomicStampedReference可以对这个对象和版本号同时进行原子更新,从而解决ABA问题。因为我们判断值是否被修改不再以值是否发生变化为标准,而是以版本号是否变化为标准,即使值相同,它们的版本号也是不同的。

以上就是对CAS的最严重的一个缺点——ABA问题的介绍。

总结

本篇文章主要围绕自旋和CAS来展开介绍,主要介绍了他们俩的区别,对于锁来讲自旋的思想给程序带来的性能提升,以及在Java中CAS和自旋的适用场景等,这里就不再赘述了。

感谢大家能看到这里,写文章不易,如果觉得有用记得点个赞,感谢!

书籍推荐:
《计算机内功修炼系列》https://www.todocoder.com/pdf/jichu/001001.htmlopen in new window
《Java编程思想》https://www.todocoder.com/pdf/java/002002.htmlopen in new window
《Java并发编程实战》https://www.todocoder.com/pdf/java/002004.htmlopen in new window