Java-并发-CAS
0x01 摘要
本文主要讲讲AQS
(AbstractQueuedSynchronizer)中大量使用的CAS
,以及著名的ABA
问题。
0x02 CAS基本概念
乐观锁在Java中的一个重要实现就是CAS
,全称为 Compare and Swap
,就是在内存级别比较和原子性地替换值。
在Java里,是用的sun.misc.Unsafe
类来实现了很多相关的native
修饰的CAS
方法。最常用的应该就是compareAndSwapInt
方法。
可以看看ReentrantLock
里的lock()
方法:
public void lock() {
// 使用同步锁进行锁定
sync.lock();
}
接着看看默认的非公平同步锁的lock
方法的实现:
final void lock() {
// 这一步其实就是期望的值是0,如果确实是0就把它更新为1
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
这里就是调用的AQS里的compareAndSetState
方法
// 用来表示同步锁状态的变量
private volatile int state;
// 上述状态变量在AbstractQueuedSynchronizer类中的域偏移值
private static final long stateOffset;
stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
// 在当前state变量值等于excpect时,就原子性的设置state变量为给定的update值
protected final boolean compareAndSetState(int expect, int update) {
// 尝试用CAS的方法将当前AQS实例state变量设为1
// 如果成功就返回true
// 返回false意味着该变量的当前值不为expect
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
0x03 ABA问题
3.1 什么是ABA问题
CAS看起来很方便,好像啥都不用我们操心,但有可能会导致著名的ABA
问题。
我们仔细考虑,CAS操作其实分为两步:
- 获取某时刻该变量在offset的值
- 比较并替换该值
也就是说,1和2不是一个原子性操作,那么就有可能发生获取的时候是期望值,但在比较和替换时其实该值已经被其他线程修改了的情况,导致有一次CAS操作的结果被覆盖。这就是ABA问题。
具体来说,这个ABA过程如下:
- 线程1做CAS(X, A, C),获取到该变量的X值为A
- 线程2做CAS(X, A, B),获取到该变量X的值为A符合预期
- 线程2将X值设为B
- 线程2做CAS(X, B, A),获取到该变量X的值为B符合预期
- 线程2又将X值设为A
- 线程1发现X值为A符合CAS传入的预期值,于是将X的值设为C
- 结果就是线程1和2都认为此次CAS操作成功,但其实里面有个中间变化线程2根本就不知道,这并不符合我们的预期。可能会导致意外的严重后果。
对于使用CAS操作的原子类,例如AtomicInteger,均存在ABA问题,所以我们通常会对变量增加版本号来解决问题,JDK中的AtomicStampedReference也帮我们实现了这个功能,不过这里需要注意存在一个坑。
3.2 ABA问题示例
private static AtomicInteger atomicInt = new AtomicInteger(100);
public static void main(String[] args) throws InterruptedException {
Thread intT1 = new Thread(new Runnable() {
@Override
public void run() {
atomicInt.compareAndSet(100, 101);
atomicInt.compareAndSet(101, 100);
}
});
Thread intT2 = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean c3 = atomicInt.compareAndSet(100, 101);
//true
System.out.println(c3);
}
});
intT1.start();
intT2.start();
}
最后会发现线程二输出的cas结果为true,也就是说他没有感知到值变量atomicInt
被被修改后又被重置为100
。
3.3 AtomicStampedReference原理
3.3.1 重要的内部类和构造方法
// AtomicStampedReference的内部类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;
/**
* 使用指定的初值创建一个新的AtomicStampedReference
*
* @param initialRef 我们关心的对象的初值
* @param initialStamp 版本戳的初值
*/
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
3.3.2 compareAndSet
/**
* 使用期望的目标对象和版本戳来设值
* 1.比较当前目标引用和目标对象引用地址
* 2.然后比较当前版本戳和参数中的期望版本戳是否相同
* 3.比较当前目标引用和新的目标对象引用是否相同且当前版本戳和新的目标版本戳相同,说明已经被其他线程修改
* 4.或是成功以CAS方式修改了<当前目标对象, 当前版本戳>为新的<新目标对象, 新版本戳>
* 5. 3和4满足任意条件就返回true
*
* @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
*/
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)));
}
3.3.3 casPair
// 这个方法就是用UNSAFE类来直接CAS方式替换Pair对象
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
3.3.4 小结
其实AtomicStampedReference
就是在普通CAS基础上加上了个版本戳,和对象形成了Pair,可以避免ABA问题。
3.4 AtomicStampedReference解决ABA问题示例
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<Integer>(100, 0);
public static void main(String[] args) throws InterruptedException {
Thread refT1 = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedRef.compareAndSet(100, 101,
atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
atomicStampedRef.compareAndSet(101, 100,
atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
}
});
Thread refT2 = new Thread(new Runnable() {
@Override
public void run() {
int stamp = atomicStampedRef.getStamp();
System.out.println("before sleep : stamp = " + stamp); // stamp = 0
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after sleep : stamp = " + atomicStampedRef.getStamp());//stamp = 1
boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp+1);
//false
System.out.println(c3);
}
});
refT1.start();
refT2.start();
}
最终会发现线程2输出的CAS结果为false,因为与atomicStampedRef关联的stamp因为线程1的操作导致已经发生了变化。
0x04 原理
透过前面的代码,可以看到compareAndSwapInt
是一个JNI
调用。
在jdk8/hotspot/src/share/vm/prims/unsafe.cpp
中可以找到以下内容:
{CC"compareAndSwapInt", CC"("OBJ"J""I""I"")Z", FN_PTR(Unsafe_CompareAndSwapInt)},
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
// 获取该filed内存地址
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 调用Atomic.cmpxchg方法
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
这个原子性是现代处理器新增的硬件指令支持的,在IA64、x86指令群中有cmpxchg指令完成的CAS功能。
具体是使用LOCK CMPXCHAG
指令实现。
CMPXCHAG
指令本身不是原子性的,他用于比较并交换操作数,CPU对CAS的原语支持。
所以需要加上LOCK
,由CPU保证被其修饰的指令的原子性,实现原理(详情参见:多处理器下的数据一致性):
-
依赖内存有序模型,来保证读取指令有序;
-
通过总线锁或缓存一致性,保证被修饰指令操作的数据一致性:
- 当访问的数据在系统内存时,通过在总线使用锁实现原子性(保证只有一个CPU能使用);
- 当访问的数据在处理器的缓存时,通过缓存一致性协议实现原子性;
常见的缓存一致性协议有:MESI,MESIF(MESIF是缓存行的状态标识,M:Modified, E: Exclusive, S:Shared, I:Invalid, F: Forwad),通过标记缓存行的状态和处理器间的通讯来实现。
举个LOCK栗子
-
Java的DCL中若返回的变量不加volatile修饰,则可能会由于指令重排导致另一个线程获取到一个非完全初始化的对象。
而当volatile修饰的变量所在的代码段成为热点,被JIT编译为汇编代码后,会增加LOCK前缀来禁止指令重拍和数据一致;
LOCK CMPXCHAG
保证原子性的不同CPU个数场景:
- 单核
无需加LOCK前缀,即使增加也会被替换为nop - 多核
需要加LOCK前缀
鉴于本人能力有限,就不再继续向下了。有兴趣的读者可以研究下jdk8/hotspot/src/share/vm/runtime/atomic.cpp
也可参考文章:
0x05 应用
AtomicInteger的原子自增方法incrementAndGet
底层就用了CAS方法,代码如下:
public final int incrementAndGet() {
// 这里在getAndAddInt就完成了自增并返回了原始值,这里加1就是得到的新值作为结果返回
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
Unsafe.getAndAddInt
如下
// 获取对象var1的偏移量为var2的值,并加上var4,直到成功
// 返回的是原来的值
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取该值
var5 = this.getIntVolatile(var1, var2);
// 如果该值为var5,且成功替换为新值var5+var4就返回,否则一直循环该过程
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
0x06 总结
以上就是对CAS和ABA问题的分析,可以看到java中的解决方法就是将对象和另一个对象关联组成pair,然后通过UNSAFE直接CAS替换该pair对象。与上面提到的AtomicStampedReference
类似的还有AtomicMarkableReference
,只是实现的原理有很小的不同而已。
CAS存在的问题
- ABA
- 不适合写多的场景
竞争大,重试很多,持续自旋,CPU开销大 - 只能保证一个共享变量的原子操作
多个变量操作时无法保证原子性。可以使用AtomicReference
来保证引用对象之间的原子性,所以可把多个变量放在一个对象里来进行CAS操作。