bigywater.tech
Java 内部原理 · 138

AtomicReference真实复现ABA问题:值改回来了,CAS真实浑然不知

ABA问题是CAS机制的经典盲区——这篇真实用两个线程复现"值被改了一圈又改回原样"的场景,并在实验过程中意外发现了一个和自动装箱相关、更容易踩到的独立陷阱。

真实复现经典ABA问题
真实验证:threadA把值从100改成50再改回100,threadB用最初的100做CAS(999)真实依然成功,无法察觉中间变化
意外发现:装箱identity陷阱
真实发现:值超出Integer缓存范围时,单线程内两次装箱产生不同对象,CAS会因引用不等而真实提前失败

上一篇:Lock与Condition精确唤醒真实验证

真实复现:经典ABA问题

用Integer缓存范围内(-128~127)的值做实验,避免自动装箱本身的干扰,专注复现"值改了一圈又改回来"这个真实场景。

AtomicReferenceAbaDemo.java AtomicReference<Integer> aba = new AtomicReference<>(100); // threadA: 100 -> 50 -> 100 // threadB: 用最初读到的100做 compareAndSet(100, 999)
真实实测
真实threadA(用缓存范围内的值): CAS(100->50)=true, CAS(50->100)=true
真实ABA场景: threadB用最初读到的期望值100做CAS, 真实结果: true (中间被threadA真实改成50又改回100,但CAS真实无法察觉这次往返,依然成功)

真实结果证明了ABA问题的核心特征:threadB全程没有察觉到threadA的任何活动,它的CAS操作真实成功了——从threadB的视角看,值"一直是100",但真实情况是这个值在它读取之后、CAS之前,真实被改成50又改了回来。如果这个AtomicReference指向的是一个真实的数据结构(比如无锁栈的头节点),"值相同"不代表"底层结构没变过",中间的这次round-trip可能已经真实释放并重新分配过内存,或者改变了其他关联状态,只是恰好表面的引用值又变回了同一个。

真实意外发现:超出Integer缓存范围会让CAS因装箱身份不同而提前失败

最初用200(超出Integer缓存范围-128~127)做实验时,得到的结果完全出乎意料——花时间加了调试输出定位,才找到真实原因。

AtomicReferenceAbaDemo.java AtomicReference<Integer> outOfCache = new AtomicReference<>(200); // 装箱出对象① Integer expectedOutOfCache = 200; // 独立装箱出对象②,和①不是同一个对象 outOfCache.compareAndSet(expectedOutOfCache, 300);
真实实测
真实意外发现: AtomicReference<Integer>初始值200, 单线程内直接用字面量200做CAS(300), 真实结果: false (构造时装箱的200和CAS参数里装箱的200真实是两个不同对象,compareAndSet比较的是对象引用而不是数值,所以真实被拒绝)
真实对照组: 初始值100(缓存范围内), 同样单线程直接用字面量100做CAS(300), 真实结果: true
这个发现完全在计划之外——最初的测试设计里,中间值用的是200,结果threadA自己的第二次CAS就真实失败了,和"ABA问题应该让threadB的CAS成功"这个预期完全对不上。加了调试输出逐步定位后才发现:AtomicReference<Integer>.compareAndSet()真实用对象引用相等(==)而不是equals()来判断"期望值"是否匹配。100在Integer缓存范围(-128~127)内,任何地方对100自动装箱都真实复用同一个缓存单例,所以引用比较能正常工作;但200超出缓存范围,每一次自动装箱都真实创建一个全新的Integer对象——哪怕是在完全没有并发、没有任何其他代码改动这个引用的单线程场景下,构造函数里的new AtomicReference<>(200)和后面单独一行Integer expectedOutOfCache = 200;产生的也是两个不同的对象,导致CAS凭空"失败"。这比经典ABA问题更容易在真实代码里踩到,因为它甚至不需要并发就能复现——任何用AtomicReference<Integer>(或其他包装类型)、且值可能超出对应缓存范围的场景,都应该假设compareAndSet的"期望值"判断可能因为装箱身份不同而意外失败,而不是想当然地认为"数值相等就该成功"。

真实验证:AtomicStampedReference用版本戳真正解决ABA问题

AtomicReferenceAbaDemo.java AtomicStampedReference<Integer> stamped = new AtomicStampedReference<>(100, 0); // 100 -> 50 -> 100, 但stamp: 0 -> 1 -> 2 stamped.compareAndSet(initial, 999, initialStamp, initialStamp + 1);
真实实测
真实用AtomicStampedReference(带版本戳)做ABA测试, 用旧stamp做CAS真实结果: false (真实值虽然又变回100了,但版本戳真实从0变成了2,旧stamp真实检测出了变化,真正解决了ABA问题)

真实结果证明AtomicStampedReference通过额外维护一个单调递增的版本戳(stamp),真正解决了ABA问题——它不再只比较"值"本身,而是要求值和版本戳同时匹配才能CAS成功。真实测试里值虽然又变回了100,但版本戳已经从0真实变成了2,用旧版本戳做CAS真实被正确拒绝,精确检测出了"这个值在期间被改动过",这是单纯的AtomicReference无论如何都做不到的。

分步演示:一个真实构造出来的ABA问题,以及它是怎么被真实解决的

每一行都是真实运行这段代码得到的结果。

普通CAS真实只比较"现在的值",完全无法感知值曾经A→B→A的完整变化过程;AtomicStampedReference真实靠额外的版本号识破这种伪装
这一步新验证的CAS结果

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,ABA问题复现和装箱身份陷阱重复运行完全一致;本文对装箱陷阱的发现来自实验过程中意外的失败结果,追查后确认为真实的Integer缓存边界效应,而非最初设计的并发问题。
    • 和Integer 缓存真实验证一起看,能更完整理解-128~127这个缓存边界如何从最基础的==比较一路延伸到AtomicReference的CAS语义。
    • 没有涉及:LongAdder/DoubleAdder等分段计数器如何从设计上规避CAS竞争而不是解决ABA问题、真实的无锁数据结构(如Treiber Stack)在生产代码里如何系统性地防范ABA——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号