Android面试中常会被问到Java并发编程中的一些问题,比如我们将要聊的CAS就是其中一个。
大致理解
首先我们要大致找到什么是CAS——CAS就是compare and swap,它不是一个类,也不是接口,而是一种“无锁并发”的思想,你可以理解为一种“乐观的更新策略”来实现无锁并发。同时Java 提供了具体的工具来实现它:原子类(如 AtomicInteger,AtomicReference
)。
具体例子
那我们怎么理解这种“乐观的更新策略”呢?我用一个开发过程中的具体例子来让你理解。
线程A和线程B都想要对共享变量a=1进行修改:
线程A想将 a
从 1
改为 2,
线程B想将 a
从 1
改为 3
。
CAS的详细步骤:
1. 线程A的操作
-
读取旧值:线程A先读取
a
的当前值,记录为oldValue = 1
。 -
计算新值:线程A基于旧值计算新值
newValue = oldValue + 1 = 2
。 -
提交修改(CAS操作):
-
比较:检查当前内存中的
a
是否还是oldValue
(即1
)。 -
交换:
-
如果
a
未被修改(仍是1
),将a
更新为newValue = 2
→ 成功。 -
如果
a
已被修改(比如被线程B抢先改为3
),则放弃更新或者自旋重试。
-
-
2. 线程B的操作
-
读取旧值:线程B也读取
a
的当前值,记录为oldValue = 1
(此时线程A尚未提交修改)。 -
计算新值:线程B计算新值
newValue = oldValue + 2 = 3
。 -
提交修改(CAS操作):
-
比较:检查当前内存中的
a
是否还是1
。 -
交换:
-
如果此时线程A的 CAS 尚未执行,线程B发现
a
仍为1
→ 更新为3
→ 成功。 -
如果线程A已抢先更新为
2
→ 线程B的 CAS 失败,需要自旋重试或放弃。
-
-
上述过程中讲的自旋是什么
假设线程 A 和线程 B 同时 尝试修改 a
,但线程 A 的 CAS 操作先成功,线程 B 的第一次 CAS 失败,随后自旋重试。
1. 第一次尝试(失败)
-
读取旧值:线程 B 读取
a = 1
,记录oldValue = 1
。 -
计算新值:
newValue = oldValue + 2 = 3
。 -
执行 CAS:
-
比较:当前内存中的
a
是否仍是1
?-
实际此时线程 A 已抢先完成 CAS,
a
被改为2
。
-
-
结果:CAS 失败(因为
a != oldValue
)。
-
-
自旋重试:线程 B 不阻塞,继续循环尝试。
2. 第二次尝试,开始自旋(可能成功或继续失败)
-
重新读取旧值:线程 B 再次读取
a
的当前值oldValue = 2
(线程 A 修改后的值)。 -
重新计算新值:
newValue = oldValue + 2 = 4
。 -
执行 CAS:
-
比较:当前内存中的
a
是否仍是2
?-
若未被其他线程修改 → 更新为
4
→ 成功。 -
若被其他线程修改(例如线程 C 将
a
改为5
)→ 继续自旋。
-
-
自旋重试的关键点
-
无阻塞等待:线程 B 不会进入阻塞状态(如
BLOCKED
),而是持续占用 CPU 时间片尝试 CAS。 -
适应性自旋:
-
实际 JVM 会优化自旋次数(例如限制最大自旋次数,避免 CPU 空转)。
-
Java 的
AtomicInteger.compareAndSet()
内部通过while
循环实现自旋。
-
-
最终一致性:只要没有其他线程持续抢占修改,线程 B 最终会成功。
CAS中的ABA问题
如果线程A读取 a=1
,在 CAS 提交修改前,a
被其他线程改为 2
,又被改回 1
,线程A的 CAS 会误认为 a
未被修改。此时我们需要通过添加版本号来解决这个问题。
与传统加锁的对比
同样还是用上述对共享变量a=1的修改为例:
-
线程A修改:系统加锁 → 读取共享变量a=1 → 对a修改 a=2 并写入新值→ 释放锁,允许其他线程竞争锁。
-
线程B修改:等待锁 发现锁已被占用 → 阻塞 → 线程A释放锁后,线程B获取锁后 → 读取 a=2(线程A修改后的值) → 计算新值a = 3 并写入新值 → 释放锁。