《Java 并发编程·线程与内存模型》8.2 原子类家族与字段更新器

把 java.util.concurrent.atomic 拆成基本、引用、数组、字段更新器四族:给出 JDK 25 的 getAndUpdate 源码与返回值语义、字段更新器省掉一个对象的代价与四条硬约束、实测的四种构造失败消息,并厘清 weakCompareAndSet 在 JDK 25 已被弃用为 plain 语义这件事。

承接的问题

8.1 只用了 AtomicReference 一个类,但它解决的问题很具体:把「读-改-写」变成一个原子操作。真实代码里的需求要碎得多:

  • 有时候要原子的 int、long、boolean;
  • 有时候要原子的对象引用,而且对象是不可变记录;
  • 有时候要一组计数器,按下标访问;
  • 有时候字段已经存在于别人的类里,不能改成 AtomicLong;
  • 有时候只关心内存开销,不愿意为每个计数器多分配一个对象。

java.util.concurrent.atomic 用四个族覆盖这些需求。这一节按「用什么、为什么、什么时候不能用」的顺序过一遍,重点放在两个容易被忽略的地方:函数式更新的返回值语义和字段更新器的四条硬约束。

家族地图

族代表类典型用途注意
基本类型AtomicInteger、AtomicLong、AtomicBoolean计数、状态位、序号没有 AtomicDouble,浮点用 DoubleAdder 或 AtomicLong 存位
引用类型AtomicReference、AtomicStampedReference、AtomicMarkableReference整体替换不可变对象、带版本戳的 CAS引用相等用 ==,不是 equals
数组类型AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray分片计数、按槽位更新元素级原子,数组长度固定
字段更新器AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater给已有类的 volatile 字段加原子操作字段必须 volatile、非 static、非 final 且对调用者可访问

四个族的 API 形状是一致的:get、set、lazySet、compareAndSet、getAndSet,加上 getAndAdd / addAndGet(数值族)或 getAndUpdate / accumulateAndGet(全部)。

lazySet 值得单独提一句:它是「最终可见」的写,不插 StoreLoad 屏障,代价比 set 小,但读到新值的线程不保证能看到写之前的其它写。用在「这个值晚一点被看到也没关系」的场景,比如统计计数;不要用在发布对象上。

函数式更新:返回值不是你想的那个

getAndUpdate 和 updateAndGet 只差一个词,返回值完全不同。JDK 25 的实现如下(AtomicInteger):

public final int getAndUpdate(IntUnaryOperator updateFunction) {
    int prev = get(), next = 0;
    for (boolean haveNext = false;;) {
        if (!haveNext)
            next = updateFunction.applyAsInt(prev);
        if (weakCompareAndSetVolatile(prev, next))
            return prev;
        haveNext = (prev == (prev = get()));
    }
}

几个细节:

  • getAndUpdate 返回旧值,updateAndGet 返回新值,accumulateAndGet(x, fn) 返回新值,getAndAccumulate(x, fn) 返回旧值。
  • 循环里的 haveNext 是一个小优化:如果 CAS 失败后重读发现值没变(prev 和重读的值相同),就不必重新执行一次 lambda。这是「避免重复副作用」的补丁,不是保证——lambda 仍然可能被执行多次。
  • 内部用的是 weakCompareAndSetVolatile,不是被弃用的 weakCompareAndSet。两者的差别在后面的小节展开。

实测这四个方法的返回值:

CatalogSlot slot = new CatalogSlot();
slot.stock = 5;
int prev = STOCK.getAndUpdate(slot, s -> Math.max(0, s - 2));
int next = STOCK.updateAndGet(slot, s -> s + 1);
int max  = STOCK.accumulateAndGet(slot, 9, Math::max);
int before = STOCK.getAndAccumulate(slot, 7, Math::min);

本机输出:

getAndUpdate prev=5, updateAndGet=4, accumulateAndGet(max 9)=9, getAndAccumulate(min 7)=9

