news 2026/8/15 4:16:28

深入解析CAS与自旋锁:高并发场景下的无锁编程利器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析CAS与自旋锁:高并发场景下的无锁编程利器

1. 从一次诡异的并发计数错误说起

那天下午,我盯着监控面板上一个持续跳动的计数器,心里咯噔一下。这是一个简单的用户在线状态统计服务,逻辑清晰:用户上线时,计数器加一,下线时减一。理论上,在任意时刻,这个数字都应该等于真实的在线用户数。但监控显示,这个数字偶尔会“漂移”——在没有任何用户上下线操作的时间窗口内,它自己会莫名其妙地减少几个,过一会儿又恢复。这显然不是网络延迟或数据上报丢失能解释的,因为丢失应该是单向的减少,而这种“漂移”更像是…数据被覆盖了。

我立刻把怀疑的目光投向了负责这个计数更新的方法。代码很简单,就一行:count = count + 1;或者count = count - 1;。在单线程世界里,这行代码坚如磐石。但在我们这个每秒处理数万次请求的高并发服务里,它就成了薛定谔的猫——你永远不知道执行这一刻的count值是不是已经被其他线程偷偷改掉了。这就是典型的竞态条件:多个线程在没有适当同步的情况下,同时读写共享变量,导致最终结果依赖于线程执行的精确时序,而这个时序是不可预测的。

为了解决这个问题,我最初的想法是加锁。用synchronized关键字把读写操作锁起来,或者用ReentrantLock。这确实能解决问题,保证同一时刻只有一个线程能操作count。但性能测试结果让人皱眉:在高并发场景下,锁带来的线程挂起、上下文切换开销,让整个服务的吞吐量下降了近30%。对于一个核心状态服务来说,这个代价有点大。

就在我纠结于锁的性能损耗时,团队里的老张走过来看了一眼,说:“试试AtomicIntegerincrementAndGet()吧,底层用的是 CAS,没锁,性能好。” 这是我第一次在工作中被直接点明去用 CAS。AtomicInteger我倒是用过,知道它是线程安全的,但对其底层原理compareAndSet(比较并交换,也就是 CAS)以及与之相关的“自旋”概念,当时只有一个模糊的印象。这次诡异的计数错误,逼着我必须把 CAS 和自旋锁彻底搞明白。这不仅是为了修复一个 Bug,更是为了在日后设计高性能并发程序时,手里能多一件称手的兵器。

2. CAS 的核心思想:乐观的并发控制哲学

要理解 CAS,首先要跳出“锁”的思维定式。传统的互斥锁(如synchronized)是一种悲观锁。它悲观地认为,只要有多线程访问共享数据,就一定会发生冲突。所以,它采取了一种保守策略:在我访问数据之前,先加锁,把其他所有线程都挡在外面,等我操作完了,释放锁,你们再进来。这就像只有一个洗手间的办公室,一个人进去就锁门,其他人只能在门口排队等着。

而 CAS 代表的是一种乐观锁的哲学。它乐观地认为,线程间不总是会发生冲突。所以,它在操作数据时,不会先去获取锁,而是直接去尝试修改。但为了安全,它在修改前会做一次检查:我认为现在这个变量的值应该是 A,如果它真的是 A,那我就把它改成 B;如果我发现它已经不是 A 了(说明在我“认为”和“修改”之间的极短间隙里,有其他线程改动了它),那么我这次修改就失败,不会执行。这个过程是原子的,不可被中断。

这个“检查-设置”的操作,就是compareAndSet。它的工作流程可以抽象为以下伪代码:

