当前位置:   article > 正文

Java多线程 (四)—— 解决CAS出现的ABA问题_java aba问题影响

java aba问题影响

前言

  我们常用AtomicLong 表示车票数量 tickets,假设原始票数 tickets = 100,有一个线程A进行卖票,另一个线程B能增加票数,也能减少票数。当线程A进行卖票的时候,线程B增加10张票,又减少10张票,引起ABA问题,那么这个ABA问题对线程A有没有影响?如果有影响,会产生哪方面的线程安全问题(原子性、可见性、有序性)?如果没有影响,为什么没影响,不是说CAS会引起ABA问题吗,为什么这种场景不需要考虑ABA问题带来的影响。
前面的文章我们已经介绍了CAS原理,这篇文章告诉我们什么业务场景下才要考虑CAS导致的ABA问题,并且如何解决ABA问题。


一、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();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

在这里插入图片描述
从结果可以看到,张三做了一次开关门操作(ABA操作)偷取了试题,然后李四顺利的打开保险柜,李四护送试题的同时,张三会卖出试题。显然,这时候使用AtomicBoolean 引起的ABA问题就需要我们去解决,因为我们关心保险柜门被打开的次数。保险柜门必须只能被打开一次。所以,我们需要记录保险柜门被操作的次数。Doug Lea 早就为JAVA 设计了AtomicStampedReference类以解决CAS的ABA问题。AtomicStampedReference的CAS操作是带有版本号的操作。

二、如何解决ABA问题?

两种解决方法:
(1)AtomicStampedReference 类版本号原子引用,理解原子引用+新增一种机制,那就是修改版本号(类似时间戳)
用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();
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

在这里插入图片描述
控制台输出 “lisi打开之前门状态为:false”,但是李四开柜门没有成功!!!因为一开始的 expectedStamp 是0,被张三操作两次后变成2,李四去开柜门的时候,拿着0的版本号去开,compareAndSet 发现版本号不一致,这个开门的操作就会失败。
接下来我们结合源码来看下AtomicStampedReference 是怎样一个实现机制。
先看下AtomicStampedReference类的构造函数:

    /**
     * Creates a new {@code AtomicStampedReference} with the given
     * initial values.
     *
     * @param initialRef the initial reference
     * @param initialStamp the initial stamp
     */
    public AtomicStampedReference(V initialRef, int initialStamp) {
        pair = Pair.of(initialRef, initialStamp);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

可以发现构造函数中调用Pair的of方法,那么Pair是什么对象呢?我们继续看Pair的源码

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

    private volatile Pair<V> pair;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

pair是AtomicStampedReference类的成员变量,被关键字volatile修饰。
上面例子中new一个AtomicStampedReference对象时,其实就是给成员变量pair的构造函数属性赋值,此时reference为false,stamp为0。后面的compareAndSet方法会应用这个成员变量。
我们接着看compareAndSet这个方法是如何标记版本戳,从而解决ABA问题

    /**
     * Atomically sets the value of both the reference and stamp
     * to the given update values if the
     * current reference is {@code ==} to the expected reference
     * and the current stamp is equal to the expected stamp.
     *
     * @param expectedReference the expected value of the reference //
     * @param newReference the new value for the reference
     * @param expectedStamp the expected value of the stamp
     * @param newStamp the new value for the stamp
     * @return {@code true} if successful
     */
      //expectedReference表示我们传递的预期值
			//newReference表示将要更改的新值
			//expectedStamp表示传递的预期版本戳
			//newStamp表示将要更改的版本戳的新值。
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

分解return后的代码有三大逻辑:第一个逻辑&&(第二个逻辑 || 第三个逻辑)
第一个逻辑:预期值(expectedReference)等于pair中的值(reference ),并且预期版本戳(expectedStamp )等于pair中的版本戳(stamp) ,如果都满足,才能继续往下判断,否则直接返回false。

expectedReference == current.reference && expectedStamp == current.stamp
  • 1

第二个逻辑:更改的新值(newReference)等于pair中的值(reference ),并且更改的版本戳的新值(newStamp)等于pair中的版本戳(stamp),如果都满足,就不在执行第三个逻辑了,如果为false,则继续往下执行第三个逻辑

newReference == current.reference && newStamp == current.stamp
  • 1

第三个逻辑:CAS逻辑

casPair(current, Pair.of(newReference, newStamp))
  • 1

如果预期值和预期版本戳与pair中的值和版本戳相等,但是更改的新值和更改的版本戳与pair中的值和版本戳不相等,此时需要进行CAS更新了。
然后我们再回顾下之前CAS包含的三个操作:
1、变量内存地址,V表示
2、旧的预期值,A表示
3、准备设置的新值,B表示
上面的预期值(expectedReference)、预期版本戳(expectedStamp )就相当旧的预期值(A),pair的属性值(reference、stamp)就相当于变量内存地址(V),更改的新值(newReference)、更改的版本戳的新值(newStamp)就相当于准备设置的新值(B)。到这里是不是看出什么了,没错,CAS的三个操作就体现在这三大逻辑里,只有当V等于A时,才会用B去更新V的值(通过Pair的of方法去更新),此时需要进行CAS更新(通过casPair方法去更新),否则就不会执行更新操作。
那么casPair是怎么实现呢?让我们继续看源码:

private boolean casPair(Pair<V> cmp, Pair<V> val) {
        return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
    }
  • 1
  • 2
  • 3
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
  • 1

发现又涉及到底层Unsafe类,compareAndSwapObject是一个native方法,也就是说再往下走就是C语言代码。这里就不继续深入研究了,有兴趣的同学可以通过openjdk继续往下研究底层C源码,会发现CAS真正实现机制由操作系统的汇编指令完成的。
理解完源码后让我们结合例子分析为什么李四开柜门没有成功。我们先看下李四的开柜代码,因为李四拿的是一开始的expectedStamp版本号,那个时候是0,但是被张三两次操作后变成了2,此时就不满足compareAndSet中return的等式:expectedStamp(0) == current.stamp(被张三操作了变成了2),所以最终不会执行casPair方法,也就是执行CAS更新。

boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expectedReference, newReference, expectedStamp, expectedStamp + 1);
  • 1

