「Java 进阶之路」系列 Day07
写在前面
Day05 讲 AQS 的时候提到过一句"非公平锁直接 CAS 抢",Day06 讲读写锁的 state 时也说"通过对 state 做 CAS 操作完成"------CAS 这个词已经出现好几次了,但一直没有展开讲。这篇就正式把它讲透:java.util.concurrent.atomic 包是怎么不用 synchronized 加锁、纯靠 CAS 就实现线程安全的,以及 CAS 绕不开的经典坑------ABA 问题。
一、是什么:atomic 包提供的无锁线程安全
java.util.concurrent.atomic 提供的是无锁(Lock-Free) 的线程安全操作,底层依赖 CPU 的 CAS 指令,不需要像 synchronized/Lock 那样挂起线程,开销更小。一共分四类:
| 分类 | 代表类 | 说明 |
|---|---|---|
| 基本类型 | AtomicInteger AtomicLong AtomicBoolean | 单个基本类型的原子操作 |
| 引用类型 | AtomicReference AtomicStampedReference AtomicMarkableReference | 对象引用的原子更新,后两者解决ABA |
| 数组类型 | AtomicIntegerArray AtomicLongArray AtomicReferenceArray | 数组元素的原子操作 |
| 字段更新器 | AtomicIntegerFieldUpdater 等 | 给已有类的volatile字段做原子更新,不用改类结构 |
选型原则 :纯计数累加首选 LongAdder(本文最后讲);需要 CAS 判断并读到旧值用 AtomicLong;更新对象引用用 AtomicReference;有 ABA 风险时换 AtomicStampedReference。
二、为什么:不加锁怎么保证线程安全
AtomicInteger 的核心方法长这样:
java
AtomicInteger ai = new AtomicInteger(0);
ai.get(); // 读取当前值
ai.incrementAndGet(); // 等价于++i,返回新值
ai.getAndIncrement(); // 等价于i++,返回旧值
ai.compareAndSet(5, 10); // CAS:当前值等于5时才设为10,返回是否成功
// JDK 8之后支持函数式写法
ai.updateAndGet(x -> x * 2);
看一眼简化后的底层实现就明白它为什么不需要加锁:
java
public class AtomicInteger {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value; // volatile保证可见性
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}
volatile 负责可见性(还记得 Day03/Day04 讲的吗),Unsafe.compareAndSwapInt 这一步则由 CPU 硬件指令保证原子性------两者配合,不需要 synchronized 挂起线程排队,就实现了线程安全。这就是"无锁编程"的核心思路。
三、CAS 原理:三个操作数 + 自旋重试
CAS = Compare And Swap,接受三个参数:
css
CAS(内存地址V, 期望值A, 新值B)
如果 V等于A
V设为B
返回成功
否则
返回失败,说明V已经被别的线程改过了
单独一次 CAS 只是"尝试一次",实际使用时几乎都会配合自旋------失败了就重新读取最新值再试一次,直到成功为止:
对应到 AtomicInteger.incrementAndGet() 内部的典型写法:
java
int oldValue, newValue;
do {
oldValue = value;
newValue = oldValue + 1;
} while (!compareAndSet(oldValue, newValue)); // 失败就重试
两个线程并发自增时的实际过程:
| 时刻 | 线程 A | 内存 V | 线程 B |
|---|---|---|---|
| 1 | 读到 V 等于 0 | V 等于 0 | |
| 2 | 计算 new 等于 1 | 读到 V 等于 0 | |
| 3 | CAS 把 0 变 1,成功 | V 变 1 | 计算 new 等于 1 |
| 4 | V 仍是 1 | CAS 把 0 变 1,失败(V 已经是 1) | |
| 5 | 重新读到 V 等于 1 | ||
| 6 | 计算 new 等于 2 | ||
| 7 | V 变 2 | CAS 把 1 变 2,成功 |
在 x86 上,这条 CAS 指令最终对应 LOCK CMPXCHG,LOCK 前缀会锁住总线或缓存行,保证整个比较交换操作是原子的,不会被其他核心的操作打断。
CAS 也有代价,不是万能药
| 缺点 | 说明 |
|---|---|
| ABA 问题 | 值经历了 A 变 B 又变回 A,CAS 感知不到中间被改过(下面细讲) |
| 自旋开销 | 竞争激烈时大量线程反复重试,CPU 空转飙高 |
| 只能保护一个变量 | 不像 synchronized 能把多个变量的修改包在一个原子操作里 |
四、ABA 问题:CAS 最经典的坑
问题场景:
css
时刻1 线程A读到栈顶是节点A
时刻2 线程B把A弹出 又把B弹出 然后又把A重新压回去
时刻3 线程A执行CAS 期望值还是A 一看当前确实是A 于是成功
但这个A已经不是原来那个上下文了 中间的结构变化被完全忽略
CAS 只比较"值是否相等",不管这个值在中间是不是被改动过又改回来------这就是 ABA 问题的本质:CAS 能保证"当前值等于期望值",但不能保证"这段时间没人动过它"。
解决方案:加一个版本戳
java
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 1);
// 线程A:读到值="A",版本戳=1
int[] stamp = new int[1];
String val = ref.get(stamp); // val是A,stamp[0]是1
// 期间线程B操作:A变B(版本戳变2),B又变回A(版本戳变3)
// 线程A的CAS:期望值是A,期望戳是1,两者都要匹配
boolean success = ref.compareAndSet(val, "C", stamp[0], 2);
// 结果失败!因为当前版本戳已经是3,不等于期望的1
AtomicStampedReference 在值之外多带了一个 int 版本戳,每次修改戳都会递增,即使值又变回了原来那个,戳也回不去了,从而让"中间是否被改动过"变得可判断。如果只需要知道"有没有被动过"而不关心具体动了几次,还有个更轻量的 AtomicMarkableReference,用 boolean 标记代替 int 戳。
| 场景 | 方案 |
|---|---|
| 普通引用替换,没有 ABA 风险 | AtomicReference |
| 需要检测是否被中间修改过 | AtomicStampedReference(int版本戳) |
| 只需要知道有没有被动过 | AtomicMarkableReference(boolean标记) |
五、LongAdder:解决高并发下 AtomicLong 的性能瓶颈
AtomicLong 有个天生的短板:高并发场景下,所有线程都在对同一个 value 做 CAS,竞争越激烈,失败重试次数越多,CPU 空转越严重:
objectivec
线程1 CAS 0变1 成功
线程2 CAS 0变1 失败 重试CAS 1变2 成功
线程3 CAS 0变1 失败 重试CAS 1变2 失败 再重试CAS 2变3 成功
LongAdder(JDK 8 引入)用分桶 思想解决这个问题:低竞争时和 AtomicLong 一样直接 CAS 一个 base;一旦竞争激烈,就把不同线程分散映射到不同的 Cell 上各自做 CAS,互不干扰:
读取总数时调用 sum(),把 base 和所有 Cell 累加起来------注意这是非强一致 的,sum() 执行期间如果还有并发写入,读到的结果可能和真实瞬时值有些许偏差。
| 对比项 | AtomicLong | LongAdder |
|---|---|---|
| 并发写性能 | 高竞争下自旋多 性能下降明显 | 分桶分散竞争 高并发吞吐高很多 |
| 读取 | get直接读 精确 | sum非强一致 读写并发时可能有偏差 |
| 内存占用 | 一个long | base加Cell数组 占用更多 |
| 功能 | 支持get set compareAndSet等完整操作 | 只支持add increment sum 不暴露CAS接口 |
| 适用场景 | 需要精确值或CAS判断 | 纯计数累加 对实时精确值要求不高 |
java
// 纯计数场景:优先用 LongAdder
LongAdder counter = new LongAdder();
counter.increment();
long total = counter.sum(); // 近似的总和
// 需要CAS判断(比如只执行一次的标志位):只能用AtomicLong
AtomicLong flag = new AtomicLong(0);
if (flag.compareAndSet(0, 1)) {
// 只有第一个执行到这里的线程会进入
}
一句话总结 :LongAdder 用空间换时间,把一个热点变量拆成多个 Cell 分散竞争,代价是牺牲了 compareAndSet 这种需要读到确切当前值的能力,所以纯计数用它,需要判断条件再更新的场景还得用 AtomicLong。
六、面试追问
Q1:CAS 是什么,包含哪三个操作数?
CAS(Compare And Swap)接受内存地址、期望值、新值三个操作数:如果内存地址当前的值等于期望值,就把它更新为新值并返回成功;否则说明值已经被别的线程改过,返回失败,不做任何修改。它由 CPU 硬件指令(x86 下是 LOCK CMPXCHG)保证整个比较交换过程的原子性。
Q2:AtomicInteger 为什么不用加锁就能保证线程安全?
它内部用 volatile int value 保证可见性(每次读都拿到最新值,写完立即刷新到主内存),配合 Unsafe.compareAndSwapInt 调用 CPU 的 CAS 指令保证修改操作的原子性。两者结合,不需要让线程挂起排队等锁,就实现了线程安全,这也是"无锁编程"性能通常优于加锁方案的原因。
Q3:什么是 ABA 问题,举个例子?
ABA 问题指一个值从 A 变成 B、又变回 A,此时执行 CAS 检查会发现"当前值仍然等于期望的 A"从而判断成功,但实际上这段时间值已经被修改过,只是恰好又变回了原来的样子。经典例子是无锁栈:线程 A 读到栈顶是节点 A,此时线程 B 把 A 弹出、弹出下一个节点、再把 A 重新压回去,线程 A 后续的 CAS 会误以为栈没有变化过,但实际的链表结构已经不同了。
Q4:怎么解决 ABA 问题?
用 AtomicStampedReference 给值附加一个版本戳,每次修改戳都递增;CAS 时要求值和戳都同时匹配才算成功,即使值又变回了原来那个,戳也不会回退,从而能感知到"中间发生过修改"。如果只需要知道"有没有被改过"而不关心改了几次,可以用更轻量的 AtomicMarkableReference(用 boolean 标记代替 int 戳)。
Q5:LongAdder 相比 AtomicLong 好在哪里,什么场景该用哪个?
AtomicLong 所有线程竞争同一个变量做 CAS,高并发下失败重试次数暴涨,CPU 空转严重;LongAdder 把热点变量拆分成多个 Cell,不同线程分散到不同 Cell 上各自累加,最后求和汇总,大幅减少了竞争。代价是 sum() 是非强一致的,且不提供 compareAndSet 这类需要读到精确当前值的接口。所以纯粹的计数、累加场景(比如接口调用次数统计)优先用 LongAdder;需要根据当前精确值做判断再更新的场景(比如"只允许第一个线程执行一次")还是要用 AtomicLong。
下一篇预告
Day08 讲并发容器全家桶------ConcurrentHashMap、CopyOnWriteArrayList 这些线程安全的集合底层是怎么实现的,和普通集合、以及给普通集合简单加锁比起来,到底强在哪。