承接的问题
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) |
|---|---|
字段必须是 volatile | IllegalArgumentException: 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 里,这个名字的语义已经变过一轮,用之前必须看清版本。
| 方法 | 内存效果 | 状态 |
|---|---|---|
weakCompareAndSet | plain(普通读写) | @Deprecated(since="9") |
weakCompareAndSetPlain | plain | 可用 |
weakCompareAndSetAcquire | acquire | 可用 |
weakCompareAndSetRelease | release | 可用 |
weakCompareAndSetVolatile | volatile | 可用 |
compareAndSet | volatile | 推荐 |
本机用反射确认:
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 与高竞争累加
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。