1. 从一次诡异的并发计数错误说起
那天下午,我盯着监控面板上一个持续跳动的计数器,心里咯噔一下。这是一个简单的用户在线状态统计服务,逻辑清晰:用户上线时,计数器加一,下线时减一。理论上,在任意时刻,这个数字都应该等于真实的在线用户数。但监控显示,这个数字偶尔会“漂移”——在没有任何用户上下线操作的时间窗口内,它自己会莫名其妙地减少几个,过一会儿又恢复。这显然不是网络延迟或数据上报丢失能解释的,因为丢失应该是单向的减少,而这种“漂移”更像是…数据被覆盖了。
我立刻把怀疑的目光投向了负责这个计数更新的方法。代码很简单,就一行:count = count + 1;或者count = count - 1;。在单线程世界里,这行代码坚如磐石。但在我们这个每秒处理数万次请求的高并发服务里,它就成了薛定谔的猫——你永远不知道执行这一刻的count值是不是已经被其他线程偷偷改掉了。这就是典型的竞态条件:多个线程在没有适当同步的情况下,同时读写共享变量,导致最终结果依赖于线程执行的精确时序,而这个时序是不可预测的。
为了解决这个问题,我最初的想法是加锁。用synchronized关键字把读写操作锁起来,或者用ReentrantLock。这确实能解决问题,保证同一时刻只有一个线程能操作count。但性能测试结果让人皱眉:在高并发场景下,锁带来的线程挂起、上下文切换开销,让整个服务的吞吐量下降了近30%。对于一个核心状态服务来说,这个代价有点大。
就在我纠结于锁的性能损耗时,团队里的老张走过来看了一眼,说:“试试AtomicInteger的incrementAndGet()吧,底层用的是 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)之类的汇编指令,由硬件保证其原子性。这就从最底层杜绝了“检查通过后,赋值前,值被其他线程修改”的可能。
举个例子,我们回到那个计数器问题。使用AtomicInteger的incrementAndGet(),其内部实现大致如下:
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 提供的AtomicReference或Unsafe类(后者不推荐直接使用)来实现一个最简单的自旋锁。这能让我们对“自旋”有更切身的体会。
下面是一个名为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,证明了我们自旋锁的有效性。
自己实现带来的思考:
- CPU 资源消耗:在
lock()方法的while循环中,如果锁被其他线程长期持有,当前线程就会一直空转,白白消耗 CPU 周期。这是自旋锁最显著的缺点。它适用于锁持有时间非常短的场景,此时线程自旋等待的代价低于被挂起再唤醒的上下文切换开销。 - 公平性问题:我们的
SimpleSpinLock是非公平锁。在锁释放的瞬间,哪个线程恰好执行到 CAS 操作,哪个就能抢到锁,不讲究先来后到。在高竞争下,可能导致某些线程“饥饿”。 - 可重入性:我们的锁是不可重入的。如果一个线程已经持有锁,再次调用
lock(),会因为owner不是null(而是它自己)而导致 CAS 失败,陷入死循环。这就是“重入”死锁。ReentrantLock是可重入的,内部维护了一个持有计数。
通过这个简单的实现,我们清晰地看到了自旋锁的骨骼:一个共享的状态变量(owner),一个 CAS 操作来竞争这个状态,以及一个循环来应对竞争失败。Java 中AtomicInteger等原子类的递增操作,其内部就包含了这样的自旋逻辑。而更复杂的锁,如ReentrantLock,在其非公平模式下的首次加锁尝试,也是基于类似的 CAS 操作。
4. CAS 的“阿喀琉斯之踵”:你必须知道的三大问题
CAS 并非银弹,它在带来高性能的同时,也引入了三个经典问题。理解这些问题,是正确使用 CAS 和自旋锁的前提。
4.1 ABA 问题:你看到的“没变”,可能已经沧海桑田
这是 CAS 最著名的一个陷阱。假设共享变量V的初始值是 A。
- 线程1读取
V为 A。 - 线程1被挂起。
- 线程2将
V从 A 改为 B。 - 线程3又将
V从 B 改回了 A。 - 线程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)。
在上面的链表例子中,即使引用从 A 变回 A,版本戳也从 0 变成了 2,CAS 就会失败。AtomicStampedReference<Node> headRef = new AtomicStampedReference<>(nodeA, 0); // 更新时,需要同时提供预期的引用和预期的版本戳 boolean success = headRef.compareAndSet(nodeA, nodeC, 0, 1); - 布尔标记:有些场景可以用一个额外的布尔标记位来表示对象是否已被修改过。
4.2 循环时间长带来的开销
正如我们实现自旋锁时看到的,如果竞争激烈,线程可能长时间循环空转,大量消耗 CPU 资源。虽然自旋避免了上下文切换的开销,但把 CPU 周期浪费在无意义的循环上,同样会降低系统的整体吞吐量。
解决方案:
- 自适应自旋:JVM 内部的锁优化(如 synchronized 的轻量级锁)和
ReentrantLock的实现中,就采用了自适应自旋。简单说,如果线程最近刚刚成功获得过锁,JVM 会认为这次自旋也很可能成功,就允许它多自旋一会儿。反之,如果很少成功,就可能直接挂起线程。这是一种基于历史经验的启发式优化。 - 手动退让:在自旋循环中,可以适时调用
Thread.yield()提示调度器让出 CPU,或者短暂睡眠(Thread.sleep(1)),但这需要谨慎控制,否则可能增加延迟。 - 升级为阻塞锁:当自旋超过一定阈值后,直接升级为传统的阻塞等待。
ReentrantLock在尝试 CAS 获取锁失败后,就会将线程放入等待队列并挂起。
4.3 只能保证一个共享变量的原子操作
CAS 指令本身是针对一个内存地址进行操作的。如果你想同时原子地更新两个独立的变量(比如i和j),一个 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 和自旋锁有了深入理解,再回头看ReentrantLock和synchronized,就能更清晰地做出技术选型。它们之间的关系和区别,可以用一个简单的表格来概括:
| 特性 | 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 能很好优化的场景。 |
如何选择?一个实用的决策流:
- 你的需求是否只是一个简单的原子整数/引用更新?如果是,直接用
AtomicInteger、AtomicLong、AtomicReference。这是最轻量、最快的选择。 - 你需要可中断、超时、公平锁、多个条件变量这些高级功能吗?如果需要,选
ReentrantLock。 - 以上都不是,只是一个普通的临界区需要保护?优先考虑
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(); }优点:
- 极致性能:
incrementAndGet()内部是 CAS 自旋,无锁,读写操作 (get) 也完全无竞争,性能最高。 - 代码简洁:和原始代码几乎一样简洁。
- 完全解决竞态:原子操作保证安全。
缺点:
- ABA 问题:在这个特定场景下,ABA 问题有影响吗?计数器从 5 变成 6 再变回 5,对于“当前在线人数”这个语义来说,没有影响。我们只关心最终值,不关心中间过程。所以 ABA 在这里不是问题。
- 自旋开销:在超高并发、极端竞争下,大量线程同时自旋尝试增加计数器,会导致 CPU 使用率飙升。但考虑到“用户上下线”这个操作频率相对于 CPU 周期来说并不算极端密集,且
AtomicInteger的自旋实现非常高效,这个开销通常是可接受的。
最终选择与实测:我们选择了方案三。原因很直接:这个场景完美契合 CAS 的优势——操作简单(增减1)、竞争时间极短、不关心 ABA 问题。部署上线后,那个诡异的计数“漂移”现象彻底消失,服务的 CPU 使用率和吞吐量相比最初的错误版本和加锁版本都有显著改善。AtomicInteger成为了这个计数器场景下的最优解。
这个案例告诉我们,技术选型没有绝对的好坏,只有是否适合。CAS 和自旋锁是并发工具箱里一把锋利的手术刀,在合适的场景下(短临界区、低竞争度、简单原子操作),它能以最小的开销解决同步问题。但在错误的场景下(长临界区、高竞争、复杂操作),它可能会带来更糟的性能和更复杂的 Bug。理解其原理和边界,才能做出最明智的选择。