
最近在给一个下单接口做并发优化压测时发现线程大量阻塞在synchronized上CPU 利用率不高但接口 RT 就是下不去。排查到最后问题不在数据库而是锁竞争太激烈。后来把热点路径上的锁替换成java.util.concurrent.atomic包里的原子类用 CAS 无锁编程的思路重构了一版效果立竿见影。但紧接着又踩了 ABA 问题的坑才真正意识到无锁编程并不是拿掉锁就完事它有一套自己的规则和代价。这篇文章就围绕 CAS 与原子类这条主线把无锁编程的原理、底层实现、原子类家族、ABA 问题的来龙去脉一次说透。不管你是准备 Java 面试还是正在做高并发系统的性能优化这篇都能给你一套可以直接落地的思路和代码。1. 为什么非要无锁先看清锁的成本1.1 加锁到底贵在哪里很多初学者觉得锁就是“让线程排队”排队听上去也没什么成本。但实际上synchronized和ReentrantLock在竞争激烈时的开销远超想象。当多个线程竞争同一把锁时拿不到锁的线程会被操作系统挂起进入 BLOCKED 或 WAITING 状态。这个挂起动作涉及用户态到内核态的切换线程上下文要保存和恢复等锁释放后 JVM 还得唤醒线程让它重新参与调度。一次完整的阻塞-唤醒过程开销可以到微秒级别甚至更高。而一个 CAS 指令通常只需要几个纳秒性能差出两三个数量级。这就像一群人要通过一扇门你非要在门口安排一个保安逐个检查通行证。每个没带证的人都要被请到旁边坐着等等保安喊到名字才能再来。如果门本身只需要 1 毫秒就能通过而检查身份和叫人这个过程要花 50 毫秒那整个系统的效率就被这个“排队机制”拖垮了。锁竞争的另一个隐蔽成本是缓存失效。被锁保护的共享变量在多个 CPU 核心之间来回传递每次加锁解锁都会触发缓存一致性协议导致其他核心上的缓存行失效。这也是为什么有些场景下锁内代码明明很短性能却依然很差。1.2 无锁方案的整体思路无锁编程的核心思想是不再“阻塞没抢到资源的线程”而是让它们反复尝试直到成功为止。这个“反复尝试”依赖的就是 CAS 指令。CAS 的全称是 Compare-And-Swap翻译过来就是“比较并交换”。它的逻辑只有一句话只有当内存中的当前值等于我预期的值时才把新值写进去否则就失败。这个操作是原子的由 CPU 指令直接保证。所以无锁方案的本质是把“锁住一段代码”转变为“原子地操作一个变量”。你不需要让其他线程停下来只需要保证每次修改都能被正确感知谁先成功谁就赢没成功的重试就行。Java 给开发者开放的入口就是java.util.concurrent.atomic包下的各种原子类比如AtomicInteger、AtomicLong、AtomicReference。这些类底层封装了 CAS 操作屏蔽了Unsafe和VarHandle这些底层 API 的复杂度让无锁编程在业务代码中真正变得可用。1.3 无锁不是银弹我必须先说清楚无锁方案只适合“临界区极短”的场景也就是每次操作几行代码、几十个纳秒就能搞定的情况。如果临界区内有复杂计算、IO 操作、数据库调用那自旋重试的成本会非常高这时候老老实实加锁反而更合适。选型时我会先看一眼临界区代码的复杂度。如果里面超过 5 行逻辑或者有网络调用直接放弃无锁。如果一个操作只是对单个变量做递增、状态切换、引用替换那无锁方案基本上是首选。2. CAS 的核心原理与底层实现2.1 CAS 的语义定义CAS 操作有三个操作数内存位置 V、预期原值 E、新值 N。执行逻辑是如果 V 当前的值等于 E就把 V 更新为 N返回 true否则说明值已经被别人改过什么都不做返回 false。用AtomicInteger来看compareAndSet方法就是最典型的 CAS 入口AtomicInteger count new AtomicInteger(0); // 期望当前值是0如果确实是0就改成1 boolean result count.compareAndSet(0, 1);如果别的线程已经把它改成了 1那这次 CAS 就会失败返回 false。调用方拿到 false 之后可以根据业务决定是重试、放弃还是做其他处理。2.2 硬件层的实现cmpxchg 指令CAS 之所以能做到原子是因为它最终会落到 CPU 提供的原子指令上。在 x86 平台上对应的指令是cmpxchg。cmpxchg指令本身只能保证单核上的原子性在多核 CPU 上还需要配合lock前缀锁定总线或缓存行确保其他核心不会同时修改这个内存位置。JDK 在底层实现 CAS 时经过 JIT 编译后会生成带lock前缀的cmpxchg指令。所以 CAS 的原子性来源不是 JVM而是 CPU 指令集。这也是为什么无锁编程被称作“硬件级并发控制”。2.3 Java 层怎么调用 CAS早期的 Java 是通过sun.misc.Unsafe类来调用 CAS 的。Unsafe的compareAndSwapInt、compareAndSwapLong、compareAndSwapObject等方法直接对应底层 CAS 指令// 不推荐直接使用仅为理解原理 Unsafe unsafe ...; // 需要反射获取实例 long offset unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField(value)); boolean ok unsafe.compareAndSwapInt(atomicInt, offset, expectedValue, newValue);Unsafe虽然强大但官方一直不推荐业务代码直接使用。它绕过了 JVM 的各种安全检查出问题时会让 JVM 崩溃而不是抛异常调试起来极其困难。Java 9 之后引入了VarHandle作为操作对象字段的官方替代方案。VarHandle提供了compareAndSet等 API功能上和Unsafe差不多但安全和规范性更好。不过对于绝大多数开发者来说连VarHandle都不需要碰直接用原子类就够了。原子类内部已经把 CAS 封装好暴露出来的getAndSet、getAndIncrement、compareAndSet这些方法语义清晰用起来几乎没有坑。3. 原子类家族与日常使用实操3.1 原子类分类java.util.concurrent.atomic包在 JDK 1.5 引入经过这么多年发展已经是一个非常完整的工具集。按应用场景我习惯把它们分成五类分类代表类适用场景基础原子类AtomicInteger、AtomicLong、AtomicBoolean计数器、状态位、ID 生成数组原子类AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray数组元素需要原子更新的场景引用原子类AtomicReference、AtomicStampedReference、AtomicMarkableReference对象引用更新CAS 替换整个对象字段更新器AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater对一个已有对象中的某个字段做原子更新避免额外包装对象累加器LongAdder、LongAccumulator、DoubleAdder、DoubleAccumulator高并发下频繁累加统计每一类都有自己的设计目标理解它们的差异才能选出合适的工具。3.2 用 AtomicInteger 实现并发计数器最经典的使用场景是并发计数器。以前用synchronized或者ReentrantLock保护一个int变量现在可以直接用AtomicIntegerpublic class OrderCounter { private final AtomicInteger count new AtomicInteger(0); public int increment() { return count.incrementAndGet(); } public int get() { return count.get(); } }incrementAndGet()内部做的事情很简单一个 do-while 循环不断调 CAS直到成功。// AtomicInteger.getAndIncrement 内部逻辑简化 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // Unsafe.getAndAddInt 的核心自旋 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v delta)); return v; }这个自旋是有讲究的先读一次当前值然后尝试 CAS 到新值。如果 CAS 失败说明其他线程已经抢先修改那就用最新的值再试一次。只要竞争不是特别极端一两个循环内就能成功。3.3 竞争激烈时换 LongAdder如果并发量极高比如每秒几十万次的计数器累加AtomicLong也会遇到瓶颈。它的瓶颈不在 CAS 本身而在于所有线程都在争抢同一个内存地址导致缓存行频繁失效。LongAdder的优化思路是“热点分离”。它内部维护一个 base 值和一个 Cell 数组。当竞争不激烈时直接累加到 base当竞争变激烈时线程会被分散到不同的 Cell 中累加最终求和时把 base 和所有 Cell 的值加起来。LongAdder counter new LongAdder(); // 并发累加 counter.increment(); counter.add(5); // 获取总和 long total counter.sum();这就像一家奶茶店一个收银台忙不过来时就多开几个窗口。每个窗口各收各的钱打烊时把所有窗口的现金加起来就是当天总营收。LongAdder牺牲了一点“强一致性”换来的是超高并发下的吞吐量。要注意的是如果你需要精确读取某个时刻的值而且这个值正在被高频更新LongAdder的sum()方法只能给你一个近似值。它更适合做统计场景比如流量计数、监控指标不适合做需要严格一致性的业务判断。3.4 字段更新器省内存技巧AtomicIntegerFieldUpdater这个工具平时用得不多但有一类场景它特别合适对象已经有实体类不能随意改造成包装类型而且不想增加内存占用时。比如一个订单对象Order里有个int version字段希望在不把 Order 包一层 AtomicInteger 的情况下更新它public class Order { volatile int version 0; // 其他业务字段... } AtomicIntegerFieldUpdaterOrder versionUpdater AtomicIntegerFieldUpdater.newUpdater(Order.class, version); // 使用 Order order new Order(); versionUpdater.incrementAndGet(order);这里有个硬性要求被更新的字段必须声明为volatile否则字段更新器会直接抛异常。另外字段不能是 static 的不能是 final 的而且调用方必须对这个字段有访问权限。这个工具的好处是不用创建额外的原子对象适合在对象数量庞大、对内存敏感的系统里使用。4. 自旋与锁升级的边界4.1 自适应自旋到底是啥CAS 自带重试机制也就是自旋。但盲目自旋是有风险的如果临界区的竞争特别激烈线程可能在循环里空转几万次CPU 被白白耗掉性能反而比加锁更差。JDK 内部在多个地方使用了“自适应自旋”。简单说JVM 会记录上一次自旋等待的成功率和时间再据此动态调整下一次自旋的次数。如果上次自旋成功率高就多自旋一会儿如果总是失败就直接让线程阻塞。这个机制跟我们平时写代码的思路也一致第一遍先试压无锁方案如果压测发现自旋消耗太严重就需要考虑调整自旋次数或者干脆改回锁。4.2 synchronized 的锁升级和 CAS 有什么关系熟悉synchronized的人都知道它有锁升级的机制无锁 → 偏向锁 → 轻量级锁 → 重量级锁。其中偏向锁和轻量级锁的实现都借鉴了 CAS 的思路。偏向锁在获取时会用 CAS 把线程 ID 写入对象头 Mark Word如果成功就说明拿到了锁之后这个线程再次进入时几乎零成本。轻量级锁也是通过 CAS 把栈帧中的锁记录和对象头的 Mark Word 交换。这就是为什么 JDK 早期说synchronized性能不差因为它在低竞争下走的也是类 CAS 的无锁或轻量级路线。但有意思的是JDK 15 开始默认禁用了偏向锁JEP 374。原因很简单现代应用的服务端负载普遍较高偏向锁撤销的成本太高而且已经退化的偏向锁反而成了性能负担。这说明一个道理即使是 JVM 内部的优化也会随着应用场景和硬件环境而自我修正我们在业务代码里做技术选型时更应该有这种动态调整的意识。4.3 无锁与锁混用才是常见形态很多系统不是全程无锁也不是全程加锁。比如一个缓存组件读取路径上用volatile加原子引用实现近乎无锁的访问写路径上偶尔用synchronized做全局刷新。关键路径无锁低频路径加锁这是互联网高并发系统的常见姿态。我自己在写并发组件时的习惯是先画出共享变量的访问图识别出高频读写的热点对热点用原子类或 CAS对包含批量操作、复杂不变式维护的逻辑用锁。无锁和锁之间不是“谁必然替代谁”而是不同压力场景下的不同选择。5. ABA 问题到底怎么发生的5.1 一个最简单的 ABA 复现场景CAS 最大的陷阱就是 ABA 问题。它指的是内存中的值先是 A被某个线程改成了 B再改回 A。这时另一个线程执行 CAS发现当前值依然是 A和期望值相同于是 CAS 成功。但实际上这个值已经被动过两次手脚。我用一段代码来复现这个核心矛盾。假设我们用一个AtomicReference包装一个String值AtomicReferenceString ref new AtomicReference(A); // 线程1 准备做 CAS期望值是 A要改成 C // 在线程1 执行之前线程2 抢先把它从 A 改成 B再从 B 改成 A ref.compareAndSet(A, B); // 线程2第一次改动 ref.compareAndSet(B, A); // 线程2第二次改动 // 线程1 的 CAS 执行发现当前值确实是 A boolean result ref.compareAndSet(A, C); System.out.println(result); // 输出 true但中间已经被改过这个结果在数据逻辑上可能没问题因为最终确实还是 A。但在很多复杂场景下“值相同”不等于“对象状态相同”。5.2 无锁栈中的 ABA 才是致命伤纸上谈兵看不出 ABA 的危害真正致命的是在无锁数据结构里。拿无锁栈来说通常用 CAS 修改栈顶指针。一个节点被弹出后又被重新压入栈顶栈顶指针值不变但栈的中间结构已经完全变了。我用一个经典的无锁栈片段来说明class LockFreeStackT { AtomicReferenceNodeT top new AtomicReference(); public void push(T value) { NodeT newTop new Node(value); while (true) { NodeT oldTop top.get(); newTop.next oldTop; if (top.compareAndSet(oldTop, newTop)) { return; } } } public T pop() { while (true) { NodeT oldTop top.get(); if (oldTop null) return null; NodeT newTop oldTop.next; if (top.compareAndSet(oldTop, newTop)) { return oldTop.value; } } } }考虑这样的执行序列栈初始top → A → B → C线程 1 准备执行pop()读取到 oldTop 是 AnewTop 是 B在线程 1 执行 CAS 之前线程 2 先执行了一次pop()弹出 A栈变成 top → B → C线程 3 又pop()弹出 B栈变成 top → C线程 2 再把 A 重新push()回去栈变成 top → A → C这时线程 1 的 CAScompareAndSet(A, B)发现 top 确实是 A于是成功把 top 改成了 B但此时栈里根本没有 B 了。B 已经被线程 3 弹掉线程 1 把一个已经不在栈中的节点重新挂到了 top栈结构被彻底破坏。A 的下一个节点是线程 2 刚刚设置的 C但线程 1 根本不知道直接丢掉了 A 和 C 的关系。这个例子说明ABA 问题的本质不是“值被改过”而是“基于瞬时状态做决策时缺少对历史变更的感知”。CAS 只校验起点状态不校验中间过程在链表这类结构复杂的数据结构上就会造成逻辑错误和数据丢失。5.3 一个生活化的类比CAS 不感知 ABA就像你做事前看了一眼时钟发现显示 10 点整。过了一会儿你再看时钟还是 10 点整你就认为时间根本没动。但实际上时钟已经走了整整一圈期间过了 12 个小时。如果在这 12 小时里有人约定 10 点开一次会你没赶上那你只根据“现在还是 10 点”就认为一切正常就大错特错了。这就是 ABA 问题的直觉理解一个状态值相同不代表过程没有发生过变化。6. ABA 问题破解方案6.1 AtomicStampedReference版本号思想解决 ABA 问题最经典的手段就是加一个版本号。每次修改时同时更新版本号CAS 时既要比较引用值又要比较版本号。JDK 提供的AtomicStampedReference就是这么设计的。它的compareAndSet有四个参数期望引用、新引用、期望版本号、新版本号。AtomicStampedReferenceString ref new AtomicStampedReference(A, 0); int[] stampHolder new int[1]; String currentRef ref.get(stampHolder); int currentStamp stampHolder[0]; // 期望引用和版本号同时匹配才算 CAS 成功 boolean result ref.compareAndSet(currentRef, C, currentStamp, currentStamp 1);第二个线程再改动时版本号从 0 变成 1又从 1 变成 2。第一个线程期望的版本号是 0即使引用值被改回 A版本号已经对不上CAS 自然失败。这就是把“只看结果”升级成了“看结果和历史”。版本号的本质是给状态变化加上时间序列让每次修改都留下痕迹。6.2 AtomicMarkableReference只要知道有没有被改过和AtomicStampedReference类似但更轻量的是AtomicMarkableReference。它不是用递增的版本号而是用一个布尔值标记记录这个对象“是否被修改过”。它适用于一个更简单的场景你只需要知道对象有没有被其他线程动过而不需要知道动了几次。比如一个资源初始化流程初始化完成后标记置为 true之后任何人 CAS 时发现标记已经为 true就知道不能再初始化了。AtomicMarkableReferenceString ref new AtomicMarkableReference(A, false); // 期望引用是 A期望标记是 false更新引用为 B标记为 true boolean result ref.compareAndSet(A, B, false, true);需要注意一旦设置为 true就再也回不到 false 了。这个类和它的名字相呼应适合一次性状态变更场景。6.3 业务场景中的真实取舍不是所有遇到 ABA 的场景都要惊动版本号。在做决策前我会先问自己一个问题这个对象的值从 A 变成 B 再变回 A对业务逻辑是否有实际影响如果只是一个计数器从 1 加到 3 再减回 1CAS 认为它还是 1 并继续加这完全没问题。如果是一个无锁队列的节点引用ABA 会直接破坏数据结构那就必须用版本号或标记。在实际业务中我处理过一个例子优惠券领取状态。用户领了优惠券状态从“未领取”变成“已领取”不可能再回到“未领取”。那这种状态机迁移过程天然不会出现 ABA直接用AtomicReference就可以。但如果是“待支付 → 已支付 → 退款 → 待支付”这种可以回环的状态就必须加版本号否则并发退款场景下会漏判。这也是为什么 Java 提供了这么多原子类而不是一个 CAS 打天下不同场景需要不同的语义保证。7. 伪共享无锁编程里另一个隐蔽的坑7.1 缓存行和伪共享原理真正在高并发下压测原子类你还会遇到另一个性能杀手伪共享False Sharing。CPU 读写内存时不是按字节读取而是按缓存行读取。x86 平台的缓存行通常为 64 字节。也就是说如果两个共享变量落在同一个 64 字节缓存行里即使它们在逻辑上毫无关系其中一个被修改也会导致另一个所在的缓存行在整个缓存体系中失效。两个线程分别在修改两个“互不干扰”的变量但因为它们共享了缓存行CPU 要反复同步这个缓存行性能损耗巨大。这就是“伪共享”的含义逻辑上没有共享物理存储上却被迫共享。7.2 Java 里怎么处理伪共享解决伪共享的思路是让两个变量不要落在同一个缓存行里要么创建一个大结构把变量隔开要么用注解填充。JDK 内部的LongAdder的 Cell 数组就通过这种方式避免了多个 Cell 之间的伪共享。业务代码里如果需要手动规避可以使用字段填充public class PaddingLong { private volatile long value 0L; private long p1, p2, p3, p4, p5, p6, p7; // 填充占满一个缓存行 }Java 8 开始JDK 提供了Contended注解专门用于标记需要避免伪共享的字段。但使用它需要添加 JVM 参数-XX:-RestrictContended否则只能 JDK 内部类使用。public class Counter { Contended private volatile long count1 0L; Contended private volatile long count2 0L; }我在实际项目中通常不会一上来就优化伪共享而是先通过jol工具或者 CPU 性能分析器确认是否存在缓存行争用。伪共享的优化是把双刃剑它通过空间换时间本质是消耗 L1 缓存容量滥用反而会导致整体性能下降。8. 常见问题与排查技巧实录8.1 面试高频问题速查结合网上讨论最多的 Java 并发面试题整理了一张速查表面试问题回答要点延伸方向CAS 的底层原理是什么硬件指令 cmpxchg 加 lock 前缀JIT 编译后由 CPU 保证原子性Unsafe、VarHandle、JIT 编译CAS 有什么缺点ABA 问题、自旋空转消耗 CPU、只能保证单个变量的原子性锁升级、AtomicStampedReference讲一下 ABA 问题值被改回原状导致 CAS 误判成功无锁栈场景最典型版本号方案AtomicInteger 和 LongAdder 区别LongAdder 采用热点分离高并发累加性能更强但 sum 近似Cell 数组、伪共享volatile 和 CAS 有什么关系volatile 保证可见性和有序性CAS 提供原子性它们组合起来能达到无锁同步JMM、happens-beforesynchronized 和 CAS 怎么选临界区短用 CAS临界区长用锁竞争不激烈用 synchronized竞争激烈且对吞吐要求高时考虑无锁锁膨胀、自旋优化这六道题基本覆盖了面试中关于 CAS 和原子类的核心考点。答题时不要只背结论把每个问题背后的“为什么”讲清楚比如 CAS 为什么能原子、ABA 为什么危险、LongAdder 为什么快这些层次的回答才真正有区分度。8.2 实践中的五个坑第一AtomicReference只能保证引用本身的原子性对象内部字段的状态变化需要额外处理。如果你把多个状态字段塞进一个对象再用AtomicReference替换必须复制出一个新对象再 CAS不能直接修改原对象的内部字段然后 CAS否则会出现“引用没变内部已变”的错乱状态。第二CAS 自旋在高竞争下会带来 CPU 飙升。如果压测发现用原子类的接口反而比加锁慢先看竞争强度再看临界区长度。竞争激烈且临界区稍长的场景无锁并不是最优解。第三AtomicIntegerFieldUpdater对字段的volatile要求是硬性的编译期不检查只有运行期才报错。一旦字段忘了加volatile上线后就等着线上故障吧。我习惯写个单元测试专门验证字段更新器能正常工作。第四LongAdder.sum()不是强一致快照。虽然它的内部实现保证了最终一致性但遍历 Cell 数组求和的过程不是原子的。如果一个线程正在累加你同时求和结果可能缺几笔。如果你用它的值做业务判断比如“等于 0 就跳过”要考虑到这个近似性。第五无锁数据结构的测试难度远高于有锁版本。它的错误往往是偶发性的需要高并发压测才能复现。我自己做无锁队列或者无锁栈时都会额外跑一轮线程数远超 CPU 核数的压测并且用随机延迟放大并发窗口让 ABA 等问题更容易暴露。8.3 排查工具和定位思路碰到原子类相关的诡异问题我喜欢按“三层排查法”走第一层是看代码检查被 CAS 的字段有没有声明volatile检查更新器的对象访问权限检查循环里的退出条件是否正确。第二层是看日志和线程状态用jstack抓线程栈看有多少线程处于 RUNNABLE 状态却在空转。如果大量线程 RUNNABLE 但业务没有任何进展说明 CAS 自旋严重需要重新评估方案。第三层是看性能指标用jfr或者async-profiler抓 CPU 热点看看是哪个方法的 CAS 占比过高。同时观察缓存未命中率很多原子类性能问题根本不是代码逻辑问题而是伪共享或者缓存行颠簸。这套流程帮我在线上定位过几次问题基本涵盖了从逻辑错误到性能瓶颈的主要路径。最后分享一个我自己的体会无锁编程看起来是“不排队”实际上是把排队的职责交给了硬件和 JVM比加锁需要更多的谨慎。每当你准备用 CAS 替换一把锁先想清楚临界区是不是足够短、ABA 会不会带来业务危害、竞争激烈时自旋是否扛得住。如果这三个问题都能答清楚再去动手改造基本稳了。原子类是好工具但工具越好越要在使用前理解它的边界。