多线程基础

CAS

多线程 CAS:无锁乐观锁,原子性保证,ABA 问题,自旋代价,AtomicInteger 实现详解。

#多线程基础#并发#CAS

CAS

一、什么是 CAS?

CAS 的全称是 Compare-And-Swap(比较并交换)。 它是并发编程中 乐观锁(Optimistic Locking)的核心实现方式。

它只做一件事:

“我认为内存里现在应该是数值 A,如果是,你就帮我把它改成数值 B;如果不是,那就告诉我失败了,我什么都不改。”

这整个“比较+修改”的过程,是 原子性 的(不可被打断)。


二、CAS 的三大要素

要执行 CAS,必须包含三个操作数:

  1. V (Value):要修改的 内存地址(内存中存的那个值)。
  2. E (Expected):我 期望 在这个地址看到的旧值(也就是我刚才读到的值)。
  3. N (New):我想把它修改成的 新值

CAS 的执行逻辑公式:

只有当 V == E 时,才把 V 更新为 N。 否则,不做任何操作。


三、执行流程

假设内存地址 V 处存着数字 10。 现在有两个线程,线程 A 和线程 B,同时要对 V 的数字进行累加。

CAS-flow-demo

1. 初始状态

  • 内存 V = 10。

2. 线程 A 进场

  • 读取:线程 A 读到 V = 10。它决定要把 10 变成 11。
  • 准备 CAS:期望值 E = 10,新值 N = 11。
  • 执行 CAS:线程 A 拿 E(10) 去跟内存 V(10) 比。
  • 相等吗?相等。
  • 结果:线程 A 成功 将内存 V 改为 11。

3. 线程 B 进场(慢了一步)

  • 读取:线程 B 实际上可能在 A 修改之前也读到了 10。它也决定要把 10 变成 11。
  • 准备 CAS:期望值 E = 10,新值 N = 11。
  • 执行 CAS:线程 B 拿 E(10) 去跟内存 V 比。
  • 关键点:此时内存 V 已经被 A 改成了 11。
  • 相等吗? 10 != 11,不相等。
  • 结果:线程 B 失败,内存 V 保持 11 不变。

4. 失败后的处理(自旋)

  • 线程 B 发现失败了,它不会报错,而是进入 自旋(Spin)
  • 重新读取内存 V,这次读到 11。
  • 重新计算:我要把它变成 12。
  • 再次发起 CAS:期望 E = 11,新值 N = 12。
  • 这次对比成功,修改为 12。

Important

⚠️ 一定注意: CAS 失败后就结束了CAS 本身是没有自旋的,自旋是我们自己对 CAS 失败做的处理策略


四、为什么 CAS 是原子的(底层原理)?

你可能会问:“比较和交换是两个动作啊,万一我比较完了,还没来得及交换,别人插队修改了怎么办?”

这就涉及到了操作系统和硬件层面:

  1. Java 层面 (Unsafe 类) Java 的 CAS 操作都是通过 sun.misc.Unsafe 类实现的(比如 compareAndSwapInt 方法)。这是一个这种直接操作内存的“后门”。
  2. 操作系统与 CPU 层面 Unsafe 里的方法是个 native 方法,它最终会编译成一条 CPU 指令:cmpxchg (Compare and Exchange)。
  • 在单核 CPU 上,一条指令天然是原子的。
  • 在多核 CPU 上:操作系统会在 cmpxchg 指令前加上 lock 前缀lock cmpxchg ...)。这个前缀会锁定 CPU 的总线(或者锁定缓存行),确保在执行这一条指令的微秒级时间内,其他 CPU 核心不能访问这块内存

结论:是 硬件指令 保证了“比较”和“交换”这两个动作合二为一,中间无法插入。


五、CAS 的三大缺陷

虽然 CAS 看起来很完美(无锁、高性能),但它有三个著名的坑:

1. ABA 问题(经典的狸猫换太子)

场景

  • 内存里原本是 A。
  • 线程 1 读到 A,准备改成 B,但稍微顿了一下。
  • 在此期间,线程 2 把 A 改成了 B,又把 B 改回了 A。
  • 线程 1 恢复运行,去对比:“咦?内存里还是 A 嘛,没变过,那我就改了。” -> 成功

后果: 虽然数值最终是对的,但在某些场景(比如栈结构、链表节点)中,这个“A”已经不是原来的“A”了,中间的状态丢失可能导致严重的数据错乱。

解决: 使用版本号,就像给数据打戳:1A -> 2B -> 3A。 Java 提供了 AtomicStampedReference 来解决这个问题(对比引用 + 版本号)。

2. 循环时间长开销大(自旋的代价)

如果并发极其剧烈,大量线程在 do-while 循环里 CAS 失败。 它们会不断地:读 -> 失败 -> 读 -> 失败... 这会让 CPU 使用率飙升(空转)。

解决:如果是这种极高并发场景,应该使用 LongAdder(分段处理),或者干脆使用悲观锁(synchronized)。

Important

⚠️ 再次说明一遍:CAS 本身是没有自旋的,自旋是我们自己对 CAS 失败做的处理策略

3. 只能保证一个共享变量的原子操作

CAS 只能锁住一个地址。如果你想原子的同时修改 xy 两个变量,普通的 CAS 做不到。

解决

  • xy 封装成一个对象,用 AtomicReference 进行 CAS。
  • 或者直接用 synchronized

六、AtomicInteger 例子详解

让我们来看看 AtomicInteger 是如何通过 CAS 实现并发下的累加操作的,以 AtomicIntegergetAndAdd 方法为突破口。

getAndAdd 方法

public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

可以看出,这里使用了 Unsafe 这个类,这里需要简要介绍一下 Unsafe 类:

Unsafe 类

Unsafe 是 CAS 的核心类。Java 无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM 还是开了一个后门,JDK 中有一个类 Unsafe,它提供了硬件级别的原子操作。

AtomicInteger 加载 Unsafe 工具,用来直接操作内存数据

public class AtomicInteger extends Number implements java.io.Serializable {
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
    public final int get() {return value;}
}

在 AtomicInteger 数据定义的部分,我们还获取了 unsafe 实例,并且定义了 valueOffset。再看到 static 块,懂类加载过程的都知道,static 块的加载发生于类加载的时候,是最先初始化的,这时候我们调用 unsafe 的 objectFieldOffset 从 Atomic 类文件中获取 value 的偏移量,那么 valueOffset 其实就是记录 value 的偏移量的。

valueOffset 表示的是变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的原值的,这样我们就能通过 unsafe 来实现 CAS 了。value 是用 volatile 修饰的,保证了多线程之间看到的 value 值是同一份。

Unsafe 的 getAndAddInt 方法的实现

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

我们看 o 获取的是什么,通过调用 unsafegetIntVolatile(o, offset),这是个 native 方法,其实就是获取 o 中,offset 偏移量处的值。

o 就是 AtomicIntegeroffset 就是我们前面提到的 valueOffset, 这样我们就从内存里获取到现在 valueOffset 处的值了。 现在重点来了,compareAndSwapInt(o, offset, delta, v + delta) 其实换成 compareAndSwapInt(obj, offset, expect, update) 比较清楚,意思就是如果 obj 内的 valueexpect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update如果这一步的 CAS 没有成功,那就采用自旋的方式继续进行 CAS 操作

UnsafegetAndAddInt 方法分析:自旋 + CAS,在这个过程中,通过 compareAndSwapInt 比较并更新 value 值,如果更新失败,重新获取,然后再次更新,直到更新成功。