逐步对一遍:初始 5,getAndUpdate 减 2 得到 3、返回旧值 5;updateAndGet 加 1 得到 4、返回 4;accumulateAndGet 取 max(4, 9) 得到 9、返回 9;最后 getAndAccumulate 取 min(9, 7) 把值改成 7,但返回的是旧值 9。最后一步最容易看错——返回 9 不代表操作失败,值确实已经变成 7。

所以千万别用返回值反推当前值。 需要当前值就再读一次 get(),或者用 updateAndGet。

引用类型与数组类型

AtomicReference 在 8.1 已经用过:存不可变记录,用 CAS 整体替换。这里补两个边界。

边界一:compareAndSet 比的是引用,不是 equals。 两个内容相同的 Book 记录,new Book(...) 出来是不同的对象,CAS 会失败。用值相等做条件必须自己写:先 get(),用 equals 判断,再 CAS 那个取到的引用。

边界二:数组族的原子性是元素级的。 AtomicLongArray 保证 incrementAndGet(i) 对第 i 个槽位原子,但两个不同槽位之间没有任何一致性。用它做分片计数是对的,用它维护「数组总和等于某值」这类不变量是错的。

AtomicLongArray buckets = new AtomicLongArray(16);
// 8 个线程各做 20_000 次,按下标散开
buckets.incrementAndGet(((k * 31 + id) & 0x7fff_ffff) & 15);
// 汇总
long total = 0;
for (int i = 0; i < buckets.length(); i++) total += buckets.get(i);

本机输出:

AtomicLongArray: total=160000 (expect 160000)

汇总必须在所有线程 join 之后做。边跑边汇总,得到的是「逐个槽位读过去」的拼合值,不对应任何时刻——和第 8.3 节 LongAdder.sum() 的问题同源。

字段更新器:省掉一个对象

有些类的字段不能改成 AtomicLong:类来自外部依赖、字段被序列化框架按名字读写、或者对象数量太大不想每个都挂一个原子对象。字段更新器在创建时定位字段的内存偏移,之后用它直接 CAS 这个字段,不额外分配对象。

final class CatalogSlot {
    volatile Book book;          // 可被 AtomicReferenceFieldUpdater 更新
    volatile int stock;          // 可被 AtomicIntegerFieldUpdater 更新
    volatile long version;       // 可被 AtomicLongFieldUpdater 更新
    int plainStock;              // 非 volatile,不能做 updater 目标
    volatile String label;       // 类型不匹配,用来触发错误
}

static final AtomicReferenceFieldUpdater<CatalogSlot, Book> BOOK =
        AtomicReferenceFieldUpdater.newUpdater(CatalogSlot.class, Book.class, "book");
static final AtomicIntegerFieldUpdater<CatalogSlot> STOCK =
        AtomicIntegerFieldUpdater.newUpdater(CatalogSlot.class, "stock");

用法和普通的 AtomicReference 一样,只是每个方法多一个「目标对象」参数:

for (;;) {
    Book cur = BOOK.get(slot);
    if (cur.stock() == 0) break;
    Book next = new Book(cur.isbn(), cur.title(), cur.stock() - 1);
    if (BOOK.compareAndSet(slot, cur, next)) break;   // 失败就重读重试
}

8 个线程各扣 10 000 次,初始库存 80 000:

reference updater: stock=0 (expect 0), casRetries=175171
int updater: stock=0 (expect 0)

引用版在这一轮里记到 175 171 次 CAS 失败。原因不在 updater 本身:引用版每次成功都要 new Book(...),构造记录的开销把两次 CAS 之间的间隔拉长,别的线程更容易读到同一个旧值。用 CAS 改不可变对象,成本里有一部分是对象分配,不是 CAS。(整数版没有单独统计失败次数,不能把 175 171 理解成两者的差值。)

