CAS与自旋锁、ABA问题

基本概念

CAS(Compare And Swap)是一种比较交换算法,很多人会把CAS和自旋锁混为一谈,两者还是有差别,前者是一种原子算法,目的是在不使用操作系统互斥信号量的重量级锁的前提下提供一种原子操作,后者基于CAS算法实现。
操作系统底层对CAS的都提供了不同的指令级实现:

  • x86 cmpxchg
  • arm LL/SC

查看openJDK的源码实现的代码片段。完整代码地址:源码地址

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
    UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 使用了Atomic::cmpxchg来保证原子性。
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

Java中提供了sun.misc.Unsafe类来提供CAS操作,这个类中大部分的方法为native方法,调用JVM底层的C++实现,该类中提供了一个获得操作native方法的对象:

@CallerSensitive
public static Unsafe getUnsafe() {
    Class var0 = Reflection.getCallerClass();
    if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
        throw new SecurityException("Unsafe");
    } else {
        return theUnsafe;
    }
}

CAS的核心思想是比较并交换,CAS的核心包含三个值:

  • V 要被更新的变量值 主存
  • E 预期的值 线程内存(V的副本)
  • N 更新的值

当 V == E 的时候说明 V 的值与期望值相同,这个时候可以将V更新为新的值N,如果不一致就将主存的值同步到线程内存副本,继续下一次比较。查看Unsafe中getAndAddInt实现

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;
}

可以将这个操作抽象为:

do{   
  备份旧数据;  
  基于旧数据构造新数据;  
}while(!CAS( 内存地址,备份的旧数据,新数据 ))  

当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的 commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。
这中间的过程其实是分为了两部,很有可能在比较的过程中,还没有修改时被别的线程已经修改了这就容易出现并发场景的变量安全问题,这就要求了CAS是一个原子操作,这个在操作系统指令级已经实现。

注意事项

当然CAS不是说没有任何缺点的,在使用的过程中需要注意自旋锁基于CAS的实现及优化细节,另外就是需要注意ABA问题,接下来对这两种情况需要注意的点进行介绍。

自旋锁

Java中synchronized锁实现存在一个锁升级的过程,在轻量级锁膨胀为重量级锁的过程中会先尝试基于CAS的自旋操作,也就是所谓的自旋锁。轻量级锁升级为重量级锁之前,线程执行monitorenter指令进入Monitor对象的EntryList队列,此时会通过自旋尝试获得锁,自旋次数超过了一定阈值(默认10),才会升级为重量级锁,等待线程被唤起。
自旋的这个过程是短暂的,但是如果过多的线程进入自旋状态,大量的线程进程进入自旋状态会占用CPU的时间片,导致CPU利用率飙升,因此有大量线程等待切换执行的场景更适合用synchronized。此外JVM也对自旋场景进行了优化:

  • 如果平均负载小于CPUs则一直自旋
  • 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节电模式则停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

ABA问题

CAS中另一个经典问题就是ABA问题。ABA指的是两个线程操作同一个变量,两个线程开始拿到主存变量副本都为A,Thread-1做写操作前,Thread-2将原值改为B,但是又做了某些操作改回A,Thread-1通过CAS操作也成功了,虽然这个过程A-B-A,在Thread-1操作时变量值没有发生改变,不影响业务逻辑,结果没有问题,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,不代表链表就没有变化。
如果添加了版本标记是不是就可以解决这种问题,具体思路就是由原来的A-B-A转换为1A-2B-3A,是不是就解决了这种问题,JUC中atomic包提供了一个工具类AtomicStampedReference就实现了这种操作。

An AtomicStampedReference maintains an object reference along with an integer "stamp", that can be updated atomically.
AtomicStampedReference 维护一个对象引用以及一个可以自动更新的整数“标记”。

查看类中相关属性和字段
AtomicStampedReference.png
属性只有两个一个对象引用reference和一个自动更新标记值stamp。
其内部包含一个静态内部类Pair将reference和stamp进行了绑定,构造方法需要传递初始版本号。

private static class Pair<T> {
    final T reference;
    final int stamp;
    private Pair(T reference, int stamp) {
        this.reference = reference;
        this.stamp = stamp;
    }
    static <T> Pair<T> of(T reference, int stamp) {
        return new Pair<T>(reference, stamp);
    }
}

查看原子操作compareAndSet方法

  1. 比较期望引用、当前引用、新引用和期望stamp、当前stamp、新stamp之间的关系,如果都为true返回true
  2. 如果1比较结果为false,通过CAS操作构造一个新的Pair对象并更新pair,返回CAS的操作结果
public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    // 获取当前pair(元素值,版本号)
    Pair<V> current = pair;
    return
        // 引用没变
        expectedReference == current.reference &&
        // 版本号没变
        expectedStamp == current.stamp &&
        // 新引用等于旧引用
        ((newReference == current.reference &&
        // 新版本号等于旧版本号
          newStamp == current.stamp) ||
          // 构造新的Pair对象并CAS更新
         casPair(current, Pair.of(newReference, newStamp)));
}

private boolean casPair(Pair<V> cmp, Pair<V> val) {
    // 调用Unsafe的compareAndSwapObject()方法CAS更新pair的引用为新引用
    return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}

其实除了版本号之外,这里也提供了一个解决ABA的思路,不重复使用节点的引用,每次使用新的节点引用来进行比较。
ABA解决方案:

  • 引入版本号
  • 不重复使用节点引用

通过一个简单的例子演示AtomicStampedReference使用

package stampreference;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 邮票参考测试
 *
 * @author starsray
 * @date 2021/12/21
 */
public class StampReferenceTest {
    
    public static void main(String[] args) {
        test();
    }

    /**
     * 测试
     */
    static void test() {
        AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(1, 1);

        Thread thread1 = new Thread(() -> {
            int[] stampHolder = new int[1];
            int value = atomicStampedReference.get(stampHolder);
            int stamp = stampHolder[0];

            System.out.printf("thread-1 read value:%s,stamp:%s \n", value, stamp);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (atomicStampedReference.compareAndSet(value, 3, stamp, stamp + 1)) {
                System.out.printf("thread-1 update from %s to 3\n", value);
            } else {
                System.out.println("thread-1 update fail");
            }
        });

        Thread thread2 = new Thread(() -> {
            int[] stampHolder = new int[1];
            int value = atomicStampedReference.get(stampHolder);
            int stamp = stampHolder[0];
            System.out.printf("thread-2 read value:%s,stamp:%s\n", value, stamp);
            if (atomicStampedReference.compareAndSet(value, 2, stamp, stamp + 1)) {
                System.out.printf("thread-2 update from %s\n", value);
                value = atomicStampedReference.get(stampHolder);
                stamp = stampHolder[0];
                System.out.printf("thread-2 read value:%s,stamp:%s\n", value, stamp);
                if (atomicStampedReference.compareAndSet(value, 1, stamp, stamp + 1)) {
                    System.out.printf("thread-2 update from %s to 1\n", value);
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

输出结果:

thread-1 read value:1,stamp:1 
thread-2 read value:1,stamp:1
thread-2 update from 1
thread-2 read value:2,stamp:2
thread-2 update from 2 to 1
thread-1 update fail

很好的通过版本解决了ABA问题。

posted @ 2022-07-01 11:29  星光Starsray  阅读(101)  评论(0编辑  收藏  举报