概述
在前一个系列中,我简单整理了 synchronized 锁的使用和原理,即采用 Monitor 管程对象控制同时只能有一个线程占有锁对象,以此来保证多线程场景下的线程安全。除了这种通过锁对象实现的同步,还有一种在不使用锁的情况下实现同步的方式,这种无锁同步的实现原理是 CAS。本篇我就来介绍下 CAS 相关的知识。
无锁同步
本篇博客分以下几个模块展开:
- 乐观锁和悲观锁
- CAS 简单介绍
- CAS 如何使用
- CAS 的优势
- 如何解决 ABA 问题
- CAS 实现自旋锁
1、乐观锁和悲观锁
在正式介绍 CAS 前,我先引出以下两个概念:
-
悲观锁:所有事情总往坏的一面考虑。对应到多线程场景下,也就是说拿到数据后,总觉得该数据会被其他线程修改,因此任何操作都需要做加锁处理。
-
乐观锁:所有事情总往好的一面考虑。对应到多线程场景下,也就是说拿到数据后,总觉得该数据不会被其他线程修改,因此不做任何同步处理,只是每次操作前判断该数据是否被修改。
有了上面的定义,我们很容易发现 synchronized 锁就是最常见的悲观锁。因为任何线程过来,执行到该关键字对应的代码块或方法后,都需要进行加解锁处理。
乐观锁是相对悲观锁提出的概念。对应悲观锁的任何同步操作都加锁,乐观锁的同步操作都不加锁。本篇我们所要介绍的无锁同步就是一种典型的乐观锁,CAS 是实现无锁同步的关键。
2、CAS 简单介绍
CAS 的全称是 “Compare And Swap” 即比较和交换。有时候也可以简写为 CAP。
它的核心原理是:操作前比较当前值是否等于期望值
-
如果值相等,说明当前线程读取数据到准备修改数据这段时间,没有其他线程修改数据值。因此可以接着向下执行
-
如果值不相等,说明期间值已经被其他线程处理,后序不做任何处理
一般情况下,CAS 思想可以用以下伪代码表示:
CAS(oldValue, hopeValue, newValue);
其中 oldValue 表示修改前的数据值,hopeValue 表示期望值,newValue 表示要修改的新值。下面我通过简单的流程图介绍整个过程:
CAS 是乐观锁的一种实现方式,虽然它总是认为其他线程不会修改数据值,但实际应用中就不一定了。当多个线程通过 CAS 修改同一变量时,只会有一个线程执行成功。执行失败的线程不会像 synchronized 锁那样被挂起,CAS 方法会返回 false 提示当前线程执行失败。实际应用中可以根据相应的业务需求决定接下来如何操作,可以选择放弃操作,接着向下执行,或是说继续执行,通过 while 循环关键字直到当前线程执行成功。
最后我简单提一句:CAS 在CPU层面是线程安全的原子操作。也就是说,整个执行过程是连续的,不会在执行期间中断或上下文切换到其他线程。也就是说,线程从判断期望值是否相等到执行后续操作的这段时间,不会有线程修改当前数据值。这也是 CAS 能够实现无锁同步的主要原理,关于 java 如何实现 CAS,后序我通过其它博客着重介绍。
3、CAS 如何使用
在 java 代码中,CAS 操作最常出现在 JDK 5 引入的原子并发包下( java.util.concurrent.atomic )。
根据操作对象的不同,CAS 可以分以下四种:
- CAS 更新基本类型
- CAS 更新对象引用
- CAS 更新数组类型
- CAS 更新对象属性
3-1、CAS 更新基本类型
CAS 更新基本类型主要包括以下三种:
- CAS 修改 Integer 类型变量
- CAS 修改 Long 类型变量
- CAS 修改 Boolean 类型变量
上述这几种处理方式比较类似,这里我主要给出 CAS 修改 Integer 类型变量的案例:
public class AtomicIntegerTest {
AtomicInteger atomicInteger = new AtomicInteger(0);
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
atomicInteger.incrementAndGet();
}
}
}
@Test
public void test() throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(new Worker()).start();
}
Thread.sleep(3000);
System.out.println(atomicInteger.get());
}
}
执行结果:1000000
在上述Demo中,我们使用并发包下的 AtomicInteger 类保证线程的安全性。其中它的底层是通过 CAS 实现的,从执行结果我们可以看出,的确没有出现线程安全问题。
3-2、CAS 更新对象引用
CAS 更新对象引用和更新基础类型相似。只是更新基础类型时比较具体的数据值,更新对象引用时比较引用指向的对象地址,核心思想是一样的。
需要注意的一点是,更新对象引用时,即使对象属性发生变化,也不会影响 CAS 方法的执行。两个属性完全相同的对象,如果地址不同,执行 CAS 所得到的结果也不相同。具体我们看实例:
public class AtomicReferenceTest {
class Demo {
int id;
String name;
public Demo(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "Demo{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}
@Test
public void test() {
Demo demo = new Demo(1, "测试");
Demo demo2 = new Demo(2, "测试2");
Demo demo3 = new Demo(3, "测试3");
AtomicReference<Demo> atomicReference = new AtomicReference<>(demo);
System.out.println("CAS 修改前:" + atomicReference.get().toString());
atomicReference.compareAndSet(demo, demo2);
System.out.println("CAS 修改后:" + atomicReference.get().toString());
demo2.id = 3;
demo2.name = "测试3";
atomicReference.compareAndSet(demo3, demo);
System.out.println("CAS 修改后:" + atomicReference.get().toString());
}
}
执行结果:
CAS 修改前:Demo{id=1, name='测试'}
CAS 修改后:Demo{id=2, name='测试2'}
CAS 修改后:Demo{id=3, name='测试3'}
在上述代码中,首先通过 CAS 的方式将 demo 对象更换为 demo2 对象。后面将 demo2 对象的属性改为和 demo3 相同,根据 demo3 对象判断是否地址相等,如果地址相等就修改为 demo 对象。
从运行结果可以看出,第一次执行 CAS 操作时,根据 demo 对象修改为 demo2 对象成功。当我们把 demo2 对象属性修改为和 demo3对象相同时,根据 demo3 对象修改 demo 对象失败。也就是说整个过程是根据对象地址来实现的,而不是根据对象属性。
3-3、CAS 更新数组类型
CAS 更新数组类型主要分以下三种:
- CAS 更新整数数组中的元素
- CAS 更新长整数数组中的元素
- CAS 更新引用类型数组中的元素
以上三种类型的处理方式类似,这里我主要给出 CAS 更新整数数组的具体实例:
public class AtomicIntegerArrayTest {
AtomicIntegerArray array = new AtomicIntegerArray(10);
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
array.incrementAndGet(i % array.length());
}
}
}
@Test
public void test() throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(new Worker()).start();
}
Thread.sleep(3000);
for (int i = 0; i < array.length(); i++) {
System.out.println(array.get(i));
}
}
}
执行结果:
100000
100000
100000
100000
100000
100000
100000
100000
100000
100000
上述代码中,我们创建10个线程并发执行,每个线程在每个数组元素上自增 10000次。从运行结果可以看出,数组中所有元素都自增 100000 次,没有出现线程安全问题。其中它内部通过字节计算确定数组元素的地址,后续通过 CAS 操作执行自增。关于这块的原理,后续我们通过其他博客专门讨论。
3-4、CAS 更新对象属性
在部分业务场景下,对象本身是不能更换的,但对象中部分属性需要更新。如果这个对象被多个线程所共享,那么就必须考虑线程安全问题。如果采用有锁的方式解决,只需通过 synchronized 关键字修饰对应获取属性的方法。此时只需要一个线程修改对应属性值,那么当其他线程获取属性值时,都会获取到更新后的属性。
在无锁同步中,CAS 并发包提供了以下三种处理对象属性的方式:
- CAS 更新对象 Integer 类型属性
- CAS 更新对象 Long 类型属性
- CAS 更新对象引用类型属性
当使用 CAS 更新对象属性时,关于属性的限制条件也比较多:
- 属性必须通过 volatile 关键字修饰
- 属性不能是 static 修饰的
- 属性不能是 final 修饰的
- 在调用 cas 修改属性时,属性访问权限必须包含的
下面我简单给出通过 CAS 修改 Integer 类型属性的示例:
public class AtomicIntegerFieldUpdaterTest {
class Node {
volatile int num = 0;
}
Node node = new Node();
class Worker implements Runnable {
@Override
public void run() {
AtomicIntegerFieldUpdater<Node> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(Node.class, "num");
for (int i = 0; i < 10000; i++) {
fieldUpdater.incrementAndGet(node);
}
}
}
@Test
public void test() throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(new Worker()).start();
}
Thread.sleep(3000);
System.out.println(node.num);
}
}
执行结果:100000
上述代码中,我们根据对象类型,属性名称创建 AtomicIntegerFieldUpdater 对象,通过该对象调用 CAS 函数修改 node 对象的 num 属性值。其中每个线程自增10000次,共十个线程并发执行,从运行结果来说,整个过程是线程安全的。
4、CAS 的优势
关于 CAS 的优势,我认为主要有以下两点:
- 无死锁
- 效率高
无死锁:之前我们提到,产生死锁的主要条件是形成环形等待。而无锁操作在执行失败时直接返回 false,不会阻塞。也就是说,线程之间不存在互相等待的情况。因此 CAS 可以从源头避免死锁发生。
效率高:这里的效率高是相对 synchronized 有锁操作来说的。无锁同步相比有锁同步在操作系统层面执行较少的上下文切换,而每次上下文切换都需要耗费不少资源,因此无锁相比有锁更加高效。
在操作系统层面,为了让较少的CPU执行远超CPU数量的任务,需要频繁的进行上下文切换。操作系统分配给每个任务相应的时间片,当时间片执行完毕后,就上下文切换到下个任务。在 synchronized 有锁同步中,当线程抢占锁失败导致阻塞后,即使时间片还没有执行完,仍需要上下文切换到其他线程。这也就导致,有锁操作需要更多的上下文切换。而无锁操作失败后,根据代码规则继续向下执行,线程本身不会阻塞。也就是说,无锁操作失败不会导致更多的上下文切换发生,这也是为什么无锁操作效率更高的主要原因。
当然上面只是只是一种理想情况,实际应用场景中,如果为了同步,导致 CAS 自旋判断的次数过多。那么线程自身占用CPU执行所消耗的资源可能已经大于加锁在操作系统层面阻塞所带来的消耗,在这种场景下,synchronzed 锁的效率就比 CAS 高了。
5、如何解决 ABA 问题
CAS 的核心思想是操作前判断,如果操作前多个线程都修改了目标数据,最终修改为和期望值相同,那么就无法判断是否有其他线程修改过目标数据,这也是CAS带来的ABA问题。
举个简单的例子:
- 线程A 读取数据值10
- 线程B 修改数据值为20
- 线程C 修改数据值为10
- 线程A 判断当前值等于期望值,都是10
- 线程A 认为获取数据期间没有其他线程修改数据值,接着向下执行
ABA 问题在有些场景下不会产生影响,如上述的加减运算类。但部分场景下还是有必要解决,因为它导致当前线程无法准确判断是否有其他线程修改数据。
JAVA 代码中提供了以下两个原子类解决 ABA 问题:
-
AtomicStampedReference
-
AtomicMarkableReference
5-1、AtomicStampedReference
AtomicStampedReference 的实现原理比较简单,它在原先单期望值的基础上,增加时间戳期望值,也就是说除了判断数据值是否相等外,还需要判断时间戳是否被修改。通过双重判断的方式增加整体 CAS 的准确度。下面我们看一个具体Demo:
public class AtomicStampedTest {
AtomicStampedReference<Integer> num = new AtomicStampedReference<>(1, 0);
class Worker implements Runnable {
@Override
public void run() {
Integer time = num.getStamp();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Boolean bool = num.compareAndSet(1, 10, time, time + 1);
if (!bool) {
System.out.println("修改失败,当前数据值为:" + num.getReference() + ",期望值为:" + 1 + "。但时间戳有异常");
}
}
}
class Worker1 implements Runnable {
@Override
public void run() {
Integer time = num.getStamp();
num.compareAndSet(1, 100, time, time + 1);
}
}
class Worker2 implements Runnable {
@Override
public void run() {
Integer time = num.getStamp();
while (!num.compareAndSet(100, 1, time, time + 1)) {
}
}
}
@Test
public void test() throws InterruptedException {
new Thread(new Worker()).start();
new Thread(new Worker1()).start();
new Thread(new Worker2()).start();
Thread.sleep(2000);
}
}
执行结果:修改失败,当前数据值为:1,期望值为:1。但时间戳有异常
在上述案例中,Worker 线程读取期望值后,休眠 1s 时间。在休眠前面,Worker1 和 Worker2 线程分别将期望值修改为100 和 1。此时当 Worker 线程执行时,即使期望值相同,由于时间戳不同,CAS 执行也失败。
下面我们简单看一下 AtomicStampedReference 类核心方法的源码:
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
通过源码我们可以看出,该方法首先判断期望值和时间戳是否都相等。只有在都相等的情况下,才调用 casPair() 方法将新的值和时间戳赋予当前对象。这里引出了 UNSAFE 类,该类是 CAS 底层实现的原理。关于 CAS 原理的介绍,后序我们通过其他博客专门展开说明。
有了 AtomicStampedReference 的介绍,我们再来看 AtomicMarkableReference。
5-2、AtomicMarkableReference
AtomicMarkableReference 和 AtomicStampedReference 本质上都是通过增加一个新的期望值的方式解决 ABA 问题。只是 AtomicStampedReference 通过新增时间戳的方式解决,而 AtomicMarkableReference 通过一个boolean 类型变量的方式来解决。
一般情况下,时间戳相比 Boolean 类型变量准确度更高。而且 Boolean 类型变量不能完全解决 ABA 问题。下面我们看一个具体案例:
public class AtomicMarkableTest {
AtomicMarkableReference<Integer> num = new AtomicMarkableReference<>(1, true);
class Worker implements Runnable {
@Override
public void run() {
Boolean jundge = num.isMarked();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(num.compareAndSet(1, 10, jundge, !jundge)){
System.out.println("当前线程修改成功,没有发生ABA问题");
}
}
}
class Worker1 implements Runnable {
@Override
public void run() {
Boolean jundge = num.isMarked();
num.compareAndSet(1, 100, jundge, !jundge);
System.out.println("Worker1 线程在 Worker 线程读取数据期间修改数据");
}
}
class Worker2 implements Runnable {
@Override
public void run() {
Boolean jundge = num.isMarked();
num.compareAndSet(100, 1, jundge, !jundge);
System.out.println("Worker2 线程在 Worker 线程读取数据期间修改数据");
}
}
@Test
public void test() throws InterruptedException {
new Thread(new Worker()).start();
new Thread(new Worker1()).start();
new Thread(new Worker2()).start();
Thread.sleep(2000);
}
}
执行结果:
Worker1 线程在 Worker 线程读取数据期间修改数据
Worker2 线程在 Worker 线程读取数据期间修改数据
当前线程修改成功,没有发生ABA问题
从执行结果可以看出,Worker1 和 Worker2 线程在 Worker 线程读取数据期间,都对数据值进行了修改。但是当 Worker 线程执行时无法真正判断是否有ABA问题产生。因为 Worker1 线程和 Worker2 线程通过两轮操作,将数据值和 Boolean 类型值都设置为期望值相同,因此无法判断是有存在ABA问题。
从这里也就可以看出,AtomicStampedReference 相对更加安全一点,我们每次操作将时间戳向前加一,这样无论多少线程执行都不会导致时间戳最终等于前面线程读取到的时间戳期望值,也就可以完全避免ABA 问题。
6、CAS 实现自旋锁
最后我们再来聊一聊上个系列提到的自旋锁。当时我们提到,jvm 为了提高 synchronized 锁的效率,当轻量级锁升级为重量级锁后,为了防止线程在操作系统层面挂起。首先会自旋一段时间。在自旋的过程中尝试获取锁,获取到锁都就继续向下执行,否则再挂起。
这里我们可以通过 CAS 配合 while 循环模拟自旋获取锁的过程。用 CAS 返回 ture 模拟获取锁成功。如果返回false,通过 while 循环模拟自旋过程,重复进行 CAS 操作,直到获取锁成功。具体实例可以看一下代码:
public class CAS_SpinLockDemo {
class Lock {
private int state;
private String message;
public Lock(int state, String message) {
this.state = state;
this.message = message;
}
}
Lock lockState = new Lock(1, "锁定状态");
Lock unLockState = new Lock(0, "未锁定状态");
AtomicReference<Lock> atomicReference = new AtomicReference<>(unLockState);
class Worker implements Runnable {
@Override
public void run() {
int num = 0;
String threadName = Thread.currentThread().getName();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
while (!atomicReference.compareAndSet(unLockState, lockState)) {
num++;
}
System.out.println("线程:" + threadName + "在第" + num + "次自旋获取锁对象");
System.out.println("线程:" + threadName + "释放锁对象");
atomicReference.compareAndSet(lockState, unLockState);
}
}
@Test
public void test() throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(new Worker()).start();
}
Thread.sleep(5000);
}
}
执行结果:
线程:Thread-4在第0次自旋获取锁对象
线程:Thread-4释放锁对象
线程:Thread-5在第3648次自旋获取锁对象
线程:Thread-5释放锁对象
线程:Thread-6在第2657次自旋获取锁对象
线程:Thread-6释放锁对象
线程:Thread-3在第1000次自旋获取锁对象
线程:Thread-3释放锁对象
线程:Thread-2在第0次自旋获取锁对象
线程:Thread-2释放锁对象
线程:Thread-0在第5565次自旋获取锁对象
线程:Thread-0释放锁对象
线程:Thread-1在第0次自旋获取锁对象
线程:Thread-1释放锁对象
线程:Thread-9在第128次自旋获取锁对象
线程:Thread-9释放锁对象
线程:Thread-8在第0次自旋获取锁对象
线程:Thread-8释放锁对象
线程:Thread-7在第0次自旋获取锁对象
线程:Thread-7释放锁对象
在上述代码中,我们通过两个对象引用模拟锁被获取和锁没有被获取两种状态。通过 CAS 修改对象引用模拟获取锁的过程。如果返回 true,说明锁获取成功,继续向下执行并释放锁资源。如果返回 false,说明获取锁失败,此时自旋次数加一并尝试重新获取锁资源。
通过执行结果可以看出,部分线程确实通过一定次数的自旋后获取到锁资源才得以继续向下执行。事实上在并发包中,基础类型如 Integer、Long 的自增或自减操作也是通过这种配合 while,循环调用 CAS的方式实现的。