AtomicStampedReference

AtomicStampedReference 本身不难理解,稍微难理解的是它要解决的 ABA 问题。

ABA 问题

ABA 问题的核心是:CAS 操作在 compare 阶段,只会比较目标的部分信息,例如只比较内存地址是否相等而非对象的所有字段相等,于是 CAS 时,无法确定目标是最初看到的那个,还是被其它替换过的。

在非 GC 的语言中(如 C/C++)中实现 lock-free 算法时容易遇到这个问题,因为内存被释放时可能还有变量存放该内存的引用,而新的对象可能重用了该内存地址,造成地址相同内容不同的情形1

考虑用链表实现一个栈:

ABA Problem Example

上图的问题在于内存地址 A 被重复利用,分配给新的对象了。在线程 1 发出 CAS 指令但还未执行的过程中,线程 2 做了两次 pop,一次 push,最终将 head 的值设置成了A(4),于是线程 1 的 CAS 判断内存地址还是 A,指令执行成功,却把线程 2 push 的元素给 pop 了。

要注意的是这个行为是不是 "bug" 取决于业务的需求,如果业务上单纯是想 pop,那么逻辑正确,如果业务上线程 1 是想 pop A(1),显然就是错的。在实现 lock-free 算法时通常这个行为是错的。

另外注意 ABA 问题并不是“原子性”引起的。CAS 的 compare-and-swap 操作依旧是原子的,只是 compare 的结果不符合上层业务的预期。

解决 ABA 问题的想法也很简单,在 compare 阶段使用更全面的信息做判断,能区分两个 A 即可。常见的手段是增加一个额外的字段,记录修改的版本号,也是 AtomicStampedReference 的实现方式。

AtomicStampedReference 成员变量

代码如下:可以看到定义了 Pair 类将原本的 reference 和新增的 stamp 包装起来。后面会看到在 CAS 时会用 Pair 作为整体用于判断。

public class AtomicStampedReference<V> {

    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;
    // ...
}

另一个类 AtomicMarkableReference 实现几乎相同,只是相比于使用 int 型的stamp,它使用了 boolean 型的 mark

CompareAndSet

可以看到 CAS 需要同时判断更新 referencestamp 两个元素:

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)));
}

只有当 expectedReference == current.reference && expectedStamp == current.stamp 成立时,我们才认为旧值相同,需要执行 CAS。在执行 CAS 前会先判断新值是否相同,如果相同已经符合预期,没必要执行 CAS。

在执行 CAS 时,受限于 CAS 操作的粒度,并无法同时判断两个变量,依旧只能判断指针是否相同。但此时判断的是 pair 的指针,而 CAS 前通过其它手段确认 pair 的旧值与用户的预期(入参)相同。

当然,这个方法是“君子协议”,如果大家每次 CAS 都将版本号加一,自然没问题,但如果有线程刻意去回退版本号,则该方法也无法处理。

亮点:包装类实现原子性

我们知道多个变量的原子操作组合后就不再是原子的(TOCTOU 问题)。但是用包装类将多个两个变量组装,提供组装类的原子操作(相当于一个变量),则可以实现整体的原子性。

例如下面的代码,业务上希望 lower <= upper,虽然 lowerupper 的获取与赋值都是原子的,但整体的约束可能被打破。

public class NumberRangeService {
  private AtomicInteger lower;
  private AtomicInteger upper;

  public int getLower() { return lower.get(); }
  public int getUpper() { return upper.get(); }
  public void setLower(int lower) { this.lower.set(lower); }
  public void setUpper(int upper) { this.upper.set(upper); }
}

可以使用包装类,将约束包装起来,用以实现线程安全,例如:

public class NumberRange {
  private int lower;
  private int upper;

  public NumberRange(int lower, int upper) {
    if (lower > upper) { throw new IllegalArgumentException("lower should be <= upper"); }
    this.lower = lower;
    this.upper = upper;
  }
  public int getLower() { return this.lower; }
  public int getUpper() { return this.upper; }
}

public class NumberRangeService {
  private AtomicReference<NumberRange> range = new AtomicReference<>(new NumberRange(0, 0));

  public NumberRange getRange() {
    NumberRange range = this.range.get();
    return new NumberRange(range.lower, range.upper);
  }

  public void setRange(NumberRange range) {
    this.range.set(range);
  }
}

1

在 Java 中不会发生内存地址重用的问题,因为如果存在对象的引用,则对象的内存不会被释放,也不可能被重复利用。