ABA问题是CAS机制的经典盲区——这篇真实用两个线程复现"值被改了一圈又改回原样"的场景,并在实验过程中意外发现了一个和自动装箱相关、更容易踩到的独立陷阱。
用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可能已经真实释放并重新分配过内存,或者改变了其他关联状态,只是恰好表面的引用值又变回了同一个。
最初用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
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无论如何都做不到的。
每一行都是真实运行这段代码得到的结果。