字段更新器的四条硬约束,违反任何一条都在 newUpdater 时抛异常:

约束违反时的真实输出(JDK 25)
字段必须是 volatileIllegalArgumentException: Must be volatile type
字段类型必须匹配IllegalArgumentException: Must be integer type
字段必须存在RuntimeException: java.lang.NoSuchFieldException: missing
字段必须对调用者可访问RuntimeException: java.lang.IllegalAccessException: class UpdaterLab cannot access a member of class CatalogSlot with modifiers "private volatile"

第四条最容易被忽略。私有字段的 updater 必须在持有该字段的类内部创建:

final class CatalogSlot {
    private volatile Book privateBook;
    static final AtomicReferenceFieldUpdater<CatalogSlot, Book> OWN_BOOK =
            AtomicReferenceFieldUpdater.newUpdater(CatalogSlot.class, Book.class, "privateBook");
}

AtomicReferenceFieldUpdater 没有 setAccessible 方法,从类外拿不到私有字段的写权限。

还有一条不在文档显眼处:static volatile 字段会被拒绝,异常消息是 null:

static volatile -> IllegalArgumentException: null
final -> IllegalArgumentException: Must be volatile type

final 字段天然不能是 volatile,报错可读;static 字段的报错没有任何信息,排查时只能靠「字段是不是 static」自己核对。

weakCompareAndSet 在 JDK 25 是什么

8.1 提过虚假失败,weakCompareAndSet 就是为它准备的。但在 JDK 25 里,这个名字的语义已经变过一轮,用之前必须看清版本。

方法内存效果状态
weakCompareAndSetplain(普通读写)@Deprecated(since="9")
weakCompareAndSetPlainplain可用
weakCompareAndSetAcquireacquire可用
weakCompareAndSetReleaserelease可用
weakCompareAndSetVolatilevolatile可用
compareAndSetvolatile推荐

本机用反射确认:

weakCompareAndSet deprecated=true, since=9
weakCompareAndSet(0,1)=true
weakCompareAndSetPlain(1,2)=true
weakCompareAndSetVolatile(2,3)=true
value=3

三个调用都成功,看不出差别——差别在内存语义,不在返回值。weakCompareAndSet 之所以被弃用,是因为名字暗示「volatile 强度的弱化版」,实际给的却是 plain 语义:它不建立任何 happens-before 边。用它在两个线程之间传递数据是错的,哪怕 CAS 返回 true。

那什么时候可以用 plain?只有一种情况:这个变量是纯统计量,读方不依赖它去读别的变量。比如「已处理条数」,读方只看这个数本身,不看它保护的内容。只要有一丝「看到这个写,就要看到之前的写」的需求,就必须用 compareAndSet 或 weakCompareAndSetVolatile。

在 libraryd-conc 里落地

8.1 留了一个问题:库存和配额是两个 CAS,中间状态会被看见。这一节用字段更新器把配额做成「每个会员一个槽位、不额外分配原子对象」的形式,并给出补偿式写法。

final class MemberQuota {
    final String memberId;
    volatile int used;
    static final AtomicIntegerFieldUpdater<MemberQuota> USED =
            AtomicIntegerFieldUpdater.newUpdater(MemberQuota.class, "used");
    MemberQuota(String memberId) { this.memberId = memberId; }
}

final class QuotaGuard {
    private static final int QUOTA = 5;
    private final ConcurrentMap<String, MemberQuota> quotas = new ConcurrentHashMap<>();

    /** 占一个配额;返回 null 表示已超额。 */
    MemberQuota acquire(String memberId) {
        MemberQuota quota = quotas.computeIfAbsent(memberId, MemberQuota::new);
        for (;;) {
            int cur = quota.used;
            if (cur >= QUOTA) return null;
            if (MemberQuota.USED.compareAndSet(quota, cur, cur + 1)) return quota;
        }
    }

    void release(MemberQuota quota) {
        MemberQuota.USED.decrementAndGet(quota);
    }
}

