详解CAS操作的ABA问题

Published on 2024-10-17 13:28 in 分类: 博客 with 狂盗一枝梅
分类: 博客

一、ABA问题

CAS原子操作虽然好,但是出生就自带ABA问题,那么什么是ABA问题?CAS的全称叫做Compare and swap,也就是比较和交换,ABA问题就出现在"Compare"比较阶段。

举个例子,X变量的值是10,现在线程A要对X变量执行CAS(10,20)操作,但是在执行前遭遇了线程切换,线程B对X变量先执行了CAS(10,30),将X的值变成了30,接着继续执行了CAS(30,10),又将X的值变成了10,这时候才切换到到线程A执行CAS(10,20),线程A不知道在它对X执行CAS操作之前,X变量已经经历了10->30->10的变化,所以最终执行CAS操作成功了。

image-20241016155404820

CAS比较的时候只比较值是否相同,X变量在线程A CAS之前出现了从10->30->10的变化而线程A不知道,而且最终还CAS成功了,就是ABA问题。

那线程A执行成功了不好吗,会带来什么影响吗?

这让我想起一则有趣的新闻:一小区业主每晚开车回家后都不锁车,有个青年等到夜深人静的时候就悄悄把车开出去带妹子兜风蹦迪,完了之后还不忘加上油再把车偷偷还回来,就这样反复过了很多天车主都没发现。

这个新闻里,车虽然加上了油,也完好无损的还回来了,青年不打招呼就借车的行为就可以被允许了吗?再想想我们银行里存的钱,如果银行工作人员悄悄把钱取出10万用了,再在我们去银行查账之前把10万块钱补上,看似我们的钱没少,但这种行为是可以的吗?同样的,X变量的值虽然都是10,它变成30之前的10和30之后的10已经不一样了,正常来说线程A的CAS应当失败才对。

二、ABA问题的解决方案

很多乐观锁的实现版本都是使用版本号(Version)方式来解决ABA问题。乐观锁每次在执行数据的修改操作时都会带上一个版本号,版本号和数据的版本号一致就可以执行修改操作并对版本号执行加1操作,否则执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加,不会减少。

参考版本号的解决方式,在JAVA原子类中,有两个类涉及到ABA问题

类名 类功能
AtomicMarkableReference 带修改标志的引用原子类,该类能减小ABA发生的概率,但是并不能解决ABA问题
AtomicStampedReference 带印戳的引用原子类,该类能解决ABA问题

1、AtomicMarkableReference

该类在我感觉是非常有问题的一个类,相对于AtomicReference类,它不仅没解决ABA问题,还引入了额外的使用成本,实在是非常失败的一个类。接下来演示使用它未未解决ABA问题的案例

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicMarkableReference;
import java.util.concurrent.locks.LockSupport;

/**
 * @author kdyzm
 * @date 2024/10/16
 */
@Slf4j
public class AtomicMarkableReferenceTest {

    public static void main(String[] args) throws InterruptedException {
        //初始值
        User user = new User("张三");

        AtomicMarkableReference<User> reference = new AtomicMarkableReference<>(user, true);

        //线程A
        Thread threadA = new Thread(() -> {
            User lisi = new User("李四");
            User reference1 = reference.getReference();
            boolean marked = reference.isMarked();
            log.info("读取到原始值:{},{}", reference1, marked);
            //模拟线程切换,等待一秒钟
            LockSupport.parkUntil(System.currentTimeMillis() + 1000);
            boolean b = reference.compareAndSet(
                    reference1,
                    lisi,
                    marked,
                    !marked);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());
        }, "threadA");

        //线程B
        Thread threadB = new Thread(() -> {
            User wangwu = new User("王五");
            User reference1 = reference.getReference();
            boolean marked = reference.isMarked();
            log.info("读取到原始值:{},{}", reference1, marked);
            //修改为王五
            boolean b = reference.compareAndSet(
                    reference1,
                    wangwu,
                    marked,
                    !marked);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());
            //修改回张三
            reference1 = reference.getReference();
            marked = reference.isMarked();
            log.info("读取到原始值:{},{}", reference1, marked);
            //修改回张三
            b = reference.compareAndSet(
                    reference1,
                    user,
                    marked,
                    !marked);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());

        }, "threadB");

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }

    @Data
    @AllArgsConstructor
    static class User {
        private String userName;
    }

}

输出结果:

image-20241017091859554

线程A想把初始值张三改成李四,结果发生了线程切换;线程B把张三改成了王五,然后又改回了张三,这时候线程切换回线程A,线程A没意识到此“张三“已经不是之前那个张三了,就成功的把张三改成了李四,ABA问题就发生了。

来研究下在上面这个案例中为什么AtomicMarkableReference没解决ABA问题:

第一点,重复使用了“张三”这个User对象,这个是最重要的原因,重复使用了这个对象,那对象指针肯定是同一个,这导致了AtomicMarkableReference误判。

第二点,AtomicMarkableReference使用了布尔类型的变量来标记对象是否修改过,布尔类型只有true和false两种值,每次修改数据都使用了!取反值,加上第一点使用了重复的对象,正好创造了初始值没有被改造过的假象。

2、ABA问题的解决方案

