跳到主要内容
约 6 分钟2 张卡

Java 原子类

Java 原子类用于维护可以独立更新的状态。原子读改写指读取旧值、计算新值和写回作为一次不可分割的操作生效。本文以 Java 17/21 为准,先解释单个变量的更新与竞争,再比较 AtomicLong 和 LongAdder。最后说明多字段状态与统计窗口的处理边界。

1. 面试核心​

先记住这几条,再读正文
  • 原子递增。 AtomicLong.incrementAndGet() 原子递增并返回新值。
  • 条件更新。 余额检查与扣减必须组成一次条件更新,用 CAS 循环或同一把锁保护。单独把读取和递减换成原子方法,仍会留下检查后状态变化的窗口。
  • 分散竞争。 LongAdder 将热点更新分散到 base 和多个 Cell,以更多内存和求和成本换取较低的写入竞争。卡 · 单变量的竞争成本
  • 统计快照边界。 并发更新期间,LongAdder.sum() 分别读取各个分量,返回值不是同一时刻的原子快照。
  • 计数窗口。 sumThenReset() 适合在无并发更新时读取并清零。卡 · 统计窗口与清零

2. 原子类与 CAS 循环​

多个线程共享一个序列号时,每次递增都需要完整生效,才能避免分配重复值。AtomicLong 提供了这样的原子操作:

private final AtomicLong sequence = new AtomicLong();

long nextId() {
return sequence.incrementAndGet();
}

每次调用都把读取、加一和写回作为一次原子更新,并返回新值。这个方案适合进程内发号,使用时还需处理 long 溢出和进程重启。

原子类也适合维护状态位或独立引用,但多个原子操作连在一起仍有交错空间。比如,下面的库存检查与扣减就是两个操作:

if (stock.get() > 0) {
stock.decrementAndGet();
}

库存为 1 时,两个线程可以同时检查到库存大于零。随后两次扣减各自原子执行,库存仍会变成 -1。因此,检查与扣减应组合为一次条件更新,或者由同一把锁覆盖这两步。

条件更新的实现见 CAS。

3. 单变量的竞争成本​

原子更新解决了正确性问题,但大量线程同时更新一个变量时,写入仍会互相竞争。CPU 按缓存行协调缓存数据,缓存行是一块连续的内存数据。多个核心争夺同一缓存行时,写入权限需要在核心之间转移,所以更新吞吐量会受限制。

使用 CAS 循环时,失败的线程还要重新读取和计算。即使线程没有通过 park 阻塞,CPU 也会花时间在自旋、失败重试和缓存同步上。因此,判断成本要结合更新频率与竞争强度。

AtomicLong 的 API 保证原子读改写,一次方法调用并不等于一次 CAS 指令。原子加法具体使用什么底层指令,取决于 JVM 与处理器。

4. LongAdder 的分段累加​

请求量统计更关心累加吞吐量,读取主要用于观测。LongAdder 会把竞争中的更新分散到多个位置,减少线程对单个变量的争夺:

private final LongAdder requests = new LongAdder();

void recordRequest() {
requests.increment();
}

long observedRequests() {
return requests.sum();
}

OpenJDK 21 的实现使用基础值 base 和 Cell 数组。Cell 是保存部分累加值的单元。初始低竞争时,更新可以直接落到 base。发生竞争后,再创建或扩展 Cell 数组,让线程分散更新不同单元。

调用 sum() 时,读取线程把 base 和各个 Cell 的值加起来。这样能减少写入时的单点竞争,但需要更多内存,读取成本也随 Cell 数量增加。示例中的 observedRequests() 返回统计值,适合监控请求量。

5. sum() 的读取语义​

分散写入以后,读取方需要明确求和结果能保证什么。sum() 依次读取各分量时,其他线程仍在更新。各分量的读取时刻不同,所以求和结果不是同一时刻的整体原子快照。

