核心内容摘要
电子密码锁实战:用STM32+AT24C04实现安全存储(附EEPROM磨损均衡算法)
这篇文章会用 Java 17 的视角把 CAS 从底层原理到实际落地系统地讲清楚。
目标只有一个看完这一篇你对 CAS 的理解不再停留在“有三个参数 V/E/N”这种记忆层面而是能从 CPU 指令一路推演到 Java 代码再对框架源码里的实战用法心里有数。
示例环境说明操作系统Windows 11JDK 版本JDK 17CPU 架构主流 x86_64支持cmpxchg等原子指令要彻底理解 CAS先把这几个基础打牢如果你之前对并发只是零散了解建议先把下面这些点梳一遍多线程与共享内存模型至少搞清楚“线程”、“共享变量”、“临界区”、“竞态条件”这些词分别指什么理解为什么count在多线程下不是一个原子操作。
CPU 缓存与缓存一致性知道 CPU 不是直接对内存操作而是对缓存行操作Java 内存模型JMM三大问题原子性、可见性、有序性分别是什么意思happens-before这条规则大致在解决什么问题。
volatile和锁的语义volatile提供的是“可见性 有序性”但不能保证复合操作的原子性如果想系统地理解volatile可以参考知乎专栏文章《深入浅出 Java volatile从硬件到 JMM 的完整剖析》synchronized/Lock提供互斥访问和更强的happens-before关系同时伴随阻塞和上下文切换成本。
悲观锁 vs 乐观锁、自旋的概念悲观锁假设“迟早会冲突”先把门锁上再干活乐观锁假设“冲突是少数”先干活真冲突了再回滚重试自旋就是“在用户态原地重试”这正是 CAS 的典型使用方式。
如果这些内容你都比较熟悉可以直接把它当成一份 checklist 快速过一眼如果有某个点不太确定建议先查一两篇专门的文章补一下再往下看 CAS会轻松很多。
为什么锁不够用了从阻塞到无锁很多人一提到“加锁”脑子里都是一个线程拿到锁其它线程就只能干等。
等得久了JVM 会把线程挂起、再唤醒来回切换上下文CPU 真正在干业务逻辑的时间就被压缩得很少。
这里再把问题说得直白一点锁的本质互斥阻塞内核参与线程挂起 / 唤醒。
线程抢锁失败后不是“什么都没发生”而是触发了一大堆调度逻辑。
代价上下文切换、用户态 / 内核态切换这些都不便宜。
低冲突时这种成本其实是“浪费”的。
那有没有一种办法冲突少的时候不要动不动就挂起线程冲突多的时候也要尽量减少等待成本能够在单变量更新这种场景下完全不依赖传统锁这就是 CAS 要解决的问题。
CAS 究竟在干什么V、E、N 三个参数背后的语义教科书式的说法是CAS 接收3个参数V当前值、E期望值、N新值。
把它翻译成“程序员语言”更好理解VValue你准备修改的那块内存里现在的真实值。
EExpected你“以为”这块内存此刻应该是多少基于你之前读到的值。
NNew如果现实确实和你的预期一致你打算把它更新成多少。
CAS 做的事情就是一句话如果V E就把V原子地改成N并告诉你“成功”否则什么都不做告诉你“失败”。
注意两个关键点比较和写入必须是一个不可分割的原子操作CAS 只负责这一小段原子更新不负责帮你排队和等待。
这其实就是“乐观并发控制”我先假设别人不会跟我抢这块内存如果真的撞车了就再试一次。
用一个排队拿号的例子看 CAS先忘掉 CPU 和指令想象一个排队叫号的场景某个窗口有一个“当前叫号牌”上面写着42你手上有一张小票上面写着“我上次看到的号码是 42”你的目标是把窗口的号改成43。
这时候 CAS 的逻辑就很好类比你先悄悄看一眼窗口现在的号是不是 42如果还是 42说明没人抢在你前面改号你就把牌子改成 43如果已经变成 43 或 44说明别人抢先一步了你这次就放弃不改牌子。
整个过程里有两个关键点你不会一边看一边改而是“先确认还是 42再一次性改成 43”要么成功把 42 改成 43要么什么都不改绝不会出现“改到一半”的状态。
把这个过程抽象成一个简单的流程图会更直观在 Java 代码里你看到的compareAndSet(expect, update)做的就是上面这件事只不过 V/E/N 都变成了内存里的整数或对象引用。
CPU 视角下的 CAScmpxchg、内存屏障和缓存一致性从 CPU 视角看CAS 其实就是一条或一小段原子读–改–写指令序列。
以 x86 为例汇编里有一条指令cmpxchg配合lock前缀就能对共享内存做原子比较交换lock cmpxchg会锁住对应缓存行配合缓存一致性协议比较寄存器里的期望值和内存当前值相等则写回新值、否则不写。
JVM 并不会自己发明新指令而是在 HotSpot 里用 C 封装这些 CPU 原子指令再通过Unsafe.compareAndSwapInt以及基于它实现的各类原子类 API 暴露给 Java 层。
你在 Java 里写的这句atomicInteger.compareAndSet(0,
;最终会在机器码里变成一条类似lock cmpxchg的序列中间不会被其他线程插进来。
另外一个经常被忽略的点内存可见性。
原子指令在实现上会伴随内存屏障memory fenceJava 有一套自己的内存模型JMMJava Memory Model用来规定“一个线程写入的数据另外一个线程在什么时刻、以什么顺序能看到”其中最重要的一条规则就叫happens-before对这篇文章来说只需要记住一个结论对同一个变量的成功compareAndSet/incrementAndGet这类操作它们的写入结果对后面读取这个变量的线程是可见的——这是 JMM 帮你兜底字段再配合volatile就能做到“CAS 负责原子性JMM volatile 负责可见性和有序性”具体细节我会在单独的一篇 JMM 文章里展开。
这也是为什么 JDK 里的原子类内部字段基本都是volatile而不是随便一个普通字段。
用 Java 17 写一个最小可跑的 CAS 示例AtomicInteger 版本前面都是概念这里先用一个“点赞计数”的小例子把 CAS 在并发场景下到底帮了什么忙讲清楚。
想象有一篇很火的技术文章很多用户同时在点“赞”每点一次赞总点赞数就要加1不管多少人同时点最终的总数都应该是“所有点击次数之和”不能丢赞但我们又不想给这个计数器加一把大锁把所有线程都串行化。
下面这段代码用 Java 17 里的AtomicInteger实现了一个最小可跑的“安全点赞计数器”import java.util.concurrent.atomic.AtomicInteger; public class CasLikeDemo { // 所有线程共享的点赞数 private static final AtomicInteger LIKE_COUNT new AtomicInteger(
; public static void main(String[] args) throws InterruptedException { // 模拟有 4 个线程同时在点赞 int threadCount 4; // 每个线程点 100000 次赞 int loop 100_000; // 保存所有线程方便后面统一 join Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j loop; j) { // 每一次点赞都通过 CAS 安全地把计数器加 1 LIKE_COUNT.incrementAndGet(); } }); threads[i].start(); } // 等待所有点赞线程都执行完 for (Thread t : threads) { t.join(); } // 期望值线程数 * 每个线程的点赞次数 System.out.println(Expected: (threadCount * loop)); // 实际值CAS 保护下的最终点赞数 System.out.println(Actual: LIKE_COUNT.get()); } }在上面的配置下4 个线程、每个线程点赞 100000 次在一台普通 x86_64 机器 JDK 17 上实际运行一次输出为Expected: 400000 Actual: 400000这段代码在 JDK 17 下可以直接编译运行。
这个小实验想验证和说明两件事在4个线程、每个线程点赞100000次的高并发场景下通过AtomicInteger.incrementAndGet()来做自增实际结果Actual等于期望结果Expected说明在没有加大锁的前提下也没有丢更新如果这里换成普通的int变量配合count在相同的测试条件下实际结果明显小于期望值例如前文实测的225004这就是没有用 CAS 时典型的写覆盖问题。
下面是一个故意不用 CAS 的“错误示例”你可以对比运行结果public class BrokenLikeDemo { private static int likeCount 0; public static void main(String[] args) throws InterruptedException { int threadCount 4; int loop 100_000; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j loop; j) { // 非原子操作读、改、写可能被其他线程打断 likeCount; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(Expected: (threadCount * loop)); System.out.println(Actual: likeCount); } }在同样的环境和参数设置下实际运行某次BrokenLikeDemo的输出类似Expected: 400000 Actual: 225004有了这个直观的感受再去看后面AtomicInteger源码里基于 CAS 的自旋实现就更容易把“原理”和“真实业务场景”对上号。
关键点LIKE_COUNT是一个AtomicInteger专门用来在多线程下做安全的整数自增每次incrementAndGet()内部都会做“读当前值 → 基于当前值算出新值 → 用 CAS 尝试写回 → 失败就重试”这一套流程冲突少的时候大部分线程第一次 CAS 就能成功不需要阻塞或挂起线程。
在低冲突场景这种基于 CAS 的自增比给整个计数器加一把大锁要轻量得多。
再看 AtomicInteger 源码getAndIncrement 背后的自旋循环AtomicInteger是大家最熟悉的 CAS 包装类之一。
看一下它在 OpenJDK 里的核心实现代码可在 OpenJDK 17 仓库中查看例如 AtomicInteger 源码下面是精简后的关键片段保持了原有语义public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID 6214790243416807050L; private static final jdk.internal.misc.Unsafe U jdk.internal.misc.Unsafe.getUnsafe(); private static final long VALUE_OFFSET; static { try { VALUE_OFFSET U.objectFieldOffset (AtomicInteger.class.getDeclaredField(value)); } catch (ReflectiveOperationException e) { throw new Error(e); } } private volatile int value; public final int get() { return value; } public final boolean compareAndSet(int expect, int update) { return U.compareAndSetInt(this, VALUE_OFFSET, expect, update); } public final int getAndIncrement() { return U.getAndAddInt(this, VALUE_OFFSET,
; } }这里面有几个细节值得注意value字段是volatilecompareAndSet直接调用了底层Unsafe.compareAndSetIntgetAndIncrement则是基于getAndAddInt封装的。
getAndAddInt的典型实现也是一个 CAS 自旋循环public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v getIntVolatile(o, offset); } while (!compareAndSetInt(o, offset, v, v delta)); return v; }可以看到和前面点赞计数示例里用到的模式本质上是一样的先读一遍旧值v基于旧值算出新值v delta用 CAS 尝试写回如果失败说明有别的线程抢先改了就再读一遍再来。
ABA 问题到底危险在哪怎么用版本号解决ABA 问题在并发编程里经常被提起这里重新整理一下更贴近实际场景。
所谓 ABA 问题就是线程 T1 读到某个共享变量值为AT1 被挂起线程 T2 把值从A改成B又从B改回AT1 醒来后再用compareAndSet(A, X)会认为“值没变”从而更新成功。
很多时候这不一定是 bug比如一个简单计数器你只关心当前数值不在乎中间历史。
但在一些“状态机”场景这可能是致命问题例如节点状态从INIT→RUNNING→STOPPED如果某些路径又把状态从STOPPED改回INIT你单纯比较值是否为INIT已经无法知道这是“老的 INIT”还是“新的一轮 INIT”。
解决思路可以分两步来看先看一个只有“值”的错误写法再看加上“值 版本号”之后的改进方案。
先看一个只比较值的 CAS 写法很容易踩 ABA 坑import java.util.concurrent.atomic.AtomicReference; public class AbaBadDemo { private static final AtomicReferenceString REF new AtomicReference(A); public static void main(String[] args) throws InterruptedException { // T1 拿到老快照值为 A String initial REF.get(); Thread t2 new Thread(() - { // T2A - B REF.compareAndSet(A, B); // T2B - A REF.compareAndSet(B, A); }); t
start(); t
join(); // T1基于“老的 A”尝试改成 X boolean success REF.compareAndSet(initial, X); System.out.println(CAS success? success); System.out.println(value REF.get()); } }在一台普通的 Windows 11 JDK 17 环境下运行结果会是CAS success? true valueX也就是说虽然期间经历过A - B - A最后 T1 仍然误以为“值没变”CAS 成功把A改成了X—— 这就是典型的 ABA 问题。
更朴素的解决思路是给变量加一个“版本号”或“时间戳”CAS 比较时不仅比较值还要比较版本号每次真正更新成功时版本号加1。
JDK 里有现成的工具类AtomicStampedReference它就是“值 版本”的包装。
示例代码import java.util.concurrent.atomic.AtomicStampedReference; public class AbaFixedDemo { private static final AtomicStampedReferenceString REF new AtomicStampedReference(A,
; public static void main(String[] args) throws InterruptedException { int[] stampHolder new int[1]; String initial REF.get(stampHolder); int initialStamp stampHolder[0]; Thread t2 new Thread(() - { int[] s new int[1]; String v1 REF.get(s); // A, stamp 0 REF.compareAndSet(v1, B, s[0], s[0]
; // A-B, stamp
String v2 REF.get(s); // B, stamp 1 REF.compareAndSet(v2, A, s[0], s[0]
; // B-A, stamp
}); t
start(); t
join(); boolean success REF.compareAndSet(initial, X, initialStamp, initialStamp
; System.out.println(CAS success? success); System.out.println(value REF.getReference() , stamp REF.getStamp()); } }在与前文相同的环境Windows 11 JDK 17下运行这个示例某次实际输出为CAS success? false valueA, stamp2这个结果完全符合预期valueA说明经过线程t2的两次修改之后值又被改回了Astamp2表示这块数据在期间经历过两次成功更新A - B、B - A版本号从0变成了2CAS success? false主线程手里拿着的是“值为A、版本为 0”这一老快照它尝试在这个快照的基础上做compareAndSet(initial, X, initialStamp, initialStamp
由于当前版本号已经是 2不再等于 0所以 CAS 按预期失败。
也就是说即使当前值又回到了AAtomicStampedReference仍然能通过版本号看出来“这已经不是当初那个 A 了”从而避免了 ABA 问题。
从单变量到复合状态什么时候该果断用锁CAS 非常适合单变量或少量字段的更新一旦状态变复杂你就要警惕了。
几种典型不适合只用 CAS 的场景需要一次性更新多个字段必须保持整体一致性比如一个订单从“未支付”切到“已支付”状态字段、支付时间、支付渠道、日志记录等多个字段必须要么都修改要么都不修改这种场景如果强行用多次 CAS很容易在中途失败状态半更新半不更新。
冲突非常激烈CAS 在高冲突场景下会疯狂自旋整体成本未必比用锁低此时合理的做法是要么调整数据结构分段、分桶要么老老实实用锁。
业务代码里有长耗时操作CAS 自旋代码里应该只放“极短的纯计算”逻辑一旦里面夹带 IO、RPC 等耗时操作就会把 CAS 这一层的优势完全吃掉。
简单一句话CAS 负责的是“原子更新一个点”不是“保证整个事务的一致性”。
超过它能力边界的场景就不要硬上了。
Netty框架里的 CAS 用法Netty引用计数和状态机里的 CASNetty 作为高性能网络框架对对象生命周期的控制非常苛刻。
以ByteBuf的引用计数为例为了做到在多线程下安全地回收内存它通过 CAS 来维护引用计数在 Netty
x 里有一个AtomicReferenceCountUpdater抽象类可参考官方文档Netty AtomicReferenceCountUpdater其中定义了casRawRefCnt这样的操作本质就是对内部的引用计数字段做 CAS 更新实现类内部会基于AtomicIntegerFieldUpdater或Unsafe等原子工具在不引入锁的前提下安全地对引用计数加减。
这类场景的特点状态很简单一个int的引用计数读写频率极高加锁会严重拖累吞吐量因此非常适合用 CAS。
如何判断一段代码适不适合用 CAS最后给一个我自己在项目里常用的“快速判断标准”判断是否值得把某段逻辑改成 CAS只更新单个变量是可以考虑 CAS。
否优先考虑锁、事务或更高级的抽象。
更新逻辑是否极短、没有阻塞操作是适合放进 CAS 自旋里。
否要谨慎避免在自旋里做重活。
冲突概率是否可控例如通过分段、分桶压低冲突是CAS 很可能能显著减少锁开销。
否冲突太频繁时自旋成本会很糟糕。
是否真的需要非阻塞有些场景即使用锁也完全够用而且代码更直观不要为了“听起来高级”而到处堆 CAS。
可以把 CAS 当成一把很锋利的刀用好了可以极大提升并发性能用错了既不好写、也不好排查问题。
在 Java 17 这个版本上你完全可以用AtomicXXX覆盖绝大多数需要 CAS 的场景业务代码里优先用AtomicInteger、LongAdder这类成熟封装写基础组件、框架代码需要更细粒度控制时可以在充分评估的前提下使用Unsafe或其他底层原子工具只有在极个别需要精细控制布局和性能的场景才真的去碰这些底层接口。
只要把这里的几层关系理顺再配合前面的代码和框架示例CAS 这块基本就算真正在脑子里“落地”了。