boolean compareAndSet(预期值 expectedValue, 新值 newValue) { if (当前内存值 == expectedValue) { 当前内存值 = newValue; return true; // 成功 } else { return false; // 失败 } }

关键在于,整个if判断和赋值操作是一条不可分割的 CPU 指令。在现代多核处理器上,这条指令通常对应着CMPXCHG(Compare and Exchange)之类的汇编指令,由硬件保证其原子性。这就从最底层杜绝了“检查通过后,赋值前,值被其他线程修改”的可能。

举个例子,我们回到那个计数器问题。使用AtomicIntegerincrementAndGet(),其内部实现大致如下:

public final int incrementAndGet() { for (;;) { // 这是一个自旋循环 int current = get(); // 获取当前值,比如是5 int next = current + 1; // 计算期望的下一个值6 if (compareAndSet(current, next)) { // 核心:如果当前值还是5,就设置为6 return next; // 成功,返回6 } // 如果失败,说明current已经不是5了(比如被其他线程改成了8),循环重试 } }

你会发现,CAS 操作本身是原子的、快速的,但它处理竞争失败的方式是重试。线程不会被挂起,而是立刻在原地循环,再次读取当前值,重新计算,然后发起新一轮的 CAS。这种通过循环不断尝试的模式,就是“自旋”的由来。而基于 CAS 构建的、通过循环重试来实现互斥的锁,就被称为自旋锁

注意:CAS 是一种底层原语,而自旋锁是利用 CAS 原语实现的一种锁策略。我们常说的“CAS 自旋锁”,指的是这种实现方式。

3. 自旋锁的实战:自己实现一个简易版本

理解了 CAS 的思想后,我们完全可以自己动手,利用 Java 提供的AtomicReferenceUnsafe类(后者不推荐直接使用)来实现一个最简单的自旋锁。这能让我们对“自旋”有更切身的体会。

下面是一个名为SimpleSpinLock的极简实现:

import java.util.concurrent.atomic.AtomicReference; public class SimpleSpinLock { // 使用AtomicReference来持有锁的“所有者”,初始值为null表示锁空闲 private AtomicReference<Thread> owner = new AtomicReference<>(); // 加锁 public void lock() { Thread currentThread = Thread.currentThread(); // 关键:通过CAS尝试将owner从null设置为当前线程 // 如果失败(owner不是null),说明锁被其他线程持有,就循环重试(自旋) while (!owner.compareAndSet(null, currentThread)) { // 空循环,即“自旋”。在实际中,可能会加入一些优化,如Thread.yield() // 但纯空循环在高竞争下会浪费大量CPU } System.out.println(currentThread.getName() + " 获取了锁"); } // 解锁 public void unlock() { Thread currentThread = Thread.currentThread(); // 解锁时,通过CAS将owner从当前线程设置回null // 只有锁的持有者才能解锁,这里简化了检查 if (owner.compareAndSet(currentThread, null)) { System.out.println(currentThread.getName() + " 释放了锁"); } else { // 非持有者尝试解锁,这里可以抛出异常 throw new IllegalMonitorStateException("Attempt to unlock without holding the lock"); } } }

我们来写个测试用例,模拟两个线程竞争这个锁:

public class SpinLockTest { private static int counter = 0; private static SimpleSpinLock lock = new SimpleSpinLock(); public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { lock.lock(); // 获取自旋锁 try { counter++; // 临界区操作 } finally { lock.unlock(); // 释放锁 } } }; Thread t1 = new Thread(task, "Thread-1"); Thread t2 = new Thread(task, "Thread-2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final counter value: " + counter); // 正确输出 20000 } }

运行这个程序,你会看到控制台交替打印“获取了锁”和“释放了锁”的信息,并且最终的counter值一定是 20000,证明了我们自旋锁的有效性。

自己实现带来的思考:

  1. CPU 资源消耗:在lock()方法的while循环中,如果锁被其他线程长期持有,当前线程就会一直空转,白白消耗 CPU 周期。这是自旋锁最显著的缺点。它适用于锁持有时间非常短的场景,此时线程自旋等待的代价低于被挂起再唤醒的上下文切换开销。
  2. 公平性问题:我们的SimpleSpinLock非公平锁。在锁释放的瞬间,哪个线程恰好执行到 CAS 操作,哪个就能抢到锁,不讲究先来后到。在高竞争下,可能导致某些线程“饥饿”。
  3. 可重入性:我们的锁是不可重入的。如果一个线程已经持有锁,再次调用lock(),会因为owner不是null(而是它自己)而导致 CAS 失败,陷入死循环。这就是“重入”死锁。ReentrantLock是可重入的,内部维护了一个持有计数。

通过这个简单的实现,我们清晰地看到了自旋锁的骨骼:一个共享的状态变量(owner),一个 CAS 操作来竞争这个状态,以及一个循环来应对竞争失败。Java 中AtomicInteger等原子类的递增操作,其内部就包含了这样的自旋逻辑。而更复杂的锁,如ReentrantLock,在其非公平模式下的首次加锁尝试,也是基于类似的 CAS 操作。

4. CAS 的“阿喀琉斯之踵”:你必须知道的三大问题

CAS 并非银弹,它在带来高性能的同时,也引入了三个经典问题。理解这些问题,是正确使用 CAS 和自旋锁的前提。

4.1 ABA 问题:你看到的“没变”,可能已经沧海桑田

这是 CAS 最著名的一个陷阱。假设共享变量V的初始值是 A。

  1. 线程1读取V为 A。
  2. 线程1被挂起。
  3. 线程2将V从 A 改为 B。
  4. 线程3又将V从 B 改回了 A。
  5. 线程1恢复运行,执行 CAS(V, 预期值=A, 新值=C)。此时它检查V的值,发现确实是 A!于是 CAS 成功,将 A 改为了 C。

从线程1的视角看,V的值没变过(一直是A),所以我的修改是合理的。但事实上,V已经经历了一个 A->B->A 的变化过程。这个中间状态 B 可能蕴含着重要的业务信息。例如,V是一个链表的头节点引用。A 是头节点,线程1想用 CAS 把 A 替换成 C。在线程1挂起期间,线程2移除了 A,插入了 B,然后线程3又移除了 B,而 A 恰好被垃圾回收后,另一个新对象复用了这块内存地址(或者就是原来的 A 对象被重新插入)。对于线程1的 CAS 来说,它成功了,但这可能破坏链表的结构。

解决方案:

  • 版本号/时间戳:最常见的解决方案。不单纯比较值,而是比较“值+版本号”。每次变量被更新,版本号都递增。Java 中的AtomicStampedReference就是为此而生。它维护了一个Pair对象,包含引用和一个int类型的版本戳(stamp)。
    AtomicStampedReference<Node> headRef = new AtomicStampedReference<>(nodeA, 0); // 更新时,需要同时提供预期的引用和预期的版本戳 boolean success = headRef.compareAndSet(nodeA, nodeC, 0, 1);
    在上面的链表例子中,即使引用从 A 变回 A,版本戳也从 0 变成了 2,CAS 就会失败。
  • 布尔标记:有些场景可以用一个额外的布尔标记位来表示对象是否已被修改过。

4.2 循环时间长带来的开销

正如我们实现自旋锁时看到的,如果竞争激烈,线程可能长时间循环空转,大量消耗 CPU 资源。虽然自旋避免了上下文切换的开销,但把 CPU 周期浪费在无意义的循环上,同样会降低系统的整体吞吐量。

解决方案:

  • 自适应自旋:JVM 内部的锁优化(如 synchronized 的轻量级锁)和ReentrantLock的实现中,就采用了自适应自旋。简单说,如果线程最近刚刚成功获得过锁,JVM 会认为这次自旋也很可能成功,就允许它多自旋一会儿。反之,如果很少成功,就可能直接挂起线程。这是一种基于历史经验的启发式优化。
  • 手动退让:在自旋循环中,可以适时调用Thread.yield()提示调度器让出 CPU,或者短暂睡眠(Thread.sleep(1)),但这需要谨慎控制,否则可能增加延迟。
  • 升级为阻塞锁:当自旋超过一定阈值后,直接升级为传统的阻塞等待。ReentrantLock在尝试 CAS 获取锁失败后,就会将线程放入等待队列并挂起。

4.3 只能保证一个共享变量的原子操作

CAS 指令本身是针对一个内存地址进行操作的。如果你想同时原子地更新两个独立的变量(比如ij),一个 CAS 指令办不到。

解决方案:

  • 封装成对象:将多个需要原子更新的变量封装到一个不可变对象里,然后使用AtomicReference来更新这个对象引用。这实际上是把对多个变量的操作,转化为对一个引用变量的 CAS 操作。
    public class Point { public final int x, y; // 不可变 public Point(int x, int y) { this.x = x; this.y = y;} } AtomicReference<Point> pointRef = new AtomicReference<>(new Point(0, 0)); // 原子地同时更新x和y Point oldP = pointRef.get(); Point newP = new Point(oldP.x + 1, oldP.y + 1); while (!pointRef.compareAndSet(oldP, newP)) { oldP = pointRef.get(); newP = new Point(oldP.x + 1, oldP.y + 1); }
  • 使用锁:如果逻辑非常复杂,或者涉及多个完全不相关的共享资源,使用互斥锁往往是更清晰、更安全的选择。不要为了用 CAS 而用 CAS。

5. 场景抉择:CAS/自旋锁 vs ReentrantLock vs synchronized

现在我们对 CAS 和自旋锁有了深入理解,再回头看ReentrantLocksynchronized,就能更清晰地做出技术选型。它们之间的关系和区别,可以用一个简单的表格来概括:

特性CAS / 自旋锁 (如AtomicInteger)ReentrantLock(默认非公平)synchronized
实现机制乐观锁,硬件指令(CMPXCHG)保证原子性,失败则自旋重试。内部基于AbstractQueuedSynchronizer (AQS),首次尝试使用 CAS,失败后入队阻塞。JVM 内置监视器锁,通过monitorenter/monitorexit字节码实现。
锁粒度无锁(仅针对特定变量)或细粒度锁(自旋锁)。显式锁,粒度由开发者控制。隐式锁,粒度是对象或类。
性能特点极低延迟,无上下文切换,适合锁持有时间极短、竞争不激烈的场景。自旋消耗 CPU。灵活,可尝试获取、可定时、可中断。在高竞争、锁持有时间长时,性能通常优于synchronized(因优化策略更灵活)。早期性能较差,但经过 JVM 大量优化(偏向锁、轻量级锁、自旋适应、锁粗化、消除等),在大多数常见场景下性能已非常好,且仍在持续优化。
功能特性仅提供原子更新,功能单一。自旋锁需自己实现高级功能。功能丰富:可重入、可中断、可超时、可设置公平/非公平、支持多个条件变量。功能简单:可重入、非公平、不可中断、不可超时、单个隐式条件变量(wait/notify)。
编程复杂度中高。需要开发者深刻理解内存可见性、ABA 问题等,正确实现有难度。中。需要显式地lock()unlock()(必须在finally中),易出错。。语法简洁,自动释放锁,不易出错。
适用场景1. 简单的计数器、状态标志位更新。
2. 实现无锁数据结构(如并发队列)。
3. 作为更高级同步器(如ReentrantLock)的基础构件。
1. 需要高级功能(如可中断、超时、公平性)。
2. 竞争激烈且锁持有时间较长的场景。
3. 需要多个等待条件(Condition)。
1. 大多数常规的同步场景。
2. 追求代码简洁和开发效率。
3. JVM 能很好优化的场景。

如何选择?一个实用的决策流:

  1. 你的需求是否只是一个简单的原子整数/引用更新?如果是,直接用AtomicIntegerAtomicLongAtomicReference。这是最轻量、最快的选择。
  2. 你需要可中断、超时、公平锁、多个条件变量这些高级功能吗?如果需要,选ReentrantLock
  3. 以上都不是,只是一个普通的临界区需要保护?优先考虑synchronized。理由如下:
    • 开发友好:语法简单,不易漏写解锁。
    • 持续优化:它是 Java 语言的一部分,享受 JVM 团队最高优先级的优化。在 JDK 不断迭代中,其性能差距与ReentrantLock在很多场景下已微乎其微。
    • 未来可期:随着 Project Loom 的推进(虚拟线程),synchronized与虚拟线程的协作可能更自然。
    • 足够好用:对于 90% 的并发控制场景,synchronized的功能已经足够。

关于“自旋”的误区澄清:很多人认为synchronized是“重量级锁”,一上来就挂起线程。这是过时的认知。现代 JVM 中,synchronized的获取也经历了“偏向锁->轻量级锁(自旋)->重量级锁”的锁升级过程。当竞争不激烈时,它也会先尝试自旋(轻量级锁),失败后才真正挂起线程(膨胀为重量级锁)。也就是说,synchronized内部也使用了自旋优化。所以,单纯因为“自旋更快”而选择自己实现自旋锁或ReentrantLock,在很多情况下可能并没有优势,反而增加了复杂度。

6. 从理论到实践:一个真实场景的优化案例

让我们回到文章开头的那个在线计数器问题。最初版本是count++,存在竞态条件。我们分析了三种改进方案:

方案一:使用synchronized

private int count = 0; public synchronized void userOnline() { count++; } public synchronized void userOffline() { count--; } public synchronized int getOnlineCount() { return count; }

优点:简单,安全,代码清晰。缺点:所有增减和读取操作都需要获取同一把锁,在高并发读(getOnlineCount调用频繁)时,会产生不必要的竞争,影响吞吐量。

方案二:使用ReentrantLock

private int count = 0; private final ReentrantLock lock = new ReentrantLock(); public void userOnline() { lock.lock(); try { count++; } finally { lock.unlock(); } } // ... 类似实现 offline 和 get

优点:和synchronized效果类似,但我们可以选择公平锁,或者尝试非阻塞获取锁(tryLock)。缺点:同样存在读写竞争问题,且代码更冗长。

方案三:使用AtomicInteger(CAS)

private AtomicInteger count = new AtomicInteger(0); public void userOnline() { count.incrementAndGet(); } public void userOffline() { count.decrementAndGet(); } public int getOnlineCount() { return count.get(); }

优点

  1. 极致性能incrementAndGet()内部是 CAS 自旋,无锁,读写操作 (get) 也完全无竞争,性能最高。
  2. 代码简洁:和原始代码几乎一样简洁。
  3. 完全解决竞态:原子操作保证安全。

缺点

  1. ABA 问题:在这个特定场景下,ABA 问题有影响吗?计数器从 5 变成 6 再变回 5,对于“当前在线人数”这个语义来说,没有影响。我们只关心最终值,不关心中间过程。所以 ABA 在这里不是问题。
  2. 自旋开销:在超高并发、极端竞争下,大量线程同时自旋尝试增加计数器,会导致 CPU 使用率飙升。但考虑到“用户上下线”这个操作频率相对于 CPU 周期来说并不算极端密集,且AtomicInteger的自旋实现非常高效,这个开销通常是可接受的。

最终选择与实测:我们选择了方案三。原因很直接:这个场景完美契合 CAS 的优势——操作简单(增减1)、竞争时间极短、不关心 ABA 问题。部署上线后,那个诡异的计数“漂移”现象彻底消失,服务的 CPU 使用率和吞吐量相比最初的错误版本和加锁版本都有显著改善。AtomicInteger成为了这个计数器场景下的最优解。

这个案例告诉我们,技术选型没有绝对的好坏,只有是否适合。CAS 和自旋锁是并发工具箱里一把锋利的手术刀,在合适的场景下(短临界区、低竞争度、简单原子操作),它能以最小的开销解决同步问题。但在错误的场景下(长临界区、高竞争、复杂操作),它可能会带来更糟的性能和更复杂的 Bug。理解其原理和边界,才能做出最明智的选择。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/15 4:14:20

从迷茫到聚焦:财经学生如何构建个人成长系统与技能体系

1. 项目概述&#xff1a;一次关于成长与理想的深度复盘“眼里有光&#xff0c;心中有理想”&#xff0c;这十个字听起来像一句常见的励志口号&#xff0c;但当我真正坐下来&#xff0c;试图复盘自己从一名普通学生到如今在财经领域找到方向、并持续前行的这段旅程时&#xff0c…

作者头像 李华
网站建设 2026/8/15 4:11:37

SystemVerilog $cast深度解析:类型安全转换与UVM验证实践

1. 项目概述&#xff1a;深入理解SystemVerilog中的$cast在SystemVerilog&#xff08;SV&#xff09;的世界里&#xff0c;数据类型转换是连接不同抽象层次、实现灵活设计的桥梁。无论是从验证平台到设计接口&#xff0c;还是从随机化约束到记分板比对&#xff0c;类型转换无处…

作者头像 李华
网站建设 2026/8/15 4:10:54

【计算机毕业设计单片机案例】 基于单片机的双模式温湿度阈值控制风扇系统开发 基于 STC89C52 单片机的物联网基础环境感知智能风扇设计(012703)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/8/15 4:10:35

从流水线到乱序执行:现代CPU性能优化的核心技术演进

1. 从“一条指令”到“千军万马”&#xff1a;现代CPU性能的演进逻辑如果你拆开一台电脑或服务器&#xff0c;看到那颗小小的CPU芯片&#xff0c;可能会好奇它究竟是如何工作的。很多人对CPU的理解还停留在“主频越高越快”的层面&#xff0c;这其实是一个巨大的误区。主频就像…

作者头像 李华
网站建设 2026/8/15 4:09:06

Suno Studio 2.0前瞻:AI音乐生成原理、Prompt工程与API集成指南

最近在AI音乐生成领域&#xff0c;Suno AI的动向备受关注。作为一个专注于AI音乐创作的平台&#xff0c;Suno Studio的每一次迭代都牵动着创作者和开发者的心。从最初的惊艳亮相到逐步完善&#xff0c;Suno Studio已经成为了许多音乐爱好者和内容创作者探索AI音乐可能性的首选工…

作者头像 李华
网站建设 2026/8/15 4:07:33

AI 电动园艺喷水控制器智能功率 MOSFET 完整选型方案

随着 AI 技术在智能园艺中的普及&#xff08;如智能灌溉、水量精准控制、环境适应算法&#xff09;&#xff0c;电动喷水控制器对功率 MOSFET 提出新要求&#xff1a;高效率、快速响应、高可靠性及微型化。微碧半导体&#xff08;VBsemi&#xff09;基于 SGT 及 Trench 工艺&am…

作者头像 李华