所有更新停止且对读取线程可见后,求和结果是精确的。并发写入期间,它适合指标、频次等统计,但以下一致性要求需要其他方案:

  • 生成唯一序列号,需要一次原子递增并返回新值。
  • 根据精确计数触发一次状态转换,需要把判断和更新组合起来。
  • 让计数与另一个字段组成一致性快照,需要一起读取或替换这些字段。
  • 精确切分结算周期,需要给计数和清零确定共同的分界点。

单变量读改写可用 AtomicLong,多字段状态可用锁或不可变对象加原子引用。也可以把事件交给一个线程统一更新,让这个线程独占状态。

6. AtomicReference 与不可变状态​

库存数量与版本需要一起变化时,可以把它们放进不可变对象,再原子替换整个引用。下面使用 Java 17/21 支持的 record 定义状态:

record State(int available, long version) {}

AtomicReference<State> state =
new AtomicReference<>(new State(10, 0));

示例先创建可用数量为 10、版本为 0 的状态。更新时构造新的 State,再用 CAS 替换整个引用,两个字段就会作为一组生效。

读取方应保留一次 get() 的结果,再访问这个对象的各个字段。这样读到的是同一版本。若每读一个字段就重新调用 get(),两次读取之间发生更新,就会混用不同版本。

这种方案需要评估新对象的分配成本,以及更新重试和 ABA 问题。状态转换复杂时,用锁覆盖多步操作更容易检查正确性。

更新函数的重试约束见 CAS。

7. 统计窗口与清零​

按时间段统计时,读取旧计数与开始新计数之间需要有清楚的边界。LongAdder.reset() 应在没有并发更新时使用。sumThenReset() 也要逐个处理分量,整个操作缺少统一的原子分界点。写线程与清零交错时,一次递增应属于哪个窗口就难以精确确定。

单计数器需要原子切换窗口时,可用 AtomicLong.getAndSet(0)。它在同一次原子操作中返回旧值并写入零,所以并发递增会按交换点落入前后窗口。多个计数器要组成同一时刻的快照时,仍需统一加锁或协调更新线程。

8. 常见问题​

8.1 LongAdder 的性能条件​

LongAdder 适合高竞争且累加远多于读取的负载。低竞争时,两者的性能差异可以很小,而 AtomicLong 的空间和读取成本更低。因此,应在目标硬件上按真实线程数与读写比例做 JMH 基准,再结合运行时性能采样判断。

8.2 原子类的同步范围​

9 · 面试题卡片​

9.1 AtomicLong 和 LongAdder 应该怎样选择​

出现公司:蚂蚁集团、美团

考察重点

  • 解释单变量原子更新为何会产生竞争,以及分散更新怎样降低竞争。
  • 区分原子读取的当前值与并发求和的统计值。
  • 根据竞争强度、读取频率和内存成本选择计数方案。

相关内容:单变量的竞争成本至sum() 的读取语义。

参考回答

需要精确发号或基于旧值执行条件更新时,选择 AtomicLong。

很多线程高频累加、读取主要用于统计时,可以选择 LongAdder。

AtomicLong 的更新集中在一个变量上,高竞争时会争夺同一缓存行。LongAdder 把更新分散到多个单元,减少写入竞争,代价是更多空间和求和开销。

sum() 在并发写入时缺少整体原子快照,所以选择时还要看业务能否接受这种读取语义。满足语义后,再按真实线程数和读写比例做 JMH 基准,并结合运行时性能采样判断。

9.2 怎样精确切分并发计数窗口,sumThenReset 适合吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释分量求和与清零为何缺少统一分界点,以及原子交换怎样划分前后窗口。

相关内容:统计窗口与清零。

参考回答

单计数器可用 AtomicLong.getAndSet(0) 精确切分,sumThenReset() 适合没有并发更新的时段。

原子交换同时返回旧值并写入零,所以每次递增都落在交换之前或之后。LongAdder 则逐个处理分量,写入会与各分量的读取、清零交错,因此缺少统一的窗口边界。多个计数器要同步切换时,还需要额外协调。

10. 参考资料​