-
什么是ABA问题
我们先来看一个多线程的运行场景:
时间点1 :线程1查询值是否为A 时间点2 :线程2查询值是否为A 时间点3 :线程2比较并更新值为B 时间点4 :线程2查询值是否为B 时间点5 :线程2比较并更新值为A 时间点6 :线程1比较并更新值为C
在这个线程执行场景中,2个线程交替执行。线程1在时间点6的时候依然能够正常的进行CAS操作,尽管在时间点2到时间点6期间已经发生一些意想不到的变化, 但是线程1对这些变化却一无所知,因为对线程1来说A的确还在。通常将这类现象称为ABA问题。
-
ABA发生了,但线程不知道
我们再来看一个小例子进一步体会ABA的发生。从而思考该如何解决ABA问题。
/**
* 无法检测到ABA是否发生
* @author
*/
public class DontCheckABADemo {
/**
* 把邮件内容“远方的问候”放到了一个普通信封envelope里
* envelope = 邮件内容
*/
static AtomicReference<String> envelope =
new AtomicReference<String>( "远方的来信" );
/**
* @param args
* @throws InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
// 线程1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
String mailContent = envelope.get();
System.out.println("T1首先看到了信封里的邮件内容[ " +
mailContent + " ](A)。");
try {
// T1被强制sleep一会,好让T2这个时候有机可乘
System.out.println("T1现在有事情暂时的离开了一小会。");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = envelope.compareAndSet(
mailContent, "远方的回信");
if( result ) {
System.out.println(
"\nT1在返回后重新检查了邮件,好像没人动过。" +
"现在可以写回信了[ " + envelope.get() + " ]。");
}
}
});
// 线程2
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
// T2先sleep一会,好让T1有机会先看到信封里面的邮件内容
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
String mailContent = envelope.get();
// T2第一次修改了信封里的邮件内容
boolean firstOpt = envelope.compareAndSet(mailContent, "");
if( firstOpt )
System.out.println(
"\nT2悄悄将信封里的邮件内容[ " + mailContent + " ]取走(B)。" +
"现在邮件内容已经不再信封里了。");
// T2第二次修改了信封里的邮件内容
boolean secondOpt = envelope.compareAndSet("", mailContent);
if( secondOpt )
System.out.println(
"T2悄悄把信封里的邮件内容[ " + envelope.get() + " ]放回(A)。" +
"现在邮件内容好像没被动过一样。");
}
});
t1.start();
t2.start();
}
}
控制台信息
T1首先看到了信封里的邮件内容[ 远方的来信 ](A)。 T1现在有事情暂时的离开了一小会。 T2悄悄将信封里的邮件内容[ 远方的来信 ]取走(B)。现在邮件内容已经不再信封里了。 T2悄悄把信封里的邮件内容[ 远方的来信 ]放回(A)。现在邮件内容好像没被动过一样。 T1在返回后重新检查了邮件,好像没人动过。现在可以写回信了[ 远方的回信 ]。
-
解决ABA问题的初步思路
通过码示例我们思考一下ABA问题的根源是什么,当线程进行compareAndSet操作时是通过比较值的方式来判断能否更改当前的值。 但有些业务场景仅仅依靠比较值是不能满足整个逻辑的正确性的,可能还需要知道这个值是被谁更新了,更新了多少次,更新的时间等等。 基于这些需求我们可以给每个值再关联上一些扩展数据作为CAS操作时额外的比较机制,从而形成一个实际值与若干个标记值的复合原子数据。
-
解决ABA问题的技术细节
我们现在有了初步的解决思路,但还需要考虑一些实现上的细节。CAS操作由原先仅仅对一个值的比较,现在变成了对多个值的比较(实际值和一些标记值),而在多线程环境中同时操作多个值往往会比操作一个值更加需要小心谨慎,如果不能以原子的方式完成多个值的操作,在多线程环境中将会出现比ABA更加严重且意想不到问题。
-
ABA问题与Java并发包
现在有了初步的解决思路和需要注意的技术细节,我们是否要开始编码实现一个更加健壮的代码来发现线程执行过程中的ABA问题。在动手之前不妨先看一看Java并发包,Java并发包提供两个原子类型:AtomicStampedReference和AtomicMarkableReference。这两个类型提供了解决ABA问题的机制,并且它们的解决办法与我们所思考的方式是完全一致的,如代码片段1所示。
代码片段1
public final boolean compareAndSet(V expect, V update) {
return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}
AtomicStampedReference的compareAndSet操作不仅需要检查值(reference)是否发生了改变,同时还要检查与值相关联的标记值(stamp)是否也发生了改变。开发者可以根据具体的业务的需求,在每次通过compareAndSet修改AtomicStampedReference内容(值与标记)的时候,既要比较reference,还要比较stamp,这个整数标记通过具体的业务需求策略生成。现在在每次CAS操作的时候reference都会与一个整数标记对应,即使在引用没有被修改的时候,也依然知道引用可能被访问过。标记就好像是引用的一个访问操作记号。AtomicStampedReference的实现如代码片段2所示。
代码片段2
public boolean compareAndSet(V expectedReference,V newReference,
int expectedStamp,int newStamp) {
// pair复制到局部变量current,current在当前方法中是线程安全的
Pair<V> current = pair;
return
expectedReference == current.reference && // 当前引用与期待的引用相同
expectedStamp == current.stamp && // 并且当前标记与期待的标记相同
((newReference == current.reference && // 并且新引用与当前引用相同
newStamp == current.stamp) || // 并且新标记与当前标记相同则无需更新
// Pair.of方法将newReference和newStamp构建出一个Pair对象,同时更新
casPair(current, Pair.of(newReference, newStamp))); // 否则更新内容
}
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
-
ABA发生了,线程检测到了
现在我们可以改写之前的代码,检测ABA的发生:
/**
* 可以检测到ABA是否发生了
* @author
*/
public final class CheckABADemo {
/**
* 把邮件内容“远方的问候”放到了一个智能的能够记录操作标记的信封envelope中
* envelope = 邮件内容 + 操作标记
*/
static AtomicStampedReference<String> envelope =
new AtomicStampedReference<String>( "远方的来信", 0 );
/**
* @param args
* @throws InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
// 线程1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
String mailContent = envelope.getReference();
int mailStamp = envelope.getStamp();
System.out.println(
"T1首先看到了信封里的邮件内容[ " + mailContent + " ](A)。" +
"信封上还有一个操作标记:" + mailStamp);
try {
// T1实际上被强制sleep一会,好让T2这个时候有机可乘
System.out.println("T1现在有事情暂时的离开了");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// T1尝试给‘远方的来信’回信
boolean result = envelope.compareAndSet(
mailContent, "远方的回信", mailStamp, ++mailStamp);
if( result ) {
System.out.println(
"\nT1在返回后检查邮件内容和信封上面的操作标记。" +
"邮件内容还是一样。信封的操作标记也没人动过。" +
"现在可以写回信了[ " + envelope.getReference() + "]");
} else {
System.out.println(
"\nT1在返回后检查邮件内容和信封上面的操作标记。" +
"邮件内容还是一样,但信封的操作标记被动过了。\n" +
"T1:信件被别人偷看了,我该做点什么好呢。\n" +
"或者\n " +
"T1:信件被别人偷看了,这没什么大不了的。" );
}
}
});
// 线程2
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
// T2先sleep一会,好让T1有机会先看到信封里面的邮件内容
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int[] mailStamp = new int[1];
String mailContent = envelope.get(mailStamp);
// T2第一次修改了信封里的邮件内容和信封上的操作标记
boolean firstOpt = envelope.compareAndSet(
mailContent, "", mailStamp[0], ++mailStamp[0]);
if( firstOpt )
System.out.println(
"\nT2悄悄将信封里的邮件内容[ " + mailContent + " ]取走(B)。" +
"现在邮件内容已经不在信封里了。" +
"信封上的操作标记首次被改动了:" + envelope.getStamp());
// T2第二次修改了信封里的邮件内容和信封上的操作标记
boolean secondOpt = envelope.compareAndSet(
"", mailContent, mailStamp[0], ++mailStamp[0]);
if( secondOpt )
System.out.println(
"T2悄悄把信封里的邮件内容[ " +
envelope.getReference() + " ]放回(A)。" +
"现在邮件内容好像没被动过一样。" +
"信封上的操作标记再次被改动了:" + envelope.getStamp());
}
});
t1.start();
t2.start();
}
}
控制台信息
T1首先看到了信封里的邮件内容[ 远方的来信 ](A)。信封上还有一个操作标记:0 T1现在有事情暂时的离开了 T2悄悄将信封里的邮件内容[ 远方的来信 ]取走(B)。现在邮件内容已经不在信封里了。信封上的操作标记首次被改动了:1 T2悄悄把信封里的邮件内容[ 远方的来信 ]放回(A)。现在邮件内容好像没被动过一样。信封上的操作标记再次被改动了:2 T1在返回后检查邮件内容和信封上面的操作标记。邮件内容还是一样,但信封的操作标记被动过了。 T1:信件被别人偷看了,我该做点什么好呢。 或者 T1:信件被别人偷看了,这没什么大不了的。
-
ABA问题小结
ABA并非是一个错误。而是多个线程在交替执行过程中可能发生的现象,并且这个现象仅仅通过基本的CAS操作是难以察觉的,而是否需要处理这个问题取决与你的业务场景。
根据我们之前提及的“解决ABA问题的初步思路”以及JDK的并发包中的AtomicStampedReference和AtomicMarkableReference 类型的实现代码,我们还可以扩展出适合不同业务场景, 创建解决ABA问题的新的原子类型。比如以线程id作为标记,以当前时间作为标记等等。现在你是否觉得有了更多的思路。