AtomicLoanService 只改 reserve,库存扣减复用 8.1 的 tryAdjust:

private final QuotaGuard guard = new QuotaGuard();

@Override
public Reservation reserve(String isbn, String memberId) {
    MemberQuota quota = guard.acquire(memberId);
    if (quota == null) {
        throw new LoanException(ErrorCode.QUOTA_EXCEEDED, memberId);
    }
    if (!tryAdjust(isbn, -1)) {
        guard.release(quota);                      // 补偿:把配额还回去
        throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
    }
    return new Reservation("L-" + isbn + "-" + memberId, isbn, memberId,
            Instant.now().plusSeconds(30L * 24 * 3600));
}

这段代码的重点不是「怎么扣」,而是补偿:配额先扣、库存后扣,库存失败就把配额还回去。MemberQuota 每个会员一个对象,配额用字段更新器直接改 volatile int;如果换成 AtomicInteger,每个会员就多一个对象。这个模式能工作,但它不满足原子性——在「配额已扣、库存未扣」的窗口里,别的线程读到的配额是偏高的。对借阅业务这可以接受(宁可少借一次,不可超借);对账务类业务不能接受,那就要把库存和配额合并进一个 AtomicReference<Permit>,用一次 CAS 同时改。

判断标准:中间状态被别的线程看到,会不会导致不可逆的错误。会,就必须合并成单次 CAS 或加锁;不会,补偿式写法更简单。

常见错误与边界

  • 把 getAndUpdate 的返回值当新值。 它返回旧值。需要新值用 updateAndGet。
  • 在 lambda 里做副作用。 重试会重复执行,日志会打两遍,计数器会多加。
  • 用 weakCompareAndSet 传递数据。 JDK 25 里它是 plain 语义,已被弃用;用 compareAndSet。
  • 对非 volatile 字段建 updater。 构造时直接抛 Must be volatile type,不会延迟到运行期。
  • 跨类给私有字段建 updater。 抛 IllegalAccessException,必须在字段所属类内部创建。
  • 以为 updater 比 AtomicLong 快很多。 省的是一个对象头和一次分配,CAS 本身的成本没变;高竞争下两者都受同一条缓存行限制。反射查找只发生在 newUpdater 时,单次调用不再走反射。
  • 以为 AtomicBoolean 能替代 volatile boolean 的全部用法。 单纯的状态标志用 volatile 更轻;只有需要 check-then-act 原子化时才需要 AtomicBoolean。
  • 用 AtomicLongArray 维护跨元素不变量。 元素级原子不等于数组级一致。
  • 对 AtomicReference 用 equals 思维做 CAS。 它比的是引用,值相等的新对象一定失败。

小结

  • 四族分工:基本类型管计数与状态位,引用类型管不可变对象的整体替换,数组类型管分片,字段更新器管「不能改成原子类的已有字段」。
  • getAndUpdate 返回旧值,updateAndGet 返回新值;lambda 可能被执行多次,必须无副作用。
  • 字段更新器的四条约束是 volatile、类型匹配、字段存在、调用者可访问;私有字段的 updater 只能在类内部创建,static volatile 的报错消息是 null。
  • weakCompareAndSet 在 JDK 25 已被弃用,实际是 plain 语义;需要 volatile 强度就用 compareAndSet 或 weakCompareAndSetVolatile。getAndUpdate 内部用的正是后者。
  • 多个字段的原子性仍然要靠「合并成一个可 CAS 的对象」或加锁;补偿式写法只适用于中间状态可接受的场景。

下一节处理另一个维度:即使每次 CAS 都正确,单变量在高竞争下也会撞上缓存行。LongAdder 用分片把这一堵墙绕开,代价是 sum() 不再是快照。

阅读导航:上一节 8.1 CAS 原理与 ABA | 下一节 8.3 LongAdder 与高竞争累加

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收