之前学习 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 还存在线程安全问题?
首先,需要简单了解 Java 内存模型(JMM),如图所示。
数据保存在主内存中,线程会拷贝一份到自己的工作内存中作为缓存,所以需要使用 violate
关键字来修饰 value 变量保证可见性。可见性具体来说就是,任意一个线程修改其工作内存中的 value 时,会立即写回主内存,同时让其他线程中的缓存数据无效,其他线程如果需要使用 value,需要重新从主内存中读取最新值进行缓存。
其次,需要深度追问一下自己,compareAndSet 中 compare 比较的是哪两个对象? compareAndSet 中 set 赋值的是哪个对象? 后面通过 AtomicInteger 的源码简单看下,这里直接上结论:
最后,为什么会有线程安全问题,问题发生的时间点在哪?
compareAndSet 是 compre(比较)和 set(赋值)是原子性操作,但实际涉及到 get-compare -> set
这三个操作,其中 get 是指从主内存获取数据到线程的工作内存的过程。产生线程安全问题的核心就是 CAS 只保证 compare -> set
这两个过程是原子的,但是 get -> compare
并不是原子的,这个时间段(从主内存读取 value 值到实际执行 CAS 指令)会被其它线程趁虚而入。因此如果发生下面的场景,对于一般的多线程环境而言,便出现线程安全问题。但,这不是一般,这是 CAS!
上面的论断先放一放,先可能带有错误地论述一下个人理解的 ABA 问题和线程安全问题之间的关系。
所谓的 ABA 问题,个人理解成是线程安全问题的其中一种抽象描述,例如我们将上面的 B = B1 + B2,那么可以理解成一个线程的操作插入到另一个线程的两次操作之间,并最终影响到那个线程的执行结果。类似的例子还有:
为什么说 ABA 是线程安全问题的其中一种描述,假设上图中,线程 A 的 CAS 操作在线程 B 的 CAS 操作之前,那么操作失败的就是线程 B 的 CAS 操作,同样线程 A 影响到线程 B,那这种模式可以描述为 ABAB,那 ABAB 可以看做是 ABA 的一种子模式嘛(ABA*),综上,个人认为 ABA 问题(狭义) = 线程安全问题(广义)。
从上面的流程图中可以看出,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;
}
}