通过上面AtomicMarkableReference的例子,可以总结出两点ABA问题的解决方案

1、每次都new一个新对象。如果每次都创建新对象,CAS 操作实际上是在比较和交换引用的内存地址,而不是引用指向的对象的具体值。因此,每次创建新对象都会生成一个新的内存地址,即使对象的内容与之前的对象相同,CAS 操作也会认为这是一个新的引用,这样都不需要AtomicMarkableReference,直接使用AtomicReference也不会出现ABA问题。

2、如果就是想复用已存在的对象,那标记是否修改过是必须的,标记必须是单向状态增长且不可逆的。 AtomicMarkableReference之所以没解决ABA问题,就是因为它的"标记"类型是布尔类型的,只有两种值true或者false,来回切换的状态标记实际上就发生了状态的”回退“,一旦和复用的对象组合,就很容易被AtomicMarkableReference认为”没修改过“。

接下来看看真正能解决ABA问题的AtomicStampedReference类。

3、AtomicStampedReference

该类被称为“带印戳的引用原子类”和“带修改标志的引用原子类”的AtomicStampedReference相比,它的标记是int类型的,所以可以通过正向自增实现标记不可逆。接下来演示AtomicStampedReference解决ABA问题的案例,和之前AtomicMarkableReference的例子一样,只是案例中的AtomicMarkableReference都被换成了AtomicStampedReference

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicStampedReference;
import java.util.concurrent.locks.LockSupport;

/**
 * @author kdyzm
 * @date 2024/10/16
 */
@Slf4j
public class AtomicStampReferenceTest {


    public static void main(String[] args) throws InterruptedException {
        //初始值
        User user = new User("张三");

        AtomicStampedReference<User> reference = new AtomicStampedReference<>(user, 1);

        //线程A
        Thread threadA = new Thread(() -> {
            User lisi = new User("李四");
            User reference1 = reference.getReference();
            int stamp = reference.getStamp();
            log.info("读取到原始值:{},{}", reference1, stamp);
            //模拟线程切换,等待一秒钟
            LockSupport.parkUntil(System.currentTimeMillis() + 1000);
            boolean b = reference.compareAndSet(
                    reference1,
                    lisi,
                    stamp,
                    stamp + 1);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());
        }, "threadA");

        //线程B
        Thread threadB = new Thread(() -> {
            User wangwu = new User("王五");
            User reference1 = reference.getReference();
            int stamp = reference.getStamp();
            log.info("读取到原始值:{},{}", reference1, stamp);
            //修改为王五
            boolean b = reference.compareAndSet(
                    reference1,
                    wangwu,
                    stamp,
                    stamp + 1);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());
            //修改回张三
            reference1 = reference.getReference();
            stamp = reference.getStamp();
            log.info("读取到原始值:{},{}", reference1, stamp);
            //修改回张三
            b = reference.compareAndSet(
                    reference1,
                    user,
                    stamp,
                    stamp + 1);
            log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());

        }, "threadB");

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }

    @Data
    @AllArgsConstructor
    static class User {
        private String userName;
    }

}

输出结果

image-20241017102717384

可以看到就算线程B就算重复使用了对象改回初始值,线程A也会发现对象被修改过了从而修改失败,因为这里标记使用了int类型而且每次修改都自增1,是单向状态增长且不可逆的。

三、AtomicStampedReference源码解析

看下AtomicStampedReference类主要内容

public class AtomicStampedReference<V> {

    /**
     * 静态内部类,包装了实际参数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;
        }

        //创建Pair对象
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    /**
     * 打包了实际对象和印戳的对象,cas操作的真正对象
     */
    private volatile Pair<V> pair;

    /**
     * 构造函数,直接调用Pair类of静态方法创建Pair对象
     */
    public AtomicStampedReference(V initialRef, int initialStamp) {
        pair = Pair.of(initialRef, initialStamp);
    }

    /**
     * @param expectedReference 期望值
     * @param newReference      待修改值
     * @param expectedStamp     期望印戳值
     * @param newStamp          待修改印戳值
     * @return CAS操作结果
     */
    public boolean compareAndSet(V expectedReference,
                                 V newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
                //旧值比对
                expectedReference == current.reference
                //印戳比对        
                &&expectedStamp == current.stamp
                &&(
                        //重复修改不再执行CAS,此处仅为提高效率的优化判断
                        (newReference == current.reference && newStamp == current.stamp)
                            ||
                        //执行CAS操作
                         casPair(current, Pair.of(newReference, newStamp))
                );
    }

    /**
     * @param cmp 期望值
     * @param val 修改值
     * @return CAS操作结果
     */
    private boolean casPair(Pair<V> cmp, Pair<V> val) {
        return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
    }
}

AtomicStampedReference类比较简单,大体结构和其他原子类没有太大的区别。最大的区别就是它维护了一个静态内部类Pair用于存储两个值:实际值和印戳。之后每次传值过来它都会使用Pair.of方法创建新的Pari对象作为CAS的操作对象,这样只要CAS成功,存储的必然是新的Pari对象。判定是否修改过的标志是同时比较Pair对象中的值和印戳。



END.


#java #多线程编程
目录
复制 复制成功