题干与适用场景
CAS 会在共享位置仍等于预期值时原子地写入新值。ABA 发生在:线程 T1 读到 A 后暂停,线程 T2 把 A 改成 B 又改回 A,T1 只比较当前值仍为 A,于是 CAS 成功,却不知道中间发生过一次状态变化。
无锁栈、无锁队列和乐观更新都可能遇到这个问题。Oracle 的 AtomicReference.compareAndSet 按引用相等判断,AtomicStampedReference 则把引用和整数 stamp 一起原子比较;C++ 的 compare_exchange 也是无锁结构常用的基本原语。核心分类是 general,考察并发原理与权衡,不因示例使用 Java 或 C++ 改成语言分类。
面试官考察点
- 能否用明确时序说明“值回到 A”不等于“状态没有变化”。
- 能否解释 CAS 只比较它收到的预期表示,而不会自动验证整个历史。
- 能否区分 ABA、数据竞争、内存可见性和对象生命周期问题。
- 能否比较版本戳、不可变对象、hazard pointer/epoch reclamation 与锁的边界。
- 能否说明修复方案的溢出、开销、内存回收和进度保证。
回答前需要澄清的问题
- CAS 比较的是值、引用还是带版本的复合状态?不同表示决定是否能观察变化。
- 共享对象是否会被回收或复用地址?ABA 与内存回收通常需要一起讨论。
- 目标是 lock-free 还是只要正确?加锁可能更简单且更易审计。
- 版本戳是否可能溢出?若会,需定义足够宽的计数器或回绕策略。
- 是否需要返回具体节点、维护顺序或只更新一个标量?风险范围不同。
- 采用哪种内存模型?原子性不自动等于业务字段的发布与生命周期安全。
30 秒回答框架
“ABA 是 T1 看到 A 后暂停,T2 完成 A→B→A,T1 再用旧预期 A 做 CAS 并成功。CAS 证明的是当前表示仍匹配,不证明期间没有变化。修复通常把版本戳和引用组成一个原子状态,每次修改递增戳;也可以使用安全内存回收避免节点地址过早复用,或直接加锁。我要先确认对象生命周期、进度目标和版本溢出,再选择 AtomicStampedReference、带标签指针或锁。”
分步骤深入解答
第一步:用无锁栈还原时序。
栈顶从 A -> B。T1 读取 head = A 和 A.next,准备把 head CAS 成 A.next。T1 暂停后,T2 弹出 A、处理 B,再把同一个 A 或复用地址的节点压回栈顶。此时 head 的表示又是 A,T1 的 CAS 可能成功,但它写入的 next 已经来自旧快照。
第二步:指出错误不在 CAS 的原子性。
CAS 本身是原子的;问题是预期值携带的信息太少。它比较的是引用或标量的当前表示,没有比较“这个表示经历了几次修改”或关联节点是否仍属于同一逻辑状态。
第三步:区分相关概念。
数据竞争是未同步访问导致的语言层未定义或错误行为;ABA 可能在 CAS 原子且无数据竞争时发生。可见性决定线程读到什么,ABA 关注读到的值虽然相同但历史不同。对象回收则决定旧指针是否仍可安全解引用。
第四步:使用引用加版本戳。
state = (reference: A, stamp: 7)
T1 reads (A, 7)
T2 changes (A, 7) -> (B, 8) -> (A, 9)
T1 CAS expected (A, 7) -> (C, 8) // failsJava 的 AtomicStampedReference.compareAndSet 会同时比较 reference 与 stamp;C++ 可用双宽原子、指针低位标签或平台支持的复合 CAS,但必须确认目标平台真的提供所需原子性。
第五步:处理生命周期与地址复用。
版本戳只能发现表示变化,不能自动让节点安全回收。无 GC 语言还需 hazard pointers、epoch-based reclamation、引用计数或延迟回收,确保线程不会解引用已释放节点。GC 语言也要确认对象引用不会被错误复用成同一逻辑状态。
第六步:评估版本溢出。
有限宽度 stamp 最终会回绕。若线程可能长时间持有旧快照,回绕后仍可能再次匹配;需要足够宽的版本、限制快照寿命、使用不会复用的代数,或采用更强的同步方案。不能把“加一个 int”当成无条件证明。
第七步:比较替代方案。
加锁把复合读取、修改和生命周期放在同一临界区,通常更简单;不可变数据结构可让新状态使用新对象,降低原地修改风险;事务或数据库版本列则在持久化层提供类似乐观版本检查。选择依据应包含竞争度、延迟、复杂度和可审计性。
第八步:验证并发正确性。
构造可控测试暂停 T1,再让 T2 执行 A→B→A,确认无戳 CAS 错误成功、有戳 CAS 失败。再测试高并发、stamp 回绕边界、节点回收和 CAS 重试。单线程单元测试不能证明无锁算法正确。
高质量示范回答
“ABA 是一种状态变化被值比较隐藏的情况。T1 读到栈顶 A 并暂停;T2 把 A 弹出、完成 A→B→A,再把 A 放回;T1 只看到当前仍是 A,于是 CAS 成功,却可能用旧的 next 覆盖新结构。CAS 的原子性没有失效,失效的是预期表示缺少版本信息。我会把引用与单调 stamp 组成一个原子状态,每次修改都递增 stamp;Java 可用 AtomicStampedReference,C++ 则确认双宽 CAS 或标签指针的实际支持。同时,非 GC 环境必须配合 hazard pointer 或 epoch 回收。若竞争不高或正确性优先,我会选择加锁。最后用强制 A→B→A 时序和回收压力测试验证。”
常见错误
- 说 ABA 是 CAS 不原子 → 误解原语保证 → 指出比较表示缺少历史信息。
- 只比较节点值 → 不同版本可能值相同 → 比较引用加版本戳。
- 只加 stamp 不谈回收 → 仍可能解引用已释放节点 → 补充生命周期方案。
- 把数据竞争等同 ABA → 混淆语言内存模型与算法缺陷 → 分别定义并说明关系。
- 忽略 stamp 溢出 → 长时间运行仍有匹配窗口 → 定义宽度、寿命或回绕策略。
- 宣称 Java/C++ API 自动解决所有问题 → API 只提供原子比较,不保证业务不变量 → 说明复合状态与回收边界。
- 给出不可移植的指针位技巧 → 平台对齐或原子宽度可能不满足 → 先核对目标平台。
- 只做单线程测试 → 无法触发 ABA 时序 → 加入可控暂停和并发压力。
追问及应对
追问一:如果 T2 把 A 改成 B 再改回 A,为什么 CAS 还会成功?
因为普通 CAS 只比较当前预期表示。若表示只是引用 A 或数值 A,当前相等就满足条件;它不会自动记录中间的 B。
追问二:版本戳一定能解决 ABA 吗?
在版本未回绕且引用与 stamp 原子更新的前提下,它能检测 A→B→A。若 stamp 会溢出、复合更新不原子或节点已释放,仍需额外设计。
追问三:AtomicReference 和 AtomicStampedReference 的差别是什么?
前者原子比较引用并更新引用;后者把引用与整数 stamp 作为一对状态,同时比较和更新。后者增加对象封装与 stamp 管理成本,换取对版本变化的检测。
追问四:为什么不可变节点有帮助?
不可变节点不会在原地改变 next 或业务字段,新状态通过新对象表达,减少旧快照与新状态共享可变内容的机会。但节点回收和引用复用仍需处理。
追问五:hazard pointer 解决的是 ABA 还是回收?
它主要保护线程正在读取的节点不被回收。若地址复用仍可能让同一指针表示不同逻辑节点,仍需版本、标签或其他 ABA 防护。
追问六:为什么不总是加锁?
加锁通常最容易证明和维护,但可能带来阻塞、优先级反转或竞争延迟。低竞争、强可维护性场景优先加锁;只有明确需要无锁进度或极低延迟时才承担复杂度。
追问七:如何证明无锁栈的修复正确?
定义栈顶复合状态、CAS 线性化点、节点生命周期与不变量;用 A→B→A 调度、CAS 失败重试、stamp 边界和回收压力测试验证,再结合目标语言内存模型审查发布与获取顺序。