只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

40、CAS

内容来自王争 Java 编程之美

上几节讲到 synchronized 和 Lock,在讲解它们的实现原理时,我们反复提到 CAS

  • 多个线程竞争锁时,我们通过 CAS 来判定谁获得锁
  • 多个线程同时操作 AQS 中的等待队列时,我们使用 CAS 保证操作的线程安全性
  • 除此之外,后面讲到的原子类、并发容器,其底层实现也会用到 CAS

CAS 是多线程开发基础中的基础,本节我们就来详细讲解一下 CAS,在开始之前请你思考,如何不使用锁实现 AQS 中的线程安全的等待队列?

1、CAS 介绍

CAS 指的是:先检查后更新这类复合操作,英文翻译有多种:Compare And Set、Compare And Swap 或 Check And Set
示例代码如下所示,这段代码是我们从 Lock 锁的底层实现原理中,抽象出来的一段功能代码
state 为共享变量,值为 0 表示没有加锁,值为 1 表示已加锁,多个线程同时调用 tryAcquire() 函数,谁将 state 变为 1,谁就获取到了锁

public class LockDemo {
private int state = 0;
public boolean tryAcquire() {
if (state == 0) {
state = 1;
return true;
}
return false;
}
}

很明显,tryAcquire() 函数中的代码访问共享资源,并且包含复合操作,符合临界区的特征
多个线程竞态交叉执行 tryAcquire() 函数,有可能存在这样的情况:多个线程均检测到 state 等于 0,并先后将其值设置为 1,导致多个线程同时获取到同一把锁,因此 tryAcquire() 是非线程安全的

tryAcquire() 函数之所以非线程安全,本质上是因为 tryAcquire() 函数中的代码是非原子操作,为了保证线程安全,我们可以使用 synchronized 或 Lock 对 tryAcquire() 函数加锁
不过如果上述代码是 synchronized 或 Lock 的底层实现中的一部分,那么我们就不能在实现锁(synchronized 或 Lock)时再递归地使用锁
如果我们不使用锁,那么如何保证 tryAcquire() 函数的原子性呢?对于这种情况,我们可以使用硬件层面提供的 CAS 原子指令来解决

2、硬件指令

X86 提供的 CAS 指令为 cmpxchg 指令,指令格式如下所示,cmpxchg 指令涉及三个操作数

  • 目标操作数位于寄存器或内存中,用于存储变量的当前值 C(CurrentValue)
  • 源操作数位于寄存器中,用于存储变量的更新值 N(NewValue)
  • 隐藏的操作数位于 AX 寄存器中,在指令中没有明确指出,用于存储变量的期望值 E(ExpectedValue)
cmpxchg [目标操作数], [源操作数]
cmpxchg [CurrentValue], [NewValue]

在执行 cmpxchg 指令时,CPU 会判断变量的当前值 C 是否等于期望值 E

  • 如果当前值 C 跟期望值 E 相等的话,那么 CPU 将更新值 N 赋值给存储当前值 C 的寄存器中,并设置标志寄存器中的 ZF 标志位为 1,用来表示变量值更新成功
  • 如果当前值 C 跟期望值 E 不相等,那么 CPU 将当前值 C 赋值给存储期望值 E 的寄存器中,也就是 AX 寄存器中
    并设置标志寄存器中的 ZF 标志位为 0,用来表示变量值更新失败

在单核计算机上,cmpxchg 指令是原子操作,尽管 cmpxchg 指令包含很多细分操作,看似是非原子的复合操作,但是指令是 CPU 执行的最小单元,指令执行的过程不可中断
多个线程共用一个 CPU 核来交替执行,只有当前线程执行完正在执行的指令(比如 cmpxchg 指令)之后,操作系统才可以调度 CPU 执行其他线程
其他线程是看不到 cmpxchg 指令执行的中间状态的,因此 cmpxchg 在单核计算机上是原子操作

不过在多核计算机上,cmpxchg 指令却是非原子操作,在多核计算机上,多个线程可以并行运行在多个 CPU 核上
也就是说,多个线程可以并行执行 cmpxchg 指令,同时对同一个内存变量进行 CAS 操作,因此 cmpxchg 就不再是原子操作了
在多核计算机中,为了保证 cmpxchg 指令的原子性,我们需要在 cmpxchg 指令前加 LOCK 前缀,如下所示

LOCK cmpxchg [目标操作数], [源操作数]
LOCK cmpxchg [CurrentValue], [NewValue]

在讲解 volatile 解决内存可见性问题时,我们也提到过 LOCK 前缀
实际上 LOCK 前缀不仅仅可以同步缓存,还可以锁定总线,禁止多个 CPU 核同时操作一块共享的内存单元
这样就能保证多核计算机上,同一时刻只有一个 CPU 核在执行 cmpxchg 指令,这就相当于在硬件层面给 cmpxchg 指令加了锁

3、native 方法

