并发编程中,如何保证原子性?
常见的做法就是加锁。
在 Java 中,我们可以使用 synchronized
关键字和 CAS(Compare-and-Swap)
来实现加锁效果。
synchronized
是悲观锁,线程开始执行第一步就要获取锁,一旦获取锁,其他的线程进入后就会阻塞并等待锁。例如平时上厕所,一个人去后将门锁上,其他人来了只能在外面等待。
CAS
是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就一直重试,直到成功为止。
乐观锁与悲观锁
锁可以从不同的角度来分类。比如 synchronized
的四种锁状态分别为无锁、偏向锁、轻量级锁、重量级锁。同理,乐观锁和悲观锁也是一种分类方式。
悲观锁
悲观锁,顾名思义,它是悲观的,总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作都加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
乐观锁
乐观锁,听名字就知道它很乐观。它总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行安全。
- 乐观锁多用于 “读多写少” 的环境,避免频繁加锁影响性能。
- 悲观锁多用于 “写多读少” 的环境,避免频繁失败和重试影响性能。
什么是 CAS
CAS 全称为 Compare And Swap
翻译过来就是比较并且交换。它是一种 CPU 并发原语,用于实现多线程环境下的同步操作。
CAS 通常包含三个参数:当前内存值 V
、旧的预期值 A
、即将更新的值 B
。它的执行过程如下:
- CPU 读取内存值 V。
- CPU 将读取到的值和 旧的预期值 A 比较。
- 如果结果相等,就将内存值修改为 B,并返回 true,否则,什么都不做,并返回false。
CAS 基本原理
CAS 主要包括两个操作:Compare
和 Swap
,那这两个方法如何保证原子性?
我们知道,在 Java 中,如果一个方法是 native 的,那 Java 就不负责具体实现它,而是交给底层的 JVM 使用 C 语言或者 C++ 去实现。
JDK 在 1.5 版本之后引入了 CAS 操作,在 sun.misc.Unsafe
这个类中定义了 CAS 相关的方法。如下图:
可以看到,方法被声明为 native
。
在 Intel 的 CPU 中,使用 cmpxchg
指令在 CPU 上完成 CAS 操作的,但在多处理器情况下,必须使用 lock
指令加锁来完成。当然,不同的操作系统和处理器在实现方式上肯定会有所不同。
CMPXCHG是“Compare and Exchange”的缩写,它是一种原子指令,用于在多核/多线程环境中安全地修改共享数据。CMPXCHG在很多现代微处理器体系结构中都有,例如Intel x86/x64体系。对于32位操作数,这个指令通常写作CMPXCHG,而在64位操作数中,它被称为CMPXCHG8B或CMPXCHG16B。
CAS 在 Java 语言中的应用
在 Java 编程中我们通常不会直接使用到 CAS,都是通过 JDK 封装好的并发工具类来间接使用的,这些并发工具类都在 java.util.concurrent
(简称 JUC,即Java 并发编程工具包) 包中。
目前 CAS 在 JDK 中主要应用在 J.U.C 包下的 Atomic 相关类中。
CAS 的三大问题
ABA 问题
ABA 问题是 CAS 操作的一个经典问题,即假设一个变量初始值为 A,被修改为B,然后又被修改为A。这个变量实际被更新了两次,但是 CAS 是检查不出来的。
案例:
import java.util.concurrent.atomic.AtomicReference;
public class StackExample {
private static AtomicReference<Node> top = new AtomicReference<>();
public static void main(String[] args) {
Node node1 = new Node("Node 1");
Node node2 = new Node("Node 2");
Node node3 = new Node("Node 3");
top.set(node1); // 初始栈顶为 node1
// 线程1执行出栈操作,期望栈顶元素为 node1
Thread thread1 = new Thread(() -> {
Node expected = top.get();
System.out.println("Thread 1: Expected top = " + expected);
// 模拟线程1执行过程中,线程2将栈顶元素修改为 node2
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
top.compareAndSet(expected, node2); // 将栈顶元素修改为 node2
System.out.println("Thread 1: Actual top after CAS = " + top.get());
// 线程1再次将栈顶元素修改为 node1
top.compareAndSet(node2, expected);
System.out.println("Thread 1: Actual top after second CAS = " + top.get());
});
// 线程2执行入栈操作,在线程1执行过程中将栈顶元素修改为 node2,并再次修改回 node1
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
top.compareAndSet(node1, node3); // 将栈顶元素修改为 node3
System.out.println("Thread 2: Actual top after CAS = " + top.get());
top.compareAndSet(node3, node1); // 将栈顶元素修改回 node1
System.out.println("Thread 2: Actual top after second CAS = " + top.get());
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static class Node {
private String data;
public Node(String data) {
this.data = data;
}
@Override
public String toString() {
return data;
}
}
}
在上述代码中,线程1执行出栈操作,期望栈顶元素为 node1。然而,在线程1执行过程中,线程2将栈顶元素先后修改为 node3 和再次修改回 node1。这样,虽然线程1的 CAS 操作在值比较时是成功的,但实际上栈顶元素已经发生了变化,从而导致 ABA 问题。
输出结果可能如下:
Thread 1: Expected top = Node 1
Thread 2: Actual top after CAS = Node 3
Thread 1: Actual top after CAS = Node 2
Thread 1: Actual top after second CAS = Node 1
Thread 2: Actual top after second CAS = Node 1
如何解决? 思路其实很简单,在变量前加版本号或时间戳。
从 JDK 1.5 开始,JDK 的 atomic 包里提供了一个AtomicStampedReference
类来解决 ABA 问题。这个类的 compareAndSe方法首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
自旋开销问题
CAS 出现冲突后就会开始自旋操作,如果资源竞争非常激烈,自旋长时间不能成功就会给 CPU 带来非常大的开销。
解决方案:可以考虑限制自旋的次数,避免过度消耗 CPU;另外还可以考虑延迟执行。
CAS 只能够保证一个共享变量的原子操作
当对一个共享变量执行操作时,可以使用 CAS 来保证原子性,但是如果要对多个共享变量进行操作时,CAS 是无法保证原子性的,比如需要将 i 和 j 同时加 1:
i++;j++;
这个时候可以使用 synchronized 进行加锁,有没有其他办法呢?有,将多个变量操作合成一个变量操作。从 JDK1.5 开始提供了AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
总结
CAS 是 Compare And Swap,是一条 CPU 原语,由操作系统保证原子性。
Java语言从 JDK1.5 版本开始引入 CAS , 并且是 Java 并发编程J.U.C 包的基石,应用非常广泛。
当然 CAS 也不是万能的,也有很多问题:典型 ABA 问题、自旋开销问题、只能保证单个变量的原子性。
感谢大家读到这里,后续还会有其他相关文章,欢迎继续阅读。