在多线程编程的世界里,数据的一致性和线程安全始终是我们面临的核心挑战。你是否曾在处理高并发计数器、序列号生成或者简单的状态标志时,为了防止脏读和竞态条件而不得不频繁地加锁(synchronized 或 Lock)?虽然加锁能解决问题,但在极端高并发场景下,它可能会成为性能瓶颈。
今天,我们将深入探讨 Java 并发包(java.util.concurrent.atomic)中的一个基石方法——AtomicLong.compareAndSet()。这个方法体现了无锁编程的精髓,能够帮助我们以极高的性能和极低的线程开销来保证线程安全。在读完这篇文章后,你不仅会掌握它的基本用法,还会理解其底层的 CAS 原理,并学会如何在实际项目 中运用它来优化性能。
什么是 compareAndSet()?
简单来说,AtomicLong.compareAndSet() 是 Java 提供的一个内置方法,它利用了底层硬件的原子指令来实现“非阻塞”的线程安全更新。它的核心逻辑非常直观:
“如果当前原子变量中的值等于我预期的值,那就把它更新为新值;否则,说明已经有其他线程修改过了,操作失败。”
这个过程是原子性的,这意味着在执行期间不会被其他线程打断。它返回一个布尔值:INLINECODEba8f69c9 表示更新成功,INLINECODE65a3693f 表示更新失败。
方法签名与参数
让我们先来看看它的语法结构,以便我们对其有一个清晰的认识:
public final boolean compareAndSet(long expect, long update)
这个方法接受两个强制性参数:
- expect (预期值): 这是你认为当前原子对象应该持有的值。只有当内存中的实际值等于这个
expect时,更新才会发生。 - update (更新值): 如果校验成功,原子变量将被设置成这个新的
update值。
基础用法演示
光说不练假把式。让我们通过几个具体的例子,一步步看看这个方法是如何工作的。
#### 示例 1:更新成功的情况
在这个场景中,我们初始化一个值为 0 的 AtomicLong,然后尝试将其更新为 6。因为此时值确实为 0(符合预期),所以操作会成功。
import java.util.concurrent.atomic.AtomicLong;
public class CasDemo1 {
public static void main(String args[]) {
// 初始化值为 0
AtomicLong val = new AtomicLong(0);
// 打印初始值
System.out.println("初始值: " + val);
// 尝试更新:如果当前值是 0,则更新为 6
// 这里我们的预期值(0)与实际值相符
boolean isUpdated = val.compareAndSet(0, 6);
// 检查结果
if (isUpdated) {
System.out.println("更新成功!新值为: " + val);
} else {
System.out.println("更新失败。");
}
}
}
输出:
初始值: 0
更新成功!新值为: 6
正如你看到的,程序顺利地将值更新了。
#### 示例 2:更新失败的情况
现在,让我们模拟一种“预期错误”的情况。我们初始化值仍为 0,但我们自欺欺人地告诉 compareAndSet 方法:“我认为当前值是 10”。由于 0 不等于 10,更新操作将被拒绝。
import java.util.concurrent.atomic.AtomicLong;
public class CasDemo2 {
public static void main(String args[]) {
// 初始化值为 0
AtomicLong val = new AtomicLong(0);
// 打印初始值
System.out.println("初始值: " + val);
// 尝试更新:声称当前值是 10 (expect),如果是 10 则更新为 6
// 但实际上当前值是 0,所以这步操作会失败
boolean isUpdated = val.compareAndSet(10, 6);
// 检查结果
if (isUpdated) {
System.out.println("更新成功!新值为: " + val);
} else {
System.out.println("更新失败,因为值不匹配。当前实际值仍为: " + val);
}
}
}
输出:
初始值: 0
更新失败,因为值不匹配。当前实际值仍为: 0
注意到没有?尽管我们传入了想要更新的值 INLINECODE4d7610cf,但因为 INLINECODE1a5bda20(10)校验失败,AtomicLong 严词拒绝了这个修改,保证了数据的准确性。
进阶实战:高并发环境下的应用
上面的例子看起来很简单,你可能会问:“我直接用赋值不就行了吗?” 关键在于并发环境。 在多线程同时修改同一个变量时,compareAndSet 才真正展现出它的威力。
#### 场景:并发计数器
假设我们有一个生成唯一序列号的需求,或者统计网站的点击量。如果不使用原子类,我们需要加锁,这会导致线程阻塞。使用 INLINECODE27b73b60 配合 INLINECODE5e264c42(或者直接使用封装好的 getAndIncrement),我们可以实现无锁的高效更新。
为了演示 compareAndSet 在并发下的核心逻辑(通常被称为 CAS 自旋),我们手动实现一个简单的“增加并获取”逻辑。
import java.util.concurrent.atomic.AtomicLong;
public class ConcurrentCasDemo {
public static void main(String args[]) throws InterruptedException {
// 初始化值为 0
AtomicLong counter = new AtomicLong(0);
// 定义任务:增加计数器的值
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
boolean success = false;
// 我们通过自旋(循环)直到更新成功
// 这模拟了 AtomicInteger.getAndIncrement 的底层逻辑
while (!success) {
long currentValue = counter.get();
long newValue = currentValue + 1;
// 尝试将 current 更新为 new
// 如果在这期间 currentValue 被其他线程改了,这里会返回 false,循环继续
success = counter.compareAndSet(currentValue, newValue);
}
}
};
// 创建两个线程并发执行
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
// 等待线程结束
thread1.join();
thread2.join();
// 最终结果
System.out.println("最终计数值: " + counter.get());
System.out.println("预期结果应为: 2000 (1000 + 1000)");
}
}
输出:
最终计数值: 2000
预期结果应为: 2000 (1000 + 1000)
代码解析:
在上述代码中,我们并没有加锁。请注意 while 循环部分。这是 CAS 操作最经典的使用模式:
- 读取当前值 (
currentValue)。 - 计算新值 (
newValue = currentValue + 1)。 - 执行 CAS (
compareAndSet(currentValue, newValue))。 - 失败重试:如果 CAS 返回
false,意味着在步骤 1 和步骤 3 之间,其他线程修改了变量。我们必须回到步骤 1 重新读取最新的值,再次尝试。
这种机制被称为自旋锁或乐观锁。它避免了线程挂起和唤醒的开销,非常适合竞争不是很激烈的情况。
实际应用场景与最佳实践
在实际的开发工作中,直接手写上面的 INLINECODE56a6d67e 循环比较少见,因为 INLINECODE5d2528d1 已经为我们封装好了如 INLINECODEf7493b9d、INLINECODEd3bc0356 等方法。但理解 compareAndSet 是理解这些封装方法的基础。
#### 1. 乐观锁实现
假设你在开发一个电商系统,需要处理库存扣减。数据库层面的乐观锁通常利用 INLINECODEbc91654f 字段,而在内存中处理热数据缓存时,INLINECODE98d181dc 就是一个绝佳的工具。
public class InventoryManager {
private final AtomicLong stock = new AtomicLong(100); // 初始库存 100
/**
* 扣减库存的方法
* @param quantity 扣减数量
* @return 扣减是否成功
*/
public boolean deductInventory(long quantity) {
long currentStock, newStock;
do {
currentStock = stock.get(); // 获取当前库存
if (currentStock < quantity) {
// 库存不足,直接返回失败,不需要进行 CAS
return false;
}
newStock = currentStock - quantity; // 计算新库存
// 尝试更新库存,如果更新失败(说明其他线程修改了),循环重试
} while (!stock.compareAndSet(currentStock, newStock));
return true;
}
public static void main(String[] args) {
InventoryManager manager = new InventoryManager();
System.out.println("库存扣减结果 (50): " + manager.deductInventory(50)); // true
System.out.println("当前库存: " + manager.stock.get()); // 50
System.out.println("库存扣减结果 (60): " + manager.deductInventory(60)); // false
}
}
#### 2. ABA 问题及其解决方案
虽然 compareAndSet 很强大,但在实际生产中,我们必须警惕著名的 ABA 问题。
什么是 ABA 问题?
假设一个变量初始值是 A。
- 线程 1 读取了 A,准备将其修改为 C,但在执行 CAS 之前被挂起了。
- 线程 2 将值从 A 改成了 B。
- 线程 2 又将值从 B 改回了 A。
- 线程 1 恢复运行,执行 CAS。它检查当前值还是 A,于是认为没有变过,成功将值改为了 C。
在大多数简单的计数器场景下,这没问题。但在处理链表节点或对象引用时,这可能引发严重的逻辑错误(比如节点被删除又加回来)。
解决之道:AtomicStampedReference
为了解决这个问题,Java 提供了 AtomicStampedReference。它不仅比较值,还比较一个“版本号”。只有当值和版本号都匹配时,更新才会发生。
import java.util.concurrent.atomic.AtomicStampedReference;
public class AbaSolutionDemo {
public static void main(String[] args) {
// 初始值为 100, 初始版本号为 0
final int initialStamp = 0;
AtomicStampedReference stampedRef = new AtomicStampedReference(100, initialStamp);
System.out.println("初始值: " + stampedRef.getReference() + ", 版本: " + stampedRef.getStamp());
// 模拟 CAS 操作,同时检查值和版本号
int oldValue = 100;
int newValue = 101;
int oldStamp = stampedRef.getStamp();
int newStamp = oldStamp + 1;
boolean result = stampedRef.compareAndSet(oldValue, newValue, oldStamp, newStamp);
System.out.println("CAS 操作结果: " + result); // true
System.out.println("更新后值: " + stampedRef.getReference() + ", 版本: " + stampedRef.getStamp());
}
}
如果你发现你的业务逻辑中涉及到状态回滚或对象复用,务必考虑使用带版本号的原子类。
性能优化与常见陷阱
#### 1. 避免在高并发下出现“死循环”
前面提到的 INLINECODEaba8e4da 模式在竞争激烈时会导致 CPU 飙升。如果多个线程频繁修改同一个值,CAS 会不断失败重试。虽然 INLINECODE184bcd43 性能很高,但在极端情况下(例如 16 个线程同时疯狂增加计数器),它的效率可能会低于 LongAdder(Java 8 引入)。
最佳实践: 如果你仅仅是做简单的计数统计(例如统计请求数),考虑使用 Java 8 引入的 LongAdder。它在高并发下通过分散热点数据来提高吞吐量。
#### 2. 不要假设操作一定会成功
很多新手开发者容易写出这样的代码:
// 错误示范
val.compareAndSet(0, 5);
System.out.println("值肯定是5了"); // 错误!万一失败了怎么办?
这是一种盲目的乐观。永远要检查返回值,或者确保你的业务逻辑能够容忍更新失败(在循环中重试)。
总结
在这篇文章中,我们深入探索了 Java 中 AtomicLong.compareAndSet() 的方方面面。我们从简单的 API 语法入手,通过代码示例了解了它在成功和失败场景下的表现,更重要的是,我们剖析了它在无锁编程中的核心地位。
让我们回顾一下关键点:
- 原子性保证:
compareAndSet利用底层 CPU 指令(如 CMPXCHG),保证了“比较并交换”操作的原子性,无需加锁。 - 并发控制:它非常适合用来实现乐观锁和高性能计数器。
- 自旋模式:在手动使用时,通常搭配
while循环处理竞争。 - ABA 问题:在复杂的业务场景中,要注意 ABA 问题,必要时升级到
AtomicStampedReference。 - 性能权衡:对于极高并发的计数场景,INLINECODE1064a5a3 可能是比 INLINECODE7029a5cd 更好的选择。
掌握了 compareAndSet,你就掌握了 Java 并发编程的“瑞士军刀”。下次当你遇到多线程修改共享变量的难题时,不妨试着放下沉重的锁,用 CAS 的方式去解决问题。我们鼓励你在本地 IDE 中运行上面的示例代码,亲自感受一下无锁编程的魅力!