尽管硬件层面提供了原子的 CAS 指令,但是在高级的 Java 编程语言中,我们无法直接使用底层的 CPU 指令
JVM 的 Unsafe 类中提供了大量的 native 方法,对比较底层的操作进行了封装,比如之前讲到的用于阻塞线程的 park()、unpark() 方法

Unsafe 类中提供了 3 个 CAS 方法,如下所示
o 表示针对哪个对象的成员变量进行 CAS 操作,offset 表示成员变量在对象中的偏移位置,oldValue 为期望值,newValue 为更新值
如果对象 o 中偏移位置为 offset 的成员变量的值等于 oldValue,那么对象 o 中偏移位置为 offset 的成员变量的值更新为 newValue,并且 CAS 方法返回 true

public final native boolean compareAndSwapInt(Object o, long offset, int oldValue, int newValue);
public final boolean compareAndSwapLong(Object o, long offset, long oldValue, long newValue);
public final boolean compareAndSwapObject(Object o, long offset, Object oldValue, Object newValue);

以上 3 个 CAS 方法的代码实现类似,我们拿 compareAndSwapInt() 方法举例讲解,Java 是跨平台语言,针对不同的平台,compareAndSwapInt() 方法的代码实现不同
在 Linux_X86 平台(CPU 为 X86,操作系统为 Linux)下,compareAndSwapInt() 方法的代码实现如下所示
在 Hotspot JVM 中,native 方法在 JVM 中由 C++ 代码实现,因此我们需要在 JVM 源码中查看 native 方法的代码实现

// 以下代码位于 unsafe.cpp 中
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(
JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)
)
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

上述代码又调用了 Atomic 类中的 cmpxchg() 方法,cmpxchg() 方法的代码实现如下所示
cmpxchg() 方法通过在 C++ 代码中调用汇编代码,来执行 cmpxchg 汇编指令

inline jint Atomic::cmpxchg(
jint exchange_value, volatile jint *dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (
LOCK_IF_MP( % 4)
"cmpxchgl %1,(%3)"
: "=a"(exchange_value)
: "r"(exchange_value), "a"(compare_value), "r"(dest), "r"(mp)
: "cc", "memory"
);
return exchange_value;
}

了解了 Unsafe 类中 CAS 方法的实现原理之后,我们使用这些 CAS 方法来实现 tryAcquire() 函数,对应的代码实现如下所示,代码比较简单,我们就不详细解释了

public class Demo {
private int state = 0;
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long stateOffset;
static {
try {
stateOffset = unsafe.objectFieldOffset(Demo.class.getDeclaredField("state"));
} catch (Exception ex) {
throw new Error(ex);
}
}
public boolean tryAcquire() {
return unsafe.compareAndSwapInt(this, stateOffset, 0, 1);
}
}

4、失败处理

如果多个线程竞争执行 CAS,那么只有一个线程会执行成功,其他执行失败的线程又该如何处理呢?
实际上,我们可以根据不同的业务场景,选择不同的处理策略,既可以转去执行失败处理逻辑(如 tryAcquire() 函数),也可以自旋执行 CAS,直到执行成功为止

我们举例解释一下自旋执行 CAS 这种处理策略,示例如下代码所示,如果我们希望 increment() 函数线程安全,那么我们现在有两种处理方法

  • 一种是使用 synchronized 或 Lock 对 increment() 函数加锁,对应代码为 increment_lock() 函数
  • 另一种是使用 CAS,对应代码为 increment_CAS() 函数
public class Demo {
private int id = 0;
// 原始方法
public void increment() {
id++;
}
// 线程安全处理方法一: 使用锁
public void increment_lock() {
synchronized (this) {
id++;
}
}
// 线程安全处理方法二: 使用 CAS
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long idOffset;
static {
try {
idOffset = unsafe.objectFieldOffset(Demo.class.getDeclaredField("id"));
} catch (Exception ex) {
throw new Error(ex);
}
}
public void increment_CAS() {
int oldValue = id;
int newValue = oldValue + 1;
unsafe.compareAndSwapInt(this, idOffset, oldValue, newValue);
}
}

对比以上两种处理方法,Increment_lock() 函数总是能让 id 值增一,但 increment_cas() 却不能,在 CAS 失败时,函数直接返回,id 值并没有增一
也就是说,increment_cas() 的处理方式,并不符合我们对 increment() 函数的逻辑要求(总是会增一)
对于这个问题,我们就可以使用自旋 + CAS 来解决,如下代码所示

public void increment_CAS() {
boolean succeed = false;
while (!succeed) {
int oldValue = id;
int newValue = oldValue + 1;
succeed = unsafe.compareAndSwapInt(this, idOffset, oldValue, newValue);
}
}

前面我们讲过很多锁,偏向锁、轻量级锁、自旋锁等等,这里我们再介绍两种锁:悲观锁和乐观锁,它们属于抽象的概念,并不是具体实现
synchronized 或 Lock 可以用来实现悲观锁,自旋 + CAS 可以用来实现乐观锁

  • 乐观锁指的是:乐观的认为不大可能会有资源竞争,大部分情况都不需要加锁
  • 悲观锁指的是:悲观的认为很有可能会出现资源竞争,需要加锁

