一、ABA问题
CAS原子操作虽然好,但是出生就自带ABA问题,那么什么是ABA问题?CAS的全称叫做Compare and swap,也就是比较和交换,ABA问题就出现在"Compare"比较阶段。
举个例子,X变量的值是10,现在线程A要对X变量执行CAS(10,20)操作,但是在执行前遭遇了线程切换,线程B对X变量先执行了CAS(10,30),将X的值变成了30,接着继续执行了CAS(30,10),又将X的值变成了10,这时候才切换到到线程A执行CAS(10,20),线程A不知道在它对X执行CAS操作之前,X变量已经经历了10->30->10的变化,所以最终执行CAS操作成功了。
CAS比较的时候只比较值是否相同,X变量在线程A CAS之前出现了从10->30->10的变化而线程A不知道,而且最终还CAS成功了,就是ABA问题。
那线程A执行成功了不好吗,会带来什么影响吗?
这让我想起一则有趣的新闻:一小区业主每晚开车回家后都不锁车,有个青年等到夜深人静的时候就悄悄把车开出去带妹子兜风蹦迪,完了之后还不忘加上油再把车偷偷还回来,就这样反复过了很多天车主都没发现。
这个新闻里,车虽然加上了油,也完好无损的还回来了,青年不打招呼就借车的行为就可以被允许了吗?再想想我们银行里存的钱,如果银行工作人员悄悄把钱取出10万用了,再在我们去银行查账之前把10万块钱补上,看似我们的钱没少,但这种行为是可以的吗?同样的,X变量的值虽然都是10,它变成30之前的10和30之后的10已经不一样了,正常来说线程A的CAS应当失败才对。
二、ABA问题的解决方案
很多乐观锁的实现版本都是使用版本号(Version)方式来解决ABA问题。乐观锁每次在执行数据的修改操作时都会带上一个版本号,版本号和数据的版本号一致就可以执行修改操作并对版本号执行加1操作,否则执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加,不会减少。
参考版本号的解决方式,在JAVA原子类中,有两个类涉及到ABA问题
类名 | 类功能 |
---|---|
AtomicMarkableReference | 带修改标志的引用原子类,该类能减小ABA发生的概率,但是并不能解决ABA问题 |
AtomicStampedReference | 带印戳的引用原子类,该类能解决ABA问题 |
1、AtomicMarkableReference
该类在我感觉是非常有问题的一个类,相对于AtomicReference类,它不仅没解决ABA问题,还引入了额外的使用成本,实在是非常失败的一个类。接下来演示使用它未未解决ABA问题的案例
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicMarkableReference;
import java.util.concurrent.locks.LockSupport;
/**
* @author kdyzm
* @date 2024/10/16
*/
@Slf4j
public class AtomicMarkableReferenceTest {
public static void main(String[] args) throws InterruptedException {
//初始值
User user = new User("张三");
AtomicMarkableReference<User> reference = new AtomicMarkableReference<>(user, true);
//线程A
Thread threadA = new Thread(() -> {
User lisi = new User("李四");
User reference1 = reference.getReference();
boolean marked = reference.isMarked();
log.info("读取到原始值:{},{}", reference1, marked);
//模拟线程切换,等待一秒钟
LockSupport.parkUntil(System.currentTimeMillis() + 1000);
boolean b = reference.compareAndSet(
reference1,
lisi,
marked,
!marked);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());
}, "threadA");
//线程B
Thread threadB = new Thread(() -> {
User wangwu = new User("王五");
User reference1 = reference.getReference();
boolean marked = reference.isMarked();
log.info("读取到原始值:{},{}", reference1, marked);
//修改为王五
boolean b = reference.compareAndSet(
reference1,
wangwu,
marked,
!marked);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());
//修改回张三
reference1 = reference.getReference();
marked = reference.isMarked();
log.info("读取到原始值:{},{}", reference1, marked);
//修改回张三
b = reference.compareAndSet(
reference1,
user,
marked,
!marked);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.isMarked());
}, "threadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
@Data
@AllArgsConstructor
static class User {
private String userName;
}
}
输出结果:
线程A想把初始值张三改成李四,结果发生了线程切换;线程B把张三改成了王五,然后又改回了张三,这时候线程切换回线程A,线程A没意识到此“张三“已经不是之前那个张三了,就成功的把张三改成了李四,ABA问题就发生了。
来研究下在上面这个案例中为什么AtomicMarkableReference没解决ABA问题:
第一点,重复使用了“张三”这个User对象,这个是最重要的原因,重复使用了这个对象,那对象指针肯定是同一个,这导致了AtomicMarkableReference误判。
第二点,AtomicMarkableReference使用了布尔类型的变量来标记对象是否修改过,布尔类型只有true和false两种值,每次修改数据都使用了!取反值,加上第一点使用了重复的对象,正好创造了初始值没有被改造过的假象。
2、ABA问题的解决方案
通过上面AtomicMarkableReference的例子,可以总结出两点ABA问题的解决方案
1、每次都new一个新对象。如果每次都创建新对象,CAS 操作实际上是在比较和交换引用的内存地址,而不是引用指向的对象的具体值。因此,每次创建新对象都会生成一个新的内存地址,即使对象的内容与之前的对象相同,CAS 操作也会认为这是一个新的引用,这样都不需要AtomicMarkableReference,直接使用AtomicReference也不会出现ABA问题。
2、如果就是想复用已存在的对象,那标记是否修改过是必须的,标记必须是单向状态增长且不可逆的。 AtomicMarkableReference之所以没解决ABA问题,就是因为它的"标记"类型是布尔类型的,只有两种值true或者false,来回切换的状态标记实际上就发生了状态的”回退“,一旦和复用的对象组合,就很容易被AtomicMarkableReference认为”没修改过“。
接下来看看真正能解决ABA问题的AtomicStampedReference类。
3、AtomicStampedReference
该类被称为“带印戳的引用原子类”和“带修改标志的引用原子类”的AtomicStampedReference相比,它的标记是int类型的,所以可以通过正向自增实现标记不可逆。接下来演示AtomicStampedReference解决ABA问题的案例,和之前AtomicMarkableReference的例子一样,只是案例中的AtomicMarkableReference都被换成了AtomicStampedReference
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicStampedReference;
import java.util.concurrent.locks.LockSupport;
/**
* @author kdyzm
* @date 2024/10/16
*/
@Slf4j
public class AtomicStampReferenceTest {
public static void main(String[] args) throws InterruptedException {
//初始值
User user = new User("张三");
AtomicStampedReference<User> reference = new AtomicStampedReference<>(user, 1);
//线程A
Thread threadA = new Thread(() -> {
User lisi = new User("李四");
User reference1 = reference.getReference();
int stamp = reference.getStamp();
log.info("读取到原始值:{},{}", reference1, stamp);
//模拟线程切换,等待一秒钟
LockSupport.parkUntil(System.currentTimeMillis() + 1000);
boolean b = reference.compareAndSet(
reference1,
lisi,
stamp,
stamp + 1);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());
}, "threadA");
//线程B
Thread threadB = new Thread(() -> {
User wangwu = new User("王五");
User reference1 = reference.getReference();
int stamp = reference.getStamp();
log.info("读取到原始值:{},{}", reference1, stamp);
//修改为王五
boolean b = reference.compareAndSet(
reference1,
wangwu,
stamp,
stamp + 1);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());
//修改回张三
reference1 = reference.getReference();
stamp = reference.getStamp();
log.info("读取到原始值:{},{}", reference1, stamp);
//修改回张三
b = reference.compareAndSet(
reference1,
user,
stamp,
stamp + 1);
log.info("修改结果:{},读取到修改后的值:{},{}", b, reference.getReference(), reference.getStamp());
}, "threadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
@Data
@AllArgsConstructor
static class User {
private String userName;
}
}
输出结果
可以看到就算线程B就算重复使用了对象改回初始值,线程A也会发现对象被修改过了从而修改失败,因为这里标记使用了int类型而且每次修改都自增1,是单向状态增长且不可逆的。
三、AtomicStampedReference源码解析
看下AtomicStampedReference类主要内容
public class AtomicStampedReference<V> {
/**
* 静态内部类,包装了实际参数reference和印戳stamp
*/
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
//创建Pair对象
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
/**
* 打包了实际对象和印戳的对象,cas操作的真正对象
*/
private volatile Pair<V> pair;
/**
* 构造函数,直接调用Pair类of静态方法创建Pair对象
*/
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
/**
* @param expectedReference 期望值
* @param newReference 待修改值
* @param expectedStamp 期望印戳值
* @param newStamp 待修改印戳值
* @return CAS操作结果
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
//旧值比对
expectedReference == current.reference
//印戳比对
&&expectedStamp == current.stamp
&&(
//重复修改不再执行CAS,此处仅为提高效率的优化判断
(newReference == current.reference && newStamp == current.stamp)
||
//执行CAS操作
casPair(current, Pair.of(newReference, newStamp))
);
}
/**
* @param cmp 期望值
* @param val 修改值
* @return CAS操作结果
*/
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
}
AtomicStampedReference类比较简单,大体结构和其他原子类没有太大的区别。最大的区别就是它维护了一个静态内部类Pair用于存储两个值:实际值和印戳。之后每次传值过来它都会使用Pair.of方法创建新的Pari对象作为CAS的操作对象,这样只要CAS成功,存储的必然是新的Pari对象。判定是否修改过的标志是同时比较Pair对象中的值和印戳。
END.
注意:本文归作者所有,未经作者允许,不得转载