(2)AtomicMarkableReference 类
用AtomicMarkableReference 编写上面的程序。

public class AtomicMarkableReferenceExample {
    private static AtomicMarkableReference<Boolean> OPEN_OR_CLOSE = new AtomicMarkableReference<>(false, false);

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

        Thread zhangsan = new Thread(() -> {
            boolean isOpen = OPEN_OR_CLOSE.compareAndSet(expectedReference, newReference, expectedMark, true);
            System.out.println(Thread.currentThread().getName() + "开柜门:" + isOpen);
            // 省略 偷试题的操作
            boolean isClose = OPEN_OR_CLOSE.compareAndSet(newReference, expectedReference, OPEN_OR_CLOSE.isMarked(), true);
            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, expectedMark, true);
            System.out.println(Thread.currentThread().getName() + "打开之后门状态为:" + OPEN_OR_CLOSE.getReference() + ", "
                    + Thread.currentThread().getName() + "开柜门是否成功:" + isOpen);
        }, "lisi");

        // 当然这个地方最好用 发令枪做,同时起跑
        zhangsan.start();
        lisi.start();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

在这里插入图片描述
执行结果与AtomicStampedReference一样,控制台输出 “lisi打开之前门状态为:false”,但是李四开柜门没有成功!!!因为一开始的 expectedMark 是false,但实际被张三操作过两次,此时的expectedMark 状态已经是true,李四再去开柜门,发现expectedMark不一致,这个开门的操作就会失败。
接下来我们结合源码来看下AtomicMarkableReference 是怎样一个实现机制。
先看下AtomicMarkableReference 类的构造函数:

    /**
     * Creates a new {@code AtomicMarkableReference} with the given
     * initial values.
     *
     * @param initialRef the initial reference
     * @param initialMark the initial mark
     */
    public AtomicMarkableReference(V initialRef, boolean initialMark) {
        pair = Pair.of(initialRef, initialMark);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

可以发现构造函数中调用Pair的of方法,跟AtomicStampedReference实现基本一样,只不过把int类型标记改成了boolean类型标记,我们继续看Pair的源码

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

    private volatile Pair<V> pair;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

发现还是跟AtomicStampedReference实现基本一样,还是int类型标记变成了boolean类型标记,让我们继续看AtomicMarkableReference的compareAndSet方法是否有什么不一样的地方

    /**
     * Atomically sets the value of both the reference and mark
     * to the given update values if the
     * current reference is {@code ==} to the expected reference
     * and the current mark is equal to the expected mark.
     *
     * @param expectedReference the expected value of the reference
     * @param newReference the new value for the reference
     * @param expectedMark the expected value of the mark
     * @param newMark the new value for the mark
     * @return {@code true} if successful
     */
      //expectedReference表示我们传递的预期值
			//newReference表示将要更改的新值
			//expectedStamp表示传递的预期boolean类型标记
			//newStamp表示将要更改的boolean类型标记的新值
    public boolean compareAndSet(V       expectedReference,
                                 V       newReference,
                                 boolean expectedMark,
                                 boolean newMark) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedMark == current.mark &&
            ((newReference == current.reference &&
              newMark == current.mark) ||
             casPair(current, Pair.of(newReference, newMark)));
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

上面的源码发现还是int类型标记变成了boolean类型标记,从这里可以看出AtomicStampedReference和AtomicMarkableReference他们的实现原理是相同的,那么他们有什么区别呢?区别如下:
AtomicMarkableReference 利用一个boolean类型的标记来记录,只能记录它改变过,不能记录改变的次数;
AtomicStampedReference 利用一个int类型的标记来记录,它能够记录改变的次数。

  除此之外,ABA还会导致整体数据错误,比如经典的链表换链头的例子,也是ABA 问题,但是它是链的数据变化了,其实不是针对次数,而是针对链中数据。简单说明下现象。
有一个单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:head.compareAndSet(A,B);在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再push D、C、A。而对象B此时处于游离状态:此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。

二、总结

  在学习CAS原理的过程中,我看了一些视频和一些博客,都是说CAS原理是什么,会带来ABA问题,ABA问题如何解决。但是都没说清楚,到底什么情况下要解决CAS导致的ABA问题。就好比师傅教你一招降龙十八掌对付坏人(ABA问题的解决方案),却没告诉你什么样的坏人(什么情况需要考虑解决ABA问题),你使出降龙十八掌才有效。
  如果业务只关心Atomic系列类的值,不关心值的变化次数(ABA会增加两次操作),那么CAS导致的ABA问题就无需考虑,例如卖票问题,你只关心总票数,不关心总票数波动的次数——别人退票后的票数增加或者其他人买票后票数减少。反之,如果业务关心CAS的操作次数,例如本文的保险柜开关次数,就需要引入版本号解决ABA问题。
某个对象,在某个状态只能被操作一次,即针对数值变化次数有要求,不是针对数值。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Monodyee/article/detail/363207
推荐阅读
相关标签
  

闽ICP备14008679号