CAS:不加锁,AtomicInteger 怎么把计数加对?

资深 Java 面试到 Agent 开发工程师|第 03 集

上一集里,两个线程都读到 count == 0,各自加一,最后都写回 1。synchronized 的做法是让它们依次执行完整的读、改、写。

如果允许两个线程同时计算,但写回前检查一下"现在的值还是我刚才读到的吗",能不能避免覆盖别人的更新?

写回之前,多一次检查

假设计数器从 0 开始,两个线程都准备把它加到 1。

线程甲先提交:当前值仍然是 0,与它读到的一致,更新成 1。

线程乙接着提交:它期望当前值是 0,但当前值已经变成了 1。这次更新失败,不会把旧计算结果写进去。

线程乙重新读取 1,计算出 2,再尝试提交。如果当前值仍然是 1,就更新成 2。

text 复制代码
初始值:0

线程甲读到 0,算出 1
线程乙读到 0,算出 1

甲提交:期望 0,当前 0 → 成功,变成 1
乙提交:期望 0,当前 1 → 失败,保持 1

乙重新读到 1,算出 2
乙提交:期望 1,当前 1 → 成功,变成 2

这就是 CAS(Compare-And-Set,比较并设置)的基本用法:当前值等于期望值,才写入新值。

比较和写入必须是一个原子操作

Java 的 AtomicInteger 提供了这个方法:

java 复制代码
boolean compareAndSet(int expectedValue, int newValue)

例如:

java 复制代码
AtomicInteger count = new AtomicInteger(0);

boolean first = count.compareAndSet(0, 1);  // true,更新为 1
boolean second = count.compareAndSet(0, 2); // false,仍为 1

compareAndSet 会把"比较当前值"和"条件成立时写入新值"作为一个不可分割的操作。其他线程不能在比较成功以后、写入以前,插进来修改同一个值。

所以不能用下面的代码代替它:

java 复制代码
if (count.get() == expected) {
    count.set(next);
}

get() 和 set() 各自有内存可见性保证,但两次调用之间仍然能穿插其他线程的更新。线程乙可以在甲检查完以后修改计数器,甲随后调用 set(),仍然会覆盖乙的结果。

即使把普通字段声明为 volatile,再写成 if (value == expected) value = next,这个缺口也还在。

在实现上,JVM 会借助处理器提供的原子指令来完成这类更新;具体采用哪些指令,取决于处理器和 JVM。compareAndSet 提供的 Java 契约,是整个比较和条件更新必须原子完成。

CAS 失败一次,不代表加一已经完成

compareAndSet 只尝试一次。失败时它返回 false,不会替你重新读取、重新计算。

要让方法只在成功加一后才返回,需要在外面写重试逻辑:

java 复制代码
class CasCounter {
    private final AtomicInteger count = new AtomicInteger();

    int incrementAndGet() {
        for (;;) {
            int oldValue = count.get();
            int newValue = oldValue + 1;
            if (count.compareAndSet(oldValue, newValue)) {
                return newValue;
            }
        }
    }

    int get() {
        return count.get();
    }
}

每次失败后,循环都会重新读取当前值,并根据这次读取重新计算。例如第一次尝试 compareAndSet(0, 1) 失败,重新读到 1 后,应该尝试 compareAndSet(1, 2)。

如果只把期望值改成 1,却沿用上次算出的新值,调用就会变成 compareAndSet(1, 1)。它可能返回成功,但计数根本没有增加。如果连期望值也不更新,一直尝试 compareAndSet(0, 1),当前值不回到 0 就会一直失败。

这里没有保证每个线程都能很快成功。某个线程可能连续输给其他线程,反复重试。这段代码也没有给出超时或重试次数上限。

成功的那次 CAS 决定了这次加一何时生效。并发算法里,这个瞬间叫作"线性化点":虽然多个调用的执行过程重叠,但每次成功的更新都能按它们的生效顺序排下来。

实际写计数器,直接用 incrementAndGet

上面的循环用来解释失败重试。日常代码直接调用原子类的方法:

java 复制代码
AtomicInteger count = new AtomicInteger();

int after = count.incrementAndGet(); // 先加一,返回更新后的值
int before = count.getAndIncrement(); // 加一,但返回更新前的值

这段代码按顺序在一个线程中执行:第一次调用后 after == 1;第二次调用后 before == 1,而 count.get() == 2。

并发时要区分"这次调用的返回值"和"现在再读到的值"。某次调用返回 1 后,其他线程可能已经把计数器改成 2;返回值不会跟着变化。

也不要把教学循环当成所有 JDK 版本的实际源码。OpenJDK 21 的 incrementAndGet() 调用的是 Unsafe.getAndAddInt(..., 1) + 1,JVM 可以采用适合目标处理器的原子加法实现。

API 保证的是原子加一,具体实现需要看对应版本和平台。

把两个线程跑完,再读取结果

使用上面的 CasCounter,可以这样启动线程:

java 复制代码
CasCounter counter = new CasCounter();
Runnable work = () -> {
    for (int i = 0; i < 100_000; i++) {
        counter.incrementAndGet();
    }
};

Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
a.join();
b.join();

System.out.println(counter.get()); // 200000

两个线程共享同一个计数器。join() 等它们都结束,避免主线程在加法还没做完时就读取结果。这里的输出是 200000,前提是没有其他线程修改或重置这个计数器,也没有发生整数溢出。

文章配套的 CounterDemo.java 包含完整可运行代码,同时演示手写 CAS 循环和 AtomicInteger.incrementAndGet()。

一个原子方法,不能包办整段业务

假设库存只剩一件:

java 复制代码
if (stock.get() > 0) {
    stock.decrementAndGet();
    createOrder();
}

即使 stock 是 AtomicInteger,两个线程也可能都先读到 1,接着各减一次,把库存减到 -1。

可以把"检查库存大于零"和"扣减一件"合进一个 CAS 重试过程。这样能保护这一个内存变量的条件扣减,但创建订单仍然是后续动作。如果创建订单失败,前面的扣减不会自动撤销。

多个字段、数据库写入和远程请求,需要分别考虑事务、一致性和失败处理。把一个字段换成原子类,并没有把整个方法变成原子操作。

不加锁,也有竞争成本

如果很多线程频繁修改同一个值,一部分 CAS 尝试就会失败,重新读取、计算和提交都需要消耗 CPU。底层还需要协调多个处理器对同一内存位置的访问。

因此不能直接得出"CAS 一定比 synchronized 快"。线程数量、竞争程度、临界区长度和 JVM 实现都会影响结果。选择同步方式时,要先确认需要保护哪些动作,再在接近业务负载的条件下测量性能。

还有一个常见追问:值从 0 变成 1,再变回 0,CAS 能发现吗?只比较当前整数的 CAS 不能识别这段变化历史。这叫 ABA。它是否影响正确性,取决于业务是否需要区分中间变化;不能仅凭出现 ABA 就认定算法有错。版本号和引用更新的例子留到后续文章。

面试时怎么回答

count++ 会丢失更新,是因为读取和写回之间可能发生其他线程的修改。CAS 把比较当前值与条件更新合成一个原子操作:当前值等于期望值才更新,不等则失败。用 CAS 实现计数时,失败后要重新读取、重新计算并重试。日常可以直接使用 AtomicInteger.incrementAndGet()。但单个原子方法不能保证多个调用或整段业务原子,竞争激烈时重试也有开销。

读完自测

  1. 为什么 if (count.get() == oldValue) count.set(newValue) 不能替代 CAS?请写出两个线程会出错的执行顺序。
  2. CAS 失败后,为什么必须重新读取并重新计算?只重复提交上次的期望值和新值,会怎样?
  3. if (stock.get() > 0) stock.decrementAndGet() 为什么可能把库存扣到负数?

参考资料:

相关推荐
职豚求职小程序1 小时前
中国铁塔2027届校招开启,网申怎么投、笔试面试怎么准备?
面试·职场和发展
弈栈录1 小时前
向量化、召回与重排序服务的实现
后端·面试·架构
PBitW2 小时前
行内编辑,前端如何兜底?😭😭😭
前端·面试
怕浪猫4 小时前
3 小时搭一个能用的聊天机器人后端,我用了这套技术栈
后端·面试·github
用户84913717547165 小时前
【技术干货】MySQL联合索引底层原理 | 最左前缀原则详解
mysql·面试
PBitW6 小时前
行内编辑,如何防止后端未返回,直接全部更新了?
前端·面试
零售校长6 小时前
二七区零售门店店长面试,重点在哪几处
面试·职场和发展·零售
考研保研资料分享6 小时前
农学跨保清华基础医学直博:四方联合与四校考核复盘(2024)
面试
Rain的Java大神之路7 小时前
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
java·后端·面试