CAS为什么还存在线程安全问题(从所谓的ABA问题再学CAS)

概述

之前学习 CAS,从 Java 代码层面知道其原理,借助一条 CPU 原子指令,通过不断地自旋去比较(compare)和(and)赋值(set)。当时对线程安全的认知停留在将多条 Java 语句组合成一个原子操作,那么就能够保证线程安全。那想着 compare 对应 if,set 对应 =,而 compareAndSet() 又是 CPU 原子指令,那总不能为了学个 CAS 还去研究 CPU 指令吧,CAS 学完了。

直到看到有关 CAS 中 ABA 问题的文章,虽然这些文章对 ABA 问题举的例子普遍都有些烂(虽然 wiki 上面也是同样的例子,但烂就是烂),什么 B 线程将 value 值从 5 改到 6 再改回 5,哪个线程吃饱了没事干,要把一个值从 5 改到 6 再改回 5 呢?看这些文章也没弄明白 ABA 问题,或者说,我最初的疑惑是:compareAndSet 不是 CPU 原子命令吗,为什么 CAS 机制还存在线程安全(ABA)问题? 在弄清楚这个问题之后,感觉 ABA 问题就不是什么问题了。

CAS 机制为什么存在线程安全问题

先从问题出发,为什么 CAS 还存在线程安全问题?

Java内存模型

  1. 首先,需要简单了解 Java 内存模型(JMM),如图所示。

    数据保存在主内存中,线程会拷贝一份到自己的工作内存中作为缓存,所以需要使用 violate 关键字来修饰 value 变量保证可见性。可见性具体来说就是,任意一个线程修改其工作内存中的 value 时,会立即写回主内存,同时让其他线程中的缓存数据无效,其他线程如果需要使用 value,需要重新从主内存中读取最新值进行缓存。

  2. 其次,需要深度追问一下自己,compareAndSet 中 compare 比较的是哪两个对象? compareAndSet 中 set 赋值的是哪个对象? 后面通过 AtomicInteger 的源码简单看下,这里直接上结论:

    • compare 比较的两个对象是线程工作内存中的 value 和主内存中的 value;
    • set 赋值的对象是工作内存中的 value,通过 violate 来实时更新主内存(线程不能直接修改主内存的值)。
  3. 最后,为什么会有线程安全问题,问题发生的时间点在哪?

    compareAndSet 是 compre(比较)和 set(赋值)是原子性操作,但实际涉及到 get-compare -> set 这三个操作,其中 get 是指从主内存获取数据到线程的工作内存的过程。产生线程安全问题的核心就是 CAS 只保证 compare -> set 这两个过程是原子的,但是 get -> compare 并不是原子的,这个时间段(从主内存读取 value 值到实际执行 CAS 指令)会被其它线程趁虚而入。因此如果发生下面的场景,对于一般的多线程环境而言,便出现线程安全问题。但,这不是一般,这是 CAS! 在这里插入图片描述

    CAS 机制流程图

ABA 问题

上面的论断先放一放,先可能带有错误地论述一下个人理解的 ABA 问题和线程安全问题之间的关系。

所谓的 ABA 问题,个人理解成是线程安全问题的其中一种抽象描述,例如我们将上面的 B = B1 + B2,那么可以理解成一个线程的操作插入到另一个线程的两次操作之间,并最终影响到那个线程的执行结果。类似的例子还有:

  • 从数据库的事务角度来说,就是不满足事务的隔离性,事务 B 影响了事务 A。
  • 从分布式系统 CP 架构的数据库和缓存的双写一致性角度来说,假设采用 Read/Write Through 模式,将 A 看做是未命中 Redis 缓存的读请求,B 看做是写请求,那么所谓的 ABA 问题就是写请求在缓存中的更新结果被读请求覆盖,造成缓存和数据库的不一致。如果不考虑缓存自动过期,那么这个不一致的时间需要等到下一次写请求才能够更新缓存。

为什么说 ABA 是线程安全问题的其中一种描述,假设上图中,线程 A 的 CAS 操作在线程 B 的 CAS 操作之前,那么操作失败的就是线程 B 的 CAS 操作,同样线程 A 影响到线程 B,那这种模式可以描述为 ABAB,那 ABAB 可以看做是 ABA 的一种子模式嘛(ABA*),综上,个人认为 ABA 问题(狭义) = 线程安全问题(广义)。

CAS 的特性

从上面的流程图中可以看出,CAS 机制应该包括:compareAndSet 原子操作、自旋。自旋本质上就是重试,可以类比消息队列的消息重发,TCP 的报文重发等。

CAS 这种实现并发的机制,并没有像 synchronized、lock 等锁机制来真正避免线程安全问题,而是通过重试机制来逃避线程安全问题。系统出了问题就卸载重启呗,费那么大劲debug干啥,没错,这就是 CAS 的理念,所以说逃避问题也是解决问题的一种有效方式。

至此,就 CAS 本身的理念而言,不存在线程安全问题,只要按照 CAS 的要求来,完美架构怎么说完美。但是,世界不是完美的,至少计算机的世界不是完美的。问题出现在 compare 上,或者说出现在 get 上。 如果 get 从主内存拷贝到线程的工作内存不是浅拷贝,又或者 compare 可以向 equals() 方法一样进行重载,而不是和 == 一样只能比较地址,那 CAS 也就不存在问题了。

假设 Person value = new Person("root", 18);,那么 value 作为 Person 对象的引用,实际上保存的只是一个地址,而修改对象中的字段属性并不会造成地址发生改变,即 value 的值没有变化。因此线程 B 将 value.name = "admin"; 并不会去更新主内存中的 value,更不会让线程 A 中的 value 缓存失效。在大多数情况下,这可能会造成业务错误,因为在这种情况下,线程 A 和线程 B 都成功执行了 compareAndSet 操作,可能会从逃避错误变成隐藏错误。(一般正常情况下,是线程 B 成功,线程 A 重做)

这种可能隐藏错误的情况,和那个线程 B 将值从 5 变到 6 再变回 5 的案例本质上都是在论述一件事:线程 B 对 value 对象属性的修改没有被线程 A 的 CAS 操作发现。但这称之为错误,个人觉得不合适,因为如果 value 是基本数据类型,那这种特性就是最终一致性(高效的体现),也是 Redis 中 AOF 重写的理念,最后再次批斗一下那个 5/6/5 的案例。
在这里插入图片描述

处理方案

虽然上面嘴硬说那不是错误,那是特性,但是大多数情况下,这个"特性"还是要处理的。

解决办法: 添加一个自增的 int 类型数据(version)作为版本号,在判断的时候不仅判断 value 是否相等,还判断 version 是否相等。

疑问:是否可以通过只判断 version 呢?感觉一般情况下也 ok 啊,不会有让 int 越界然后重复的的并发量吧,有待进一步理解。

相似机制: HashMap、ArrayList 等通过 modCount 来禁止迭代时进行修改,虽然是记录修改次数,但将每一次修改就作为一个新版本来看待的话,也是一样的。

源码简析

public class AtomicInteger{
	private volatile int value;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
        	// 通过Unsafe + 反射获取到value字段的偏移地址,所以valueOffset就是value在主内存中的地址,因此可以通知更新?
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    
    // compareAndSwap是原子指令,但CAS代表一套机制,并不是一个指令。而getAndSet及其源代码更能体现整个过程
	public final int getAndSet(int newValue) {
        return unsafe.getAndSetInt(this, valueOffset, newValue);
    }
}
public final class Unsafe {
    public final int getAndSetInt(Object o, long offset, int newValue) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!compareAndSwapInt(o, offset, v, newValue));
        return v;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值