
写 Java 并发代码的时候我见过不少团队的第一反应就是加锁。synchronized 一放逻辑是好写了可一旦锁竞争上来线程阻塞唤醒、上下文切换、缓存失效这些开销会直接把吞吐拖垮。这个系列做到这里我想认真聊聊另一种思路CAS 与原子类。它不锁住临界区而是用先操作、再验证冲突的方式把并发安全托付给硬件层的一条指令。这篇文章会从锁的代价讲起把 CAS 的底层原理、原子类的选型、ABA 问题的成因和破解方法串起来适合已经写过 synchronized、但想把并发性能再往上提一档的开发者。1. 锁太重了从 synchronized 的代价聊起1.1 锁竞争到底耗在哪很多初学者会有个错觉既然 synchronized 是 JDK 原生提供的那它一定很轻。其实单线程抢锁确实不算重JDK 多年优化下来无竞争时锁开销已经压得很低。真正贵的是竞争本身。当一个线程拿不到锁时JVM 会把它挂起等锁释放后再由操作系统唤醒。这个挂起和唤醒的过程涉及线程状态从运行态切到阻塞态需要操作系统介入一次线程上下文切换通常要 1 微秒到几微秒肉眼感觉不到但在高频调用下这时间会叠加线程调度器的调度延迟可能是几十微秒甚至更高CPU 缓存被破坏之前预取的数据可能全部作废。我之前测过一个模拟的计数场景8 个线程同时对同一个 HashMap 加锁更新锁内代码本身只有两三行结果吞吐量还不如单线程跑。临界区越短锁的开销占比就越大这是加锁方案最尴尬的地方——你把宝贵的 CPU 时间都花在了协调谁该进去这件事上。除了性能锁还带来设计上的负担嵌套锁容易死锁tryLock 超时怎么处理wait/notify 配对错乱线程中断和异常会不会释放锁。每一条都能在线上踩出事故。1.2 乐观与悲观两种并发策略的较量锁的思路是悲观的先假设一定有别的线程来抢所以我先把门锁上你再急也得等着。而 CAS 的思路是乐观的先假设冲突是偶发事件我直接执行操作最后写回的时候检查一下——如果这期间没人动过共享变量我就成功如果被人动过了我就重试。用生活里的例子类比悲观锁是图书馆占座你到了得先拿书包占好别人看见座位有人就只能去别处乐观锁是试衣间挂个牌子用后请还原如果有人用过请等一下你没发现任何使用痕迹就直接进去用了万一发现里面有人动过退出来重排。乐观策略最棒的地方在于没有线程被挂起也就没有上下文切换冲突发生时只是让执行线程原地转圈重试。在低冲突场景下这比锁快出一个量级。2. CAS 的底层真相一条 CPU 指令撑起无锁世界2.1 一次指令级的原子承诺CASCompare And Swap比较并交换是一条 CPU 指令级的操作接受三个参数内存地址 V、期望的旧值 E、准备写入的新值 N。执行时处理器先读内存地址 V 当前的值如果等于 E就把 N 写进去并返回成功如果不等说明值已经被别人改过了什么都不做返回失败。它最关键的性质是Compare和Swap两个动作在硬件层面是不可分割的不会出现刚判断完相等、还没来得及写就被别的线程改掉的窗口期。在 x86 上这条指令通常对应带 LOCK 前缀的 CMPXCHGLOCK 前缀会锁住缓存行保证多核之间的一致性和原子性。Java 层面对这条底层指令的封装早年是 sun.misc.Unsafe 的 compareAndSwapInt新版本里换成了 jdk.internal.misc.Unsafe。这些 Unsafe 类不推荐业务代码直接用但从理解原理的角度知道 CAS 最终落在一条指令上就够了。这里有个容易忽略的点CAS 之所以能读到最新的值依赖的是共享变量的 volatile 语义。Java 里做 CAS 的封装类内部字段都声明为 volatile保证任何线程修改后对其他线程立即可见。所以volatile 保证可见性 CAS 保证原子性是这对组合不可拆分的两半。2.2 incrementAndGet 的真相一个自旋循环看过 AtomicInteger 源码的人都知道incrementAndGet 并不是什么复杂魔法它的逻辑等价于下面这个自旋循环public final int incrementAndGet() { for (;;) { int current get(); // volatile 读拿到当前值 int next current 1; if (compareAndSet(current, next)) { return next; } } }每次循环先读一次当前值算好新值然后调用 compareAndSet 尝试更新。如果在这几步之间有人抢先改了值CAS 会失败循环继续重来。这个失败就重试的过程就是自旋。自旋之所以能成功前提是失败只是偶发。如果 8 个线程同时争抢同一个计数器大部分 CAS 都会失败重试次数爆炸CPU 白白空转。所以我在实际选型时有一条朴素的判断标准冲突概率低用 CAS冲突概率高老老实实加锁或者换分段方案。2.3 用 CAS 手写一个自旋锁理解了循环你其实已经能自己造一个最简自旋锁了。我用 AtomicReference 存当前持锁线程null 表示没人持锁public class SpinLock { private final AtomicReferenceThread owner new AtomicReference(); public void lock() { Thread current Thread.currentThread(); // 谁先 CAS 成功谁就拿到锁 while (!owner.compareAndSet(null, current)) { // 自旋等待临界区极短时适用 Thread.onSpinWait(); } } public void unlock() { Thread current Thread.currentThread(); // 只有持锁线程才能释放避免误释放 owner.compareAndSet(current, null); } }这个锁没有阻塞也就没有上下文切换。但它只适合临界区极短的场景比如只更新一个变量、写一个数组下标。要是临界区里有 IO 操作、远程调用自旋就是灾难线程会一直空转烧 CPU。Thread.onSpinWait() 是从 Java 9 加入的提示性指令它在 x86 上会翻译成 PAUSE暗示处理器这段循环是自旋等待能减少指令流水线冲刷算是一个值得养成习惯的小优化。3. 原子类工具箱从 AtomicInteger 到 LongAdder 的选型3.1 常用原子类的一张清单JDK 在 java.util.concurrent.atomic 包下提供的原子类绝大多数场景可以直接抄作业不需要自己封装。类名核心用途典型场景AtomicInteger / AtomicLong单值计数器、序列号请求计数、ID 生成AtomicBoolean原子布尔状态开关、一次性初始化AtomicReference对象引用原子更新状态机、缓存指针AtomicIntegerArray / AtomicLongArray数组元素原子更新分片统计AtomicIntegerFieldUpdater对已有对象的 volatile 字段做原子更新减少对象内存、装箱开销LongAdder / DoubleAdder高竞争计数器热点统计、限流计数AtomicStampedReference带版本号的引用更新破解 ABA 问题AtomicMarkableReference带布尔标记的引用更新只关心是否被改动过AtomicIntegerFieldUpdater 是一个容易被忽视的类。它可以对普通对象里的 volatile int 字段做原子操作省去为每个统计项单独创建 AtomicInteger 的开销。注意它有个硬性约束目标字段必须是 volatile否则直接抛 IllegalArgumentException这也是为了保障 CAS 的可见性前提。3.2 高并发计数器LongAdder 的分段思想AtomicLong 在高竞争下有个硬伤所有线程都在同一个内存地址上做 CAS成功者只有一个其余人都白转圈还伴随严重的缓存行竞争。LongAdder 的思路是分段内部维护一个 base 值和一组 Cell 数组。竞争低时直接更新 base竞争高了就把计数分散到不同的 Cell 里每个线程尽量落在自己的 Cell 上互不干扰。最终的值是 base 与所有 Cell 相加的结果。我有一个直观的项目经验某接口的 QPS 统计点8 线程固定并发压测AtomicLong 版本大概只能跑出 60 万的每秒更新量换成 LongAdder 后能到 300 万级别。但注意 LongAdder 的 sum() 需要遍历所有 Cell读多写少的场景反而慢。所以我的习惯是读多写少用 AtomicLong写多读少用 LongAdder。3.3 原子类的适用边界原子类不是万能钥匙。遇到下面几种情况它就不够用了复合不变式比如余额不能为负且转账前后总账不变这涉及多个变量的联合约束单个 CAS 覆盖不了多个共享变量需要保持一致CAS 只能保证一个变量的原子性多个变量要么用锁要么打包成一个不可变对象再放进 AtomicReference高冲突写写操作频繁且冲突激烈时CAS 重试开销大于锁的阻塞开销。我见过有人用 AtomicReference 硬写一个无锁队列最后 ABA 问题连环爆代码里全是 workaround。给自己留个退路如果无锁方案的复杂度已经超过同步方案它就是过度设计。4. 原子类在业务代码里的三种典型用法4.1 无锁计数器与流量统计最常见的用法就是统计。比如网关层统计每分钟请求量多线程大量打点、低频读取public class RequestCounter { private final LongAdder count new LongAdder(); public void inc() { count.increment(); } public long snapshot() { return count.sum(); } }这里用 LongAdder 是因为打点操作远多于读取。如果统计逻辑要求取一次并清零可以用 AtomicLong 的 getAndSet(0)两个操作天然原子不需要额外加锁。4.2 AtomicReference 实现简易状态机状态机是 CAS 很典型的应用场景。检查当前状态、判断是否允许切换、执行切换这三个动作用锁写是三步用 CAS 写是一步。我之前处理过一个离线任务调度模块的需求任务状态只允许从空闲切到运行中且同一时刻只能有一个线程成功启动任务。enum TaskState { IDLE, RUNNING, DONE, FAILED } public class Task { private final AtomicReferenceTaskState state new AtomicReference(TaskState.IDLE); public boolean start() { return state.compareAndSet(TaskState.IDLE, TaskState.RUNNING); } }两个线程同时调用 start()底层只有一个 CAS 能成功另一个拿到 false直接退出。这比 synchronized 加 if 判断要干净得多而且没有锁竞争开销。这个模式可以推广到初始化一次、领取唯一资源等场景。4.3 并发容器源码里的 CAS 基因理解了 CAS再去看并发容器的源码会顺畅很多。ConcurrentHashMap 在 Java 8 的桶位初始化、链表转红黑树、扩容迁移等环节大量使用了 CAS 操作源码里的 casTabAt、compareAndSet 系列方法ConcurrentLinkedQueue 的入队、出队也完全基于 CAS 实现LongAdder 本身又是 ConcurrentHashMap 的计数组件。所以这篇文章的内容不只是为了用那几个原子类——它是你通读并发容器源码、看懂底层设计的一块垫脚石。5. ABA 问题无锁世界里最隐蔽的陷阱5.1 ABA 是怎么发生的现在要聊 CAS 无锁方案里最经典的一个坑ABA 问题。场景是这样的线程 T1 从共享变量读到的值是 A在 T1 准备好新值、还没来得及执行 CAS 的这段时间里线程 T2 把值从 A 改成了 B然后又改回了 A随后 T1 执行 CAS发现当前值确实等于它预期的 A于是 CAS 成功。表面上这是一次成功的更新但共享变量在中途经历过A → B → A的完整变化。如果业务逻辑只关心最终值是不是 A那确实没问题但如果这个值代表的是某个结构的状态、某个对象的引用那么中间被改过这件事本身就是信息就这样被 CAS 吞掉了。ABA 的名字就是取中间状态 B 和起始状态 A 的首字母。5.2 无锁栈被 ABA 破坏的完整推演直接讲理论不够直观我拿一个无锁栈实际推演一遍。栈用 AtomicReference 指向栈顶节点pop 的逻辑是典型的读顶 → 读下一个 → CAS 替换栈顶public class LockFreeStack { private final AtomicReferenceNode top new AtomicReference(); public Node pop() { Node oldTop; Node newTop; do { oldTop top.get(); if (oldTop null) return null; newTop oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); return oldTop; } public void push(Node node) { Node oldTop; do { oldTop top.get(); node.next oldTop; } while (!top.compareAndSet(oldTop, node)); } }假设初始栈top → A → B → C即 A.next B。现在按下面的时序执行步骤线程动作栈顶状态1T1pop读到 oldTopAnewTopB准备 CAStopA2T1切换出去暂停topA3T2执行 pop弹出 AtopB4T2继续 pop弹出 BtopC5T2push 一个节点恰好复用了引用 A可能是对象池回收或逻辑复用A.nextCtopA6T1恢复执行CAS(top, A, B)发现 top 确实还是 A成功7T1top 被设置成 BtopBA 丢失栈结构损坏问题出在最后一步T1 眼中的B是步骤 1 读取的旧值但在 T1 挂起的这段时间里B 已经被 T2 弹出A 的 next 也被改成了 C。T1 的 CAS 虽然成功了却把一个早该离开栈的节点重新立到了栈顶原本在栈里的 A 节点直接丢失。这真不是理论推演。我曾排查过一个无锁缓存池的诡异 bug症状是偶尔丢了对象排查到最后就是上面的时序——缓存池里复用的是同一个对象引用ABA 把这个引用骗过去了。5.3 不必恐慌不是所有 ABA 都致命在跳进解决方案之前先分清哪些场景 ABA 无害。如果共享变量是纯粹的值比如 AtomicLong 做自增计数、AtomicInteger 做序号生成那从 5 变 6 又变回 5并不会让计数的含义出错因为业务只认最终值。ABA 造成危害的前提是变量本身承载了结构状态或者对象引用的信号比如栈顶、队列头、复合对象的指针。在这些场景状态经过了一次完整的离开又回来但中间的过程不可见就会导致基于旧结构的决策出错。所以我在评审代码时先问一句这个变量代表值还是代表状态代表状态就得考虑防 ABA。6. 破解 ABA版本号思想与 AtomicStampedReference6.1 给状态加一枚邮票破解 ABA 的思路直观朴素既然值相同不足以证明状态没变过那就给每次修改附带一个版本号。CAS 时既比较值也比较版本号。值相同但版本号不同照样失败。JDK 提供的就是 AtomicStampedReference。它的 API 结构如下AtomicStampedReferenceFoo ref new AtomicStampedReference(foo, 0); // 获取时把版本号存入 int 数组 int[] stampHolder new int[1]; Foo current ref.get(stampHolder); // 更新时要同时匹配引用和版本号 ref.compareAndSet(current, newFoo, stampHolder[0], stampHolder[0] 1);每次修改版本号加一。线程 T1 读到的版本号如果是 0而 T2 在中途改了两次那 T1 执行 CAS 时要匹配的版本号 0 已经不存在CAS 失败继续重试问题就化解了。这里有个使用细节必须提醒ref.get(int[] stampHolder)返回的引用和写入 int 数组的版本号理论上并不保证是同一瞬间的一致快照这在高度竞争时可能读到新引用 旧版本号的组合。如果需要对一致性要求极高的场景配套的版本号要自行设计或用更重的机制兜底不能盲目信任这个组合读。6.2 用 AtomicStampedReference 修复无锁栈把上面的 LockFreeStack 改成带版本号的版本核心改动只有一点CAS 时同时比较并更新版本号。public class StampedStack { private final AtomicStampedReferenceNode top new AtomicStampedReference(null, 0); public void push(Node node) { while (true) { int[] stampHolder new int[1]; Node current top.get(stampHolder); node.next current; if (top.compareAndSet(current, node, stampHolder[0], stampHolder[0] 1)) { return; } } } public Node pop() { while (true) { int[] stampHolder new int[1]; Node current top.get(stampHolder); if (current null) return null; Node next current.next; if (top.compareAndSet(current, next, stampHolder[0], stampHolder[0] 1)) { return current; } } } }回到刚才的推演T2 弹出 A、弹出 B、再推回 A期间版本号已经累加过三次。T1 恢复时持有的还是初始版本号CAS 直接失败进入下一轮循环重新读取最新的栈顶和版本号栈结构不再被破坏。6.3 AtomicMarkableReference 与选型建议如果业务只关心这期间有没有被动过而不关心被改了几次可以用更轻量的 AtomicMarkableReference。它附带一个布尔标记CAS 时同时匹配引用和标记。比如对象是否被初始化过节点是否已被删除这类只有两态的问题用布尔标记就够了。场景方案需要记录修改次数或关注具体中间过程AtomicStampedReference只关心是否被修改过两态即可AtomicMarkableReference数值型计数器不涉及结构状态直接用 AtomicLong不必防 ABA工程上还有一个更朴素的建议很多时候可以避开 ABA而不是破解它。比如无锁栈的节点不复用、每次 push 都新建对象ABA 的触发条件就不存在了。但这样 GC 压力上升。所以我的经验是业务代码里先评估一下你到底需不需要无锁如果并发冲突本身就低synchronized 或者 ReentrantLock 都不会差无锁带来的复杂度反而成了维护成本。7. 几个只有踩过坑才会注意的细节7.1 自旋失败时的退让策略纯自旋在高竞争下是 CPU 杀手。我见过线上一个原子类计数器在极端热点下把单核 CPU 打满查看线程栈全是同一个 compareAndSet 的循环。解决思路是给自旋加退让连续失败若干次后让出 CPU 或者随机睡一小会。JDK 里可以组合使用int spins 0; while (!ref.compareAndSet(old, new)) { if (spins 16) { Thread.onSpinWait(); // 短暂退让保持热点 } else { Thread.yield(); // 让出时间片 LockSupport.parkNanos(1); // 或者指数退避 } }退让的时机和步长需要压测调优没有银弹但这个模式比死循环体面得多。7.2 伪共享CAS 的隐形敌人CAS 操作的是内存中的值而 CPU 读写内存是以缓存行为单位进行的通常 64 字节。如果两个高频访问的共享变量恰好落在同一条缓存行里其中一个被更新另一个所在缓存行也会失效导致两个变量互相拖累这就是伪共享。LongAdder 内部的 Cell 数组就特意做了缓存行填充让每个 Cell 独占一条缓存行。业务代码如果自己维护多个热点字段可以考虑用填充字段或者字段对齐来隔离但这是一把双刃剑——白白浪费内存。我一般只在性能 profile 确实看到缓存失效严重时才会动手不主动做。7.3 volatile 是 CAS 的地基别跳过去最后说一个我面试时经常考、开发时也经常被忽略的点CAS 只保证比较并写入的原子性不保证其他字段的可见性。Java 原子类内部把目标变量声明为 volatile才会有读到的值是最新的这个前提。用 AtomicIntegerFieldUpdater 时这个约束更明显——它要求被更新的字段必须声明为 volatile否则直接报错。这其实是 JVM 在提醒你没有 volatile 的可见性CAS 就是空中楼阁。我自己在设计并发组件时有个小习惯先写两行注释说明这个字段是干什么的、允许哪些线程写、用什么手段保证同步。写注释的过程会把上面这些细节全部过一遍很多坑也就是在过注释的时候提前发现的。这个习惯帮我少犯了不少低级错误也推荐你试试。