在现代高并发应用的开发中,如何保证数据在多线程环境下的安全性,同时又能保持系统的高性能,一直是我们面临的核心挑战。如果仅仅依赖传统的 synchronized 锁机制,虽然安全,但在高竞争场景下往往会引起线程频繁的上下文切换,导致性能瓶颈。
那么,有没有一种方法,既能让我们不加锁(避免线程挂起),又能保证共享变量的原子性操作呢?答案是肯定的。这就是我们今天要深入探讨的主角——比较并交换。
在这篇文章中,我们将通过实际代码和底层原理,带你理解 CAS 是如何工作的,它为何能比锁机制更快,以及在 Java 开发中我们该如何通过 Atomic 类库来优雅地使用它。无论你是正在构建高性能后端系统,还是对并发编程原理感兴趣,这篇深度解析都将为你提供实用的知识和见解。
什么是 CAS (Compare and Swap)?
比较并交换(Compare and Swap,简称 CAS),其核心思想非常直观:它包含三个操作数——内存值 (V)、预期原值 (A) 和 新值 (B)。
我们在执行更新操作时,会先将内存中的当前值 V 与我们的预期值 A 进行比较:
- 如果相等:说明在我们准备操作的这一瞬间,没有其他线程修改过该变量,我们可以安全地将内存值 V 更新为新值 B。
- 如果不相等:说明在这期间有其他线程抢先修改了变量,那么本次操作失败(或者你可以选择重新尝试,这也就是自旋锁的由来)。
为什么要用 CAS?
为了理解 CAS 的价值,我们需要先回顾一下在多线程环境下最常见的一个陷阱——“检查再行动” 模式,也就是我们常说的 Check-Then-Act。
#### 危险的“检查再行动”模式
想象一下,你正在编写一个简单的互斥锁逻辑。代码看起来可能像这样自然且易懂:
// 危险的代码示例:不要在生产环境中使用!
class UnsafeLock {
private boolean locked = false;
public boolean lock() {
// 1. 检查:如果锁是空闲的
if (!locked) {
// 模拟一些耗时操作,增加并发冲突发生的概率
try { Thread.sleep(10); } catch (Exception e) {}
// 2. 行动:将锁设置为占用状态
locked = true;
return true;
}
return false;
}
}
这段代码在单线程环境下完美运行,但在多线程环境下却是一场灾难。
让我们推演一下:假设 线程 A 和 线程 B 同时调用 lock() 方法。
- 线程 A 读取 INLINECODEc5ad0316 为 INLINECODE33f98ffd,觉得太棒了,可以通过。
- 就在线程 A 还没来得及执行 INLINECODEbd9f56a2 的那一瞬间(甚至在它 sleep 的时候),线程 B 也读取了 INLINECODE6e4eefbd 变量。
- 因为线程 A 还没来得及修改它,线程 B 读到的依然是
false。 - 结果:两个线程都认为锁是空闲的,都成功进入了临界区。并发安全荡然无存。
#### 传统解决方案:synchronized
为了解决这个问题,我们通常会引入 synchronized 关键字,将“检查”和“行动”这两个步骤原子化,强行让它们不可分割。
class SafeLock {
private boolean locked = false;
// 使用 synchronized 关键字保证原子性
public synchronized boolean lock() {
if (!locked) {
locked = true;
return true;
}
return false;
}
}
这样确实安全了。但是,这种做法的代价是重量级的锁机制。当线程竞争激烈时,没有抢到锁的线程会被挂起(阻塞),操作系统需要介入进行线程上下文切换。这个过程消耗的资源远多于代码执行本身。对于仅仅修改一个 boolean 变量这样的简单操作,这种开销显得有些“杀鸡用牛刀”。
#### 终极解决方案:CAS 算法
如果我们能在硬件层面保证“检查”和“赋值”这两个动作是原子的,是不是就不需要加锁了?是的,这正是 CAS 的威力所在。
CAS 依靠的是底层硬件指令(在大多数现代处理器上是 cmpxchg 指令),从而在操作系统层面保证了这个操作的原子性,不需要操作系统的调度介入。这意味着它更快,不会引起线程挂起。
在 Java 中实现 CAS:Atomic 类库
从 Java 5 开始,java.util.concurrent.atomic 包为我们提供了一系列类,让我们可以轻松使用 CAS 算法。这些类通常被称为原子变量类。
最常用的包括:
-
AtomicBoolean -
AtomicInteger -
AtomicLong -
AtomicReference
它们内部都封装了一个核心方法:compareAndSet(expect, update)。这个方法会直接调用底层的 CAS 操作。
#### 示例 1:使用 AtomicBoolean 重写锁机制
让我们回到之前的锁的例子。如果我们使用 AtomicBoolean,代码会变得极其简洁且高效。
import java.util.concurrent.atomic.AtomicBoolean;
public class CASLockDemo {
// 初始值为 false
private static AtomicBoolean locked = new AtomicBoolean(false);
public static void main(String[] args) {
// 模拟多个线程尝试获取锁
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
// 尝试获取锁
boolean isLocked = tryLock();
if (isLocked) {
System.out.println(threadName + " 成功获取了锁!正在执行关键任务...");
try {
// 模拟业务操作
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
System.out.println(threadName + " 释放了锁。");
}
} else {
System.out.println(threadName + " 获取锁失败,资源正忙。");
}
};
// 启动 3 个线程进行测试
new Thread(task, "线程 A").start();
new Thread(task, "线程 B").start();
new Thread(task, "线程 C").start();
}
/**
* 尝试加锁的方法
* 逻辑:如果当前值是 false(未锁),则将其设为 true(已锁)
*/
public static boolean tryLock() {
// compareAndSet 的意思是:
// "我看了一眼当前的值,如果是 false,我就把它改成 true 并返回 true。
// 如果不是 false(说明被别人改了),我就什么都不做,并返回 false。"
return locked.compareAndSet(false, true);
}
/**
* 释放锁
* 注意:为了简单起见,这里直接设为 false。
* 在实际生产代码中,通常需要检查当前线程是否真的是持有锁的线程。
*/
public static void unlock() {
locked.set(false);
}
}
代码解析:
在这个例子中,INLINECODE2cd1d6a3 方法不再需要 INLINECODE0a76d20d。INLINECODEf3ecf033 这一行代码利用了 CPU 的 CAS 指令。在同一时刻,只有一个线程能观察到 INLINECODE80dbd125 并将其成功更新为 INLINECODEa9e88172,其他线程会看到 INLINECODEe35d1577,从而 CAS 操作失败,返回 false。
#### 示例 2:并发计数器 AtomicInteger
除了布尔锁,最常见的应用场景就是计数器。比如,我们需要在多线程环境下统计网站访问量。
如果使用普通的 INLINECODE8c6fbb5b,这在字节码层面其实包含了“取值、加一、写回”三个步骤,并非原子操作。使用 INLINECODE9113c199 可以轻松解决这个问题。
import java.util.concurrent.atomic.AtomicInteger;
public class CounterDemo {
// 创建一个原子整数,初始值为 0
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
// 使用 incrementAndGet 方法原子性地将值加 1
count.incrementAndGet();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
// 期望输出 2000,因为每个线程加了 1000 次
System.out.println("最终计数值: " + count.get());
}
}
深入理解 compareAndSet
你可能会问,incrementAndGet() 内部是怎么实现的?其实它就是在一个循环中不断调用 CAS。我们可以手动模拟一下这个过程,这能让你更深刻地理解 CAS 的“乐观”特性。
import java.util.concurrent.atomic.AtomicInteger;
public class ManualCASDemo {
public static void main(String[] args) {
AtomicInteger val = new AtomicInteger(0);
// 模拟 updateAndGet 的逻辑:将原值加 10
int updateResult = updateAndGet(val, 10);
System.out.println("更新后的值: " + updateResult);
}
/**
* 模拟原子更新操作:取当前值,加上 delta,然后更新回变量。
* 我们手动实现这个逻辑来展示 CAS 的工作流程。
*/
public static int updateAndGet(AtomicInteger atomic, int delta) {
int prev;
int next;
do {
// 1. 先获取当前的值
prev = atomic.get();
// 2. 计算新的值
next = prev + delta;
// 3. 尝试更新。
// compareAndSet(prev, next) 的意思是:
// "如果我刚才看到的 prev 还是现在的内存值,就把它改成 next。"
// 如果返回 false,说明在计算 next 的时候,别的线程修改了值,
// 这时 do-while 循环会重新读取最新值,重新计算,重新尝试,直到成功为止。
} while (!atomic.compareAndSet(prev, next));
return next;
}
}
这个 do-while 循环就是所谓的自旋。我们并没有阻塞线程,而是一直在尝试直到成功。这通常比阻塞等待更高效,但在竞争极其激烈的情况下(大量线程同时修改同一个变量),可能会导致 CPU 空转(忙等待),浪费资源。
常见的原子类概览
除了基本的 INLINECODE92de967e 和 INLINECODE44b35317,Java 还提供了更多强大的原子类来适应不同的数据结构:
- INLINECODE02bd56fd: 用于对 INLINECODE54507cf1 类型进行原子操作,提供了 INLINECODE61f6483a、INLINECODE29c151bc 等方法。
- INLINECODE6255381b: 适用于 INLINECODEd37d9999 类型,常用于生成序列号或高精度的时间戳。
-
AtomicReference: 可以原子性地更新对象引用。这对于实现无锁的数据结构(如无锁链表节点)非常重要。 - 数组类型: INLINECODE95783df4, INLINECODE9888cdc3,
AtomicReferenceArray。它们可以保证对数组中某个元素的修改是原子的。
#### 示例 3:AtomicReference 使用场景
假设你有一个“当前最佳配置”对象,多个线程可能会尝试更新它,只有更新后的版本比旧的版本更新时才允许替换。
import java.util.concurrent.atomic.AtomicReference;
class Config {
int version;
String data;
public Config(int version, String data) {
this.version = version;
this.data = data;
}
}
public class ReferenceCasDemo {
public static void main(String[] args) {
// 初始配置,版本 1
Config configV1 = new Config(1, "Initial Settings");
AtomicReference atomicConfig = new AtomicReference(configV1);
Thread updater = new Thread(() -> {
// 我们想更新到版本 2
Config newConfig = new Config(2, "Optimized Settings");
// 使用 CAS 更新对象引用
// 只有当 atomicConfig 当前的引用指向 configV1 时,才会替换为 newConfig
boolean success = atomicConfig.compareAndSet(configV1, newConfig);
if(success) {
System.out.println("更新成功: 版本 " + atomicConfig.get().version);
} else {
System.out.println("更新失败: 配置已被其他线程修改");
}
});
updater.start();
}
}
CAS 的局限性与 ABA 问题
虽然 CAS 很强大,但它并不是万能药。在实际开发中,我们必须警惕一个经典的问题:ABA 问题。
什么是 ABA 问题?
假设一个变量初始值是 A。
- 线程 1 读取到了 A,准备把它改成 C,但动作慢了点。
- 线程 2 进场,迅速把 A 改成了 B,然后又迅速把 B 改回了 A。
- 线程 1 现在开始执行 CAS,它检查内存中的值,发现依然是 A。
- 线程 1 认为“没人动过它”,于是成功把值改成了 C。
后果: 虽然值看起来没变,但状态可能已经发生了变化。比如在链表操作中,节点被删除又重新加入,虽然引用地址一样,但链表结构可能已经变了。
解决方案:
为了解决这个问题,Java 提供了 INLINECODE8c69c989 和 INLINECODE1664a185。它们的思路是给变量加上一个版本号或标记。
原本的比较 INLINECODE5a20eb26 变成了比较 INLINECODE7ddc8143。即使值回到了 A,但版本号已经变了(比如从 1 变成了 3),CAS 就会失败。
性能优化与最佳实践
在结束之前,让我们聊聊如何在实际项目中更好地使用 CAS。
- 避免在高竞争下使用简单的 CAS 循环:如果多个线程激烈竞争同一个变量,大量的 CPU 时间会浪费在自旋重试上。这时,可以考虑使用
LongAdder(Java 8 引入),它在高并发下通过分散热点来提高性能,而不是在一个变量上死磕。 - CAS 只是手段,不是目的:它能保证单个变量的原子性。如果你涉及到多个变量(例如:“同时更新账户余额和操作日志”),你仍然需要使用 INLINECODE79e1b0d0 或 INLINECODE9d1d2054 来保证复合操作的原子性。
- 内存可见性:INLINECODE657c479a 类不仅保证了原子性,还通过 INLINECODE18291560 语义保证了可见性。这意味着当一个线程修改了原子变量,其他线程能立即看到最新的值,这比普通的变量操作更可靠。
总结
在这篇文章中,我们一起深入探讨了 比较并交换 (CAS) 算法。我们从最基础的并发安全问题出发,理解了“检查再行动”模式的风险,并通过对比 synchronized 锁机制,发现了 CAS 作为无锁(Lock-Free)算法的核心价值。
我们学会了:
- 如何使用 INLINECODE8e408abe、INLINECODEe1be81db 和
AtomicReference来编写线程安全且高效的代码。 - CAS 在底层是如何通过
compareAndSet配合硬件指令实现原子性的。 -
incrementAndGet等方法背后隐藏的自旋逻辑。 - 了解了 CAS 可能带来的 ABA 问题及其解决方案(带版本号的引用)。
掌握 CAS 是通往高级 Java 并发编程的必经之路。它让我们在追求高性能的同时,依然能够保证系统的稳定性。下次当你遇到简单的并发计数、状态标记或序列号生成需求时,请优先考虑 java.util.concurrent.atomic 包中的这些利器。