4. 无锁编程:CAS原理、ABA问题及解决方案、Atomic类族的正确使用姿势

说到锁优化,很多人第一反应就是「减少锁粒度」、「读写分离」这些。但说实话,在高频风控场景下,有时候连加锁本身都是个奢侈行为。你想想看,一笔交易进来,风控系统要在毫秒级内完成几十个规则的校验,这时候如果每个规则都去抢一把锁,那系统早就卡死了。

所以,无锁编程就成了我们不得不掌握的一门手艺。今天我就聊聊CAS、ABA问题,还有Atomic类族怎么用才不翻车。

4.1 CAS原理:硬件级别的乐观锁

CAS,全称是Compare-And-Swap,比较并交换。说白了,就是一条CPU指令干三件事:读内存、比较、写回。整个过程是原子的,不会被线程调度打断。

我刚开始接触CAS时,总觉得它很神秘。后来在项目中调试一个计数器,发现用synchronized加锁后QPS直接掉了30%。换成AtomicLong后,性能立马回来了。那一刻我才真正理解——CAS不是魔法,它是硬件帮你做好的原子操作。

核心流程:

  1. 线程读取内存中的值V,记为旧值A
  2. 线程计算新值B
  3. CAS指令比较当前内存值是否等于A
  4. 如果相等,将内存值更新为B;否则重试或放弃

伪代码大概长这样:

// 模拟CAS操作(实际是CPU指令)
public class SimulatedCAS {
    private volatile int value;
    
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue) {
            value = newValue;
        }
        return oldValue;
    }
}

嗯,这里要注意:volatile只保证可见性,不保证原子性。CAS的原子性靠的是CPU的cmpxchg指令。我在一次代码review中看到有人用volatile + synchronized自己实现CAS,其实完全没必要——Atomic类已经封装好了。

4.2 ABA问题:一个容易被忽视的坑

CAS有个经典问题叫ABA。什么意思呢?

假设内存中值是A。线程1读到A,准备改成C。但线程2先把它改成了B,又改回了A。这时候线程1的CAS比较发现还是A,就认为没人动过,于是成功更新为C。

你想想看,这中间其实发生了两次修改,但CAS感知不到。在高频风控中,这种问题会带来什么后果?

我曾经在一个风控规则引擎中用过CAS来维护一个状态机。状态从「待审核」变成「已通过」,再变回「待审核」,然后另一个线程CAS成功,以为状态没变过。结果呢?规则被重复执行了两次,风控结果全乱了。

避坑指南:

我曾经在压测环境中复现过ABA问题。当时排查了整整两天,最后发现是对象引用被回收后又被复用导致的。所以,如果你的业务场景允许值「绕一圈」回来,一定要处理ABA。

4.3 ABA问题的解决方案

解决ABA问题,主流方案就两种:

  • 版本号机制:每次修改时版本号+1,CAS时同时比较值和版本号
  • 标记机制:用一个布尔标记表示是否被修改过

Java里提供了AtomicStampedReferenceAtomicMarkableReference。前者用int做版本号,后者用boolean做标记。

我个人习惯用AtomicStampedReference,因为版本号能精确记录修改次数。来看个例子:

// 使用AtomicStampedReference解决ABA问题
public class ABASolution {
    private AtomicStampedReference<String> ref = 
        new AtomicStampedReference<>("A", 0);
    
    public boolean update(String expected, String newValue) {
        int[] stampHolder = new int[1];
        String current = ref.get(stampHolder);
        int stamp = stampHolder[0];
        
        // 比较值和版本号,两者都匹配才更新
        return ref.compareAndSet(expected, newValue, stamp, stamp + 1);
    }
}

注意看,compareAndSet有四个参数:期望值、新值、期望版本号、新版本号。版本号每次递增,这样即使值变回A,版本号也不会重复。

小技巧:

如果你的业务场景不需要精确的修改次数,只关心「有没有被改过」,用AtomicMarkableReference就够了。它比版本号方案省一个int的内存。

4.4 Atomic类族的正确使用姿势

Java的Atomic类族,说白了就是CAS的封装。常用的有:

类名 适用场景 我踩过的坑
AtomicInteger 计数器、序号生成 不要用getAndSet做累加,用incrementAndGet
AtomicLong 高并发下的长整型统计 32位JVM下性能差,建议用LongAdder
AtomicReference 对象引用的原子更新 注意ABA问题,用Stamped版本
AtomicIntegerArray 数组元素的原子操作 数组长度固定,不能动态扩容
LongAdder 极高并发下的统计 适合写多读少,读时需sum()汇总

我建议你记住一个原则:Atomic类适合做「单一变量的原子更新」,不适合做「复合操作」。比如你要先读再写,中间依赖其他条件,那就得用锁或者更高级的并发工具。

举个例子,我在风控系统中用AtomicInteger做限流计数器:

public class RateLimiter {
    private AtomicInteger counter = new AtomicInteger(0);
    private int maxRequests = 1000;
    
    public boolean tryAcquire() {
        int current = counter.incrementAndGet();
        if (current > maxRequests) {
            counter.decrementAndGet(); // 回退
            return false;
        }
        return true;
    }
}

这段代码有个问题:incrementAndGetdecrementAndGet不是原子的。在高并发下,可能多个线程同时超过阈值,然后一起回退,导致实际请求数超过限制。这就是典型的「复合操作」陷阱。

注意:

Atomic类不是万能的。如果你的操作涉及多个变量的协同更新,或者需要「先检查后执行」的原子性,请考虑使用ReentrantLockStampedLock。我在项目中见过有人用两个AtomicReference拼凑一个状态机,结果死锁了——因为两个CAS之间没有原子性保证。

4.5 性能对比:CAS vs 锁

我做过一个简单的压测,对比CAS和synchronized的性能差异。场景是100个线程并发递增一个计数器,每个线程递增10000次。

方案 耗时(毫秒) CPU使用率
synchronized 约450ms 高(上下文切换频繁)
AtomicLong 约120ms 中(自旋消耗CPU)
LongAdder 约80ms 低(分段减少冲突)

你看,CAS比锁快了将近4倍。但注意,CAS在竞争激烈时自旋次数会暴增,CPU飙升。所以如果你的并发度极高(比如每秒百万级),LongAdder是更好的选择——它把热点分散到多个Cell上,减少了CAS冲突。

我个人习惯是:低并发用AtomicLong,高并发用LongAdder,需要精确控制用AtomicStampedReference。没有银弹,只有适合场景的方案。

4.6 本章小结

无锁编程的核心就是CAS。它用硬件指令替代了操作系统级的锁,性能提升明显。但要注意ABA问题,用版本号或标记来解决。Atomic类族是CAS的封装,用对了能大幅提升系统吞吐量,用错了反而引入bug。

最后送你一句话:无锁不是没有锁,而是把锁的粒度降到了CPU指令级别。理解这一点,你才算真正入门了高并发编程。


公众号:蓝海资料掘金营,微信deep3321