4. 无锁编程:CAS原理、ABA问题及解决方案、Atomic类族的正确使用姿势
说到锁优化,很多人第一反应就是「减少锁粒度」、「读写分离」这些。但说实话,在高频风控场景下,有时候连加锁本身都是个奢侈行为。你想想看,一笔交易进来,风控系统要在毫秒级内完成几十个规则的校验,这时候如果每个规则都去抢一把锁,那系统早就卡死了。
所以,无锁编程就成了我们不得不掌握的一门手艺。今天我就聊聊CAS、ABA问题,还有Atomic类族怎么用才不翻车。
4.1 CAS原理:硬件级别的乐观锁
CAS,全称是Compare-And-Swap,比较并交换。说白了,就是一条CPU指令干三件事:读内存、比较、写回。整个过程是原子的,不会被线程调度打断。
我刚开始接触CAS时,总觉得它很神秘。后来在项目中调试一个计数器,发现用synchronized加锁后QPS直接掉了30%。换成AtomicLong后,性能立马回来了。那一刻我才真正理解——CAS不是魔法,它是硬件帮你做好的原子操作。
核心流程:
- 线程读取内存中的值V,记为旧值A
- 线程计算新值B
- CAS指令比较当前内存值是否等于A
- 如果相等,将内存值更新为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里提供了AtomicStampedReference和AtomicMarkableReference。前者用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;
}
}
这段代码有个问题:incrementAndGet和decrementAndGet不是原子的。在高并发下,可能多个线程同时超过阈值,然后一起回退,导致实际请求数超过限制。这就是典型的「复合操作」陷阱。
注意:
Atomic类不是万能的。如果你的操作涉及多个变量的协同更新,或者需要「先检查后执行」的原子性,请考虑使用ReentrantLock或StampedLock。我在项目中见过有人用两个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