悲观锁和乐观锁各有利弊

  • 基于 synchornized 或 Lock 实现的悲观锁
    等待资源而阻塞线程会导致内核态到用户态的上下文切换,带来性能损耗
    但是处于阻塞状态的线程不会被分配 CPU 时间片,不会浪费 CPU 资源
  • 基于自旋 + CAS 实现的乐观锁
    循环执行 CAS,不需要阻塞线程,没有内核态到用户态的上下文切换带来的性能损耗
    但是线程一直处于运行状态,白白浪费 CPU 资源

因此如果多线程竞争资源不激烈,那么使用乐观锁来竞争资源更合适,如果多线程竞争资源比较激烈,那么使用悲观锁来竞争资源更合适

5、应用场景

5.1、AQSDemo

在本节的开头,我们留了一个思考题:如何不使用锁实现 AQS 中的线程安全的等待队列
等待队列主要包含两个操作,一个是锁竞争失败之后线程入队,另一个是等待队列中的线程获取到锁之后出队
等待队列是基于双向链表来实现的,非线程安全的等待队列的代码实现如下所示,其中使用虚拟头节点和 tail 尾指针是为了方便在链表尾部插入元素

public class AQSDemo {
public static final class Node {
private int threadId;
private Node prev;
private Node next;
public Node(int val, Node prev, Node next) {
this.threadId = val;
this.prev = prev;
this.next = next;
}
}
private Node head = new Node(-1, null, null); // 虚拟头节点
private Node tail = head;
// 入队
public void addWaiter(int threadId) {
Node newNode = new Node(threadId, null, null);
newNode.prev = tail;
tail.next = newNode;
tail = newNode;
}
// 出队
public void removeWaiter() {
if (head.next == null) return;
head = head.next;
head.threadId = -1;
head.prev = null;
}
}

5.2、问题

上述代码中的 addWaiter() 函数访问共享资源(tail),并且包含复合操作(读写 tail 和 tail.next),多线程竞态交叉执行 addWaiter() 函数会存在线程安全问题
如下例子所示,两个线程执行完之后,tail 指向值为 3 的节点,而值为 1 的节点的 next 指针却指向值为 4 的节点
image

5.3、addWaiter()

我们可以使用 CAS 来保证 addWaiter() 函数线程安全,如下代码所示
多个线程竞争往链表尾部添加元素时,只有一个线程会成功执行 CAS,将 tail 指针更新为指向自己的节点
其他线程执行 CAS 失败,继续自旋执行 CAS,直到将元素成功添加到链表尾部为止

public void addWaiter(int threadId) {
Node newNode = new Node(threadId, null, null);
for (; ; ) {
Node oldTail = tail;
if (unsafe.compareAndSwapObject(this, tailOffset, oldTail, newNode)) {
newNode.prev = oldTail;
oldTail.next = newNode;
return;
}
}
}

通过使用 CAS 重构 addWaiter() 入队函数,使得入队与入队之间线程安全
那么在不对出队函数 removeWaiter() 进行重构的情况下,入队与出队之间是否线程安全呢?答案是肯定的
这得益于虚拟头节点的存在以及通过替代来删除真实头节点的处理思路,出队将虚拟头节点删除,然后将真实头节点设置为虚拟头节点
如下图所示,同时执行入队和出队,出队操作只会影响 X 节点的 prev 指针和值,入队只会影响 X 节点的 next 指针,因此入队和出队操作互相不影响
image

5.4、removeWaiter()

入队和入队之间、入队和出队之间均是线程安全的,那么出队和出队之间是否线程安全呢?答案是否定的
参照 removeWaiter() 的代码实现,两个线程同时执行 removeWaiter() 函数,有可能删除的是同一个真实头节点
为了解决出队与出队之间的线程安全问题,我们可以使用类似 addWaiter() 的重构方式来重构 removeWaiter(),代码如下所示

public void removeWaiter() {
for (; ; ) {
Node oldHead = head;
if (oldHead.next == null) return;
if (unsafe.compareAndSwapObject(this, headOffset, oldHead, oldHead.next)) {
head.threadId = -1;
head.prev = null;
return;
}
}
}

不过对于 AQS 来说,等待队列实际上并不需要保证出队与出队之间的线程安全性
这是因为对于独享锁来说,一次只会有一个线程竞争到锁,不会出现多个线程同时执行出队的情况

6、课后思考题

经常听到有人说,使用 CAS 或者自旋 + CAS 就是无锁编程,你觉得这样的说法严谨吗?

  • 从硬件层面来看:CAS 底层依赖硬件提供的锁来实现,因此将 CAS 看做无锁编程不合理的
  • 从软件层面来说:CAS 没有使用 Java 提供的 synchronized 或 Lock 锁,因此将 CAS 看做无锁编程是合理的
posted @   lidongdongdong~  阅读(60)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开