真实业务场景展现CAS原理的ABA问题及解决方案

  • Post author:
  • Post category:其他




阅读提示

本文将借助

开保险柜的业务场景

重点阐述误用AtomicBoolean引起的ABA问题,以及解决方案。基于此,请先深入理解CAS原理,以及其会产生的ABA问题。关于CAS原理和ABA问题的

优秀博客

已经存在很多,所以本文只简单介绍CAS原理,希望读者有此基础。



CAS原理、ABA问题介绍

CAS(Compare and Swap)是一种乐观锁机制。CAS有3个操作数,预期值A,内存值V,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS算法实现一个重要前提需要线程A取出内存中某时刻的数据,而在下一时刻比较并替换,那么在这个时间差内的数据变化,线程A是无法感知。比如线程B在这个时间差内,就数据从A改成B,再从B改成A。线程A在下一时刻比较替换会成功。请你思考一下,这种ABA问题有什么危害?为什么要关心CAS原理带来的ABA问题?

比如,我们常用AtomicLong 表示车票数量 tickets,假设原始票数 tickets = 100,有一个线程A进行卖票,另一个线程B能增加票数,也能减少票数。当线程A进行卖票的时候,线程B增加10张票,又减少10张票,引起ABA问题,那么这个ABA问题对线程A有没有影响?如果有影响,会产生哪方面的线程安全问题(原子性、可见性、有序性)?如果没有影响,为什么没影响,不是说CAS会引起ABA问题吗,为什么这种场景不需要考虑ABA问题带来的影响。本文终极问题——

到底什么情况下才要考虑解决CAS导致的ABA问题?



真实业务场景

// OPEN_OR_CLOSE 表示 保存高考卷的保险柜 开关,false表示close, true 表示 open,考虑下面的业务场景,
// 高考卷的保险柜,有且仅有两人有打开的权限。根据保密要求,人越少越好。当然,有且仅有一个人有权限,保密性更高,但是如果这人发生意外,就没人能打开保险柜
// 所以选两个人 既能照顾到保密性要求,又能减少突发事件的影响。
// 要求 2022-06-07 06:00:00 后,两个线程竞争去开保险柜,有且只有一人能打开,打开保险柜的人负责护送试题。(不考虑 synchronized 的实现方式)
// 假设这样的一种场景,张三、李四 竞争开柜的过程中,张三使手段让李四在开柜前,卡一下,确保自己能先开柜,然后拍照,获取试题,最后关上柜门
// 这个时候李四来开柜门,发现门的状态和教育部说的状态一样,都是close,然后李四拿走试题,张三过来说“李四啊,这次送试卷的任务就只能麻烦你了。”
// 然后 张三转手卖出试题,就算出了事情,教育厅也只能查李四。

这里先使用 AtomicBoolean 去实现,然后引出ABA问题。

public class AtomicBooleanExample {

    private static AtomicBoolean OPEN_OR_CLOSE = new AtomicBoolean(false);

    public static void main(String[] args) throws InterruptedException {
        boolean expect = OPEN_OR_CLOSE.get();
        boolean update = true;

        Thread zhangsan = new Thread(() -> {
            boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expect, update);
            System.out.println(Thread.currentThread().getName() + "开柜门:" + isOpen);
            // 省略 偷试题的操作
            boolean isClose = OPEN_OR_CLOSE.compareAndSet(OPEN_OR_CLOSE.get(), expect);
            System.out.println(Thread.currentThread().getName() + "关柜门:" + isClose);
            System.out.println(Thread.currentThread().getName() + "偷题是否成功" + (isOpen && isClose));
        }, "zhangsan");

        Thread lisi = new Thread(() -> {
            try {
                // 张三使手段,确保自己先执行完,真实场景可能用其它的手段
                zhangsan.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "打开之前门状态为:" + OPEN_OR_CLOSE.get());
            boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expect, update);
            System.out.println(Thread.currentThread().getName() + "打开之后门状态为:" + OPEN_OR_CLOSE.get() + ", " + Thread.currentThread().getName() + "开柜门是否成功:" + isOpen);
        }, "lisi");

        zhangsan.start();
        lisi.start();
    }
}

zhangsan开柜门:true
zhangsan关柜门:true
zhangsan偷题是否成功true
lisi打开之前门状态为:false
lisi打开之后门状态为:true, lisi开柜门是否成功:true

从结果可以看到,张三做了一次开关门操作(ABA操作)偷取了试题,然后李四顺利的打开保险柜,李四护送试题的同时,张三会卖出试题。显然,这时候使用AtomicBoolean 引起的ABA问题就需要我们去解决,因为我们关心保险柜门被打开的次数。

保险柜门必须只能被打开一次。

所以,我们需要记录保险柜门被操作的次数。Doug Lea 早就为JAVA 设计了AtomicStampedReference类以解决CAS的ABA问题。AtomicStampedReference的CAS操作是带有

版本号

的操作。



如何解决ABA问题

接下来用AtomicStampedReference 编写上面的程序。

public class AtomicStampedReferenceExample {

    private static AtomicStampedReference<Boolean> OPEN_OR_CLOSE = new AtomicStampedReference<>(false, 0);

    public static void main(String[] args) throws InterruptedException {
        boolean expectedReference = OPEN_OR_CLOSE.getReference();
        boolean newReference = true;
        int expectedStamp = OPEN_OR_CLOSE.getStamp();

        Thread zhangsan = new Thread(() -> {
            boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expectedReference, newReference, expectedStamp, expectedStamp + 1);
            System.out.println(Thread.currentThread().getName() + "开柜门:" + isOpen);
            // 省略 偷试题的操作
            boolean isClose = OPEN_OR_CLOSE.compareAndSet(newReference, expectedReference, OPEN_OR_CLOSE.getStamp(), OPEN_OR_CLOSE.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + "关柜门:" + isClose);
            System.out.println(Thread.currentThread().getName() + "偷题是否成功" + (isOpen && isClose));
        }, "zhangsan");

        Thread lisi = new Thread(() -> {
            try {
                // 张三使手段,确保自己先执行完,真实场景可能用其它的手段
                zhangsan.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "打开之前门状态为:" + OPEN_OR_CLOSE.getReference());
            boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expectedReference, newReference, expectedStamp, expectedStamp + 1);
            System.out.println(Thread.currentThread().getName() + "打开之后门状态为:" + OPEN_OR_CLOSE.getReference() + ", "
                    + Thread.currentThread().getName() + "开柜门是否成功:" + isOpen);
        }, "lisi");

        // 当然这个地方最好用 发令枪做,同时起跑
        zhangsan.start();
        lisi.start();
    }

}

zhangsan开柜门:true
zhangsan关柜门:true
zhangsan偷题是否成功true
lisi打开之前门状态为:false
lisi打开之后门状态为:false, lisi开柜门是否成功:false

控制台输出 “lisi打开之前门状态为:false”,但是李四开柜门没有成功!!!因为一开始的 expectedStamp 是0,被张三操作两次后变成2,李四去开柜门的时候,拿着0的版本号去开,compareAndSet 发现版本号不一致,这个开门的操作就会失败。



CAS学习总结

再次学习CAS原理的过程中,我看了一些视频和一些博客,都是说CAS原理是什么,会带来ABA问题,ABA问题如何解决。但是都没说清楚,到底什么情况下要解决CAS导致的ABA问题。就好比师傅教你一招降龙十八掌对付坏人(ABA问题的解决方案),却没告诉你什么样的坏人(什么情况需要考虑解决ABA问题),你使出降龙十八掌才有效。


如果业务只关心Atomic系列类的值,不关心值的变化次数(ABA会增加两次操作),那么CAS导致的ABA问题就无需考虑,例如卖票问题,你只关心总票数,不关心总票数波动的次数——别人退票后的票数增加或者其他人买票后票数减少。



反之,如果业务关心CAS的操作次数,例如本文的保险柜开关次数,就需要引入版本号解决ABA问题。


某个对象,在某个状态只能被操作一次,即针对数值变化次数有要求,不是针对数值。


不过,也有种可能是ABA会导致整体数据错误,比如经典的链表换链头的例子,也是ABA 问题,但是它是链的数据变化了,其实不是针对次数,而是针对链中数据。

学而不思则罔,思而不学则殆。



版权声明:本文为cauchy6317原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。