CAS 与原子类:Java 如何实现无锁编程

「Java 进阶之路」系列 Day07

写在前面

Day05 讲 AQS 的时候提到过一句"非公平锁直接 CAS 抢",Day06 讲读写锁的 state 时也说"通过对 state 做 CAS 操作完成"------CAS 这个词已经出现好几次了,但一直没有展开讲。这篇就正式把它讲透:java.util.concurrent.atomic 包是怎么不用 synchronized 加锁、纯靠 CAS 就实现线程安全的,以及 CAS 绕不开的经典坑------ABA 问题。


一、是什么:atomic 包提供的无锁线程安全

java.util.concurrent.atomic 提供的是无锁(Lock-Free) 的线程安全操作,底层依赖 CPU 的 CAS 指令,不需要像 synchronized/Lock 那样挂起线程,开销更小。一共分四类:

分类 代表类 说明
基本类型 AtomicInteger AtomicLong AtomicBoolean 单个基本类型的原子操作
引用类型 AtomicReference AtomicStampedReference AtomicMarkableReference 对象引用的原子更新,后两者解决ABA
数组类型 AtomicIntegerArray AtomicLongArray AtomicReferenceArray 数组元素的原子操作
字段更新器 AtomicIntegerFieldUpdater 等 给已有类的volatile字段做原子更新,不用改类结构

选型原则 :纯计数累加首选 LongAdder(本文最后讲);需要 CAS 判断并读到旧值用 AtomicLong;更新对象引用用 AtomicReference;有 ABA 风险时换 AtomicStampedReference


二、为什么:不加锁怎么保证线程安全

AtomicInteger 的核心方法长这样:

java 复制代码
AtomicInteger ai = new AtomicInteger(0);

ai.get();                          // 读取当前值
ai.incrementAndGet();               // 等价于++i,返回新值
ai.getAndIncrement();               // 等价于i++,返回旧值
ai.compareAndSet(5, 10);            // CAS:当前值等于5时才设为10,返回是否成功

// JDK 8之后支持函数式写法
ai.updateAndGet(x -> x * 2);

看一眼简化后的底层实现就明白它为什么不需要加锁:

java 复制代码
public class AtomicInteger {
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private volatile int value;   // volatile保证可见性

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
}

volatile 负责可见性(还记得 Day03/Day04 讲的吗),Unsafe.compareAndSwapInt 这一步则由 CPU 硬件指令保证原子性------两者配合,不需要 synchronized 挂起线程排队,就实现了线程安全。这就是"无锁编程"的核心思路。


三、CAS 原理:三个操作数 + 自旋重试

CAS = Compare And Swap,接受三个参数:

css 复制代码
CAS(内存地址V, 期望值A, 新值B)
  如果 V等于A
      V设为B
      返回成功
  否则
      返回失败,说明V已经被别的线程改过了

单独一次 CAS 只是"尝试一次",实际使用时几乎都会配合自旋------失败了就重新读取最新值再试一次,直到成功为止:

flowchart LR A[读取旧值<br/>并计算新值] --> B{执行<br/>compareAndSet} B -->|成功| C[结束] B -->|失败 <br/>被别的线程抢先改了| A

对应到 AtomicInteger.incrementAndGet() 内部的典型写法:

java 复制代码
int oldValue, newValue;
do {
    oldValue = value;
    newValue = oldValue + 1;
} while (!compareAndSet(oldValue, newValue));   // 失败就重试

两个线程并发自增时的实际过程:

时刻 线程 A 内存 V 线程 B
1 读到 V 等于 0 V 等于 0
2 计算 new 等于 1 读到 V 等于 0
3 CAS 把 0 变 1,成功 V 变 1 计算 new 等于 1
4 V 仍是 1 CAS 把 0 变 1,失败(V 已经是 1)
5 重新读到 V 等于 1
6 计算 new 等于 2
7 V 变 2 CAS 把 1 变 2,成功

在 x86 上,这条 CAS 指令最终对应 LOCK CMPXCHGLOCK 前缀会锁住总线或缓存行,保证整个比较交换操作是原子的,不会被其他核心的操作打断。

CAS 也有代价,不是万能药

缺点 说明
ABA 问题 值经历了 A 变 B 又变回 A,CAS 感知不到中间被改过(下面细讲)
自旋开销 竞争激烈时大量线程反复重试,CPU 空转飙高
只能保护一个变量 不像 synchronized 能把多个变量的修改包在一个原子操作里

四、ABA 问题:CAS 最经典的坑

问题场景

css 复制代码
时刻1 线程A读到栈顶是节点A
时刻2 线程B把A弹出 又把B弹出 然后又把A重新压回去
时刻3 线程A执行CAS 期望值还是A 一看当前确实是A 于是成功
      但这个A已经不是原来那个上下文了 中间的结构变化被完全忽略

CAS 只比较"值是否相等",不管这个值在中间是不是被改动过又改回来------这就是 ABA 问题的本质:CAS 能保证"当前值等于期望值",但不能保证"这段时间没人动过它"

解决方案:加一个版本戳

java 复制代码
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 1);

// 线程A:读到值="A",版本戳=1
int[] stamp = new int[1];
String val = ref.get(stamp);   // val是A,stamp[0]是1

// 期间线程B操作:A变B(版本戳变2),B又变回A(版本戳变3)

// 线程A的CAS:期望值是A,期望戳是1,两者都要匹配
boolean success = ref.compareAndSet(val, "C", stamp[0], 2);
// 结果失败!因为当前版本戳已经是3,不等于期望的1

AtomicStampedReference 在值之外多带了一个 int 版本戳,每次修改戳都会递增,即使值又变回了原来那个,戳也回不去了,从而让"中间是否被改动过"变得可判断。如果只需要知道"有没有被动过"而不关心具体动了几次,还有个更轻量的 AtomicMarkableReference,用 boolean 标记代替 int 戳。

场景 方案
普通引用替换,没有 ABA 风险 AtomicReference
需要检测是否被中间修改过 AtomicStampedReference(int版本戳)
只需要知道有没有被动过 AtomicMarkableReference(boolean标记)

五、LongAdder:解决高并发下 AtomicLong 的性能瓶颈

AtomicLong 有个天生的短板:高并发场景下,所有线程都在对同一个 value 做 CAS,竞争越激烈,失败重试次数越多,CPU 空转越严重:

objectivec 复制代码
线程1 CAS 0变1 成功
线程2 CAS 0变1 失败 重试CAS 1变2 成功
线程3 CAS 0变1 失败 重试CAS 1变2 失败 再重试CAS 2变3 成功

LongAdder(JDK 8 引入)用分桶 思想解决这个问题:低竞争时和 AtomicLong 一样直接 CAS 一个 base;一旦竞争激烈,就把不同线程分散映射到不同的 Cell 上各自做 CAS,互不干扰:

flowchart TB T1[线程1] --> C0[Cell0做CAS加1] T4[线程4] --> C0 T2[线程2] --> C1[Cell1做CAS加1] T3[线程3] --> C2[Cell2做CAS加1] C0 --> SUM[sum等于base加上所有Cell之和] C1 --> SUM C2 --> SUM

读取总数时调用 sum(),把 base 和所有 Cell 累加起来------注意这是非强一致 的,sum() 执行期间如果还有并发写入,读到的结果可能和真实瞬时值有些许偏差。

对比项 AtomicLong LongAdder
并发写性能 高竞争下自旋多 性能下降明显 分桶分散竞争 高并发吞吐高很多
读取 get直接读 精确 sum非强一致 读写并发时可能有偏差
内存占用 一个long base加Cell数组 占用更多
功能 支持get set compareAndSet等完整操作 只支持add increment sum 不暴露CAS接口
适用场景 需要精确值或CAS判断 纯计数累加 对实时精确值要求不高
java 复制代码
// 纯计数场景:优先用 LongAdder
LongAdder counter = new LongAdder();
counter.increment();
long total = counter.sum();   // 近似的总和

// 需要CAS判断(比如只执行一次的标志位):只能用AtomicLong
AtomicLong flag = new AtomicLong(0);
if (flag.compareAndSet(0, 1)) {
    // 只有第一个执行到这里的线程会进入
}

一句话总结LongAdder 用空间换时间,把一个热点变量拆成多个 Cell 分散竞争,代价是牺牲了 compareAndSet 这种需要读到确切当前值的能力,所以纯计数用它,需要判断条件再更新的场景还得用 AtomicLong


六、面试追问

Q1:CAS 是什么,包含哪三个操作数?

CAS(Compare And Swap)接受内存地址、期望值、新值三个操作数:如果内存地址当前的值等于期望值,就把它更新为新值并返回成功;否则说明值已经被别的线程改过,返回失败,不做任何修改。它由 CPU 硬件指令(x86 下是 LOCK CMPXCHG)保证整个比较交换过程的原子性。

Q2:AtomicInteger 为什么不用加锁就能保证线程安全?

它内部用 volatile int value 保证可见性(每次读都拿到最新值,写完立即刷新到主内存),配合 Unsafe.compareAndSwapInt 调用 CPU 的 CAS 指令保证修改操作的原子性。两者结合,不需要让线程挂起排队等锁,就实现了线程安全,这也是"无锁编程"性能通常优于加锁方案的原因。

Q3:什么是 ABA 问题,举个例子?

ABA 问题指一个值从 A 变成 B、又变回 A,此时执行 CAS 检查会发现"当前值仍然等于期望的 A"从而判断成功,但实际上这段时间值已经被修改过,只是恰好又变回了原来的样子。经典例子是无锁栈:线程 A 读到栈顶是节点 A,此时线程 B 把 A 弹出、弹出下一个节点、再把 A 重新压回去,线程 A 后续的 CAS 会误以为栈没有变化过,但实际的链表结构已经不同了。

Q4:怎么解决 ABA 问题?

AtomicStampedReference 给值附加一个版本戳,每次修改戳都递增;CAS 时要求值和戳都同时匹配才算成功,即使值又变回了原来那个,戳也不会回退,从而能感知到"中间发生过修改"。如果只需要知道"有没有被改过"而不关心改了几次,可以用更轻量的 AtomicMarkableReference(用 boolean 标记代替 int 戳)。

Q5:LongAdder 相比 AtomicLong 好在哪里,什么场景该用哪个?

AtomicLong 所有线程竞争同一个变量做 CAS,高并发下失败重试次数暴涨,CPU 空转严重;LongAdder 把热点变量拆分成多个 Cell,不同线程分散到不同 Cell 上各自累加,最后求和汇总,大幅减少了竞争。代价是 sum() 是非强一致的,且不提供 compareAndSet 这类需要读到精确当前值的接口。所以纯粹的计数、累加场景(比如接口调用次数统计)优先用 LongAdder;需要根据当前精确值做判断再更新的场景(比如"只允许第一个线程执行一次")还是要用 AtomicLong


下一篇预告

Day08 讲并发容器全家桶------ConcurrentHashMapCopyOnWriteArrayList 这些线程安全的集合底层是怎么实现的,和普通集合、以及给普通集合简单加锁比起来,到底强在哪。

相关推荐
颜进强1 小时前
前端看后端 13:什么是 Cookie 和 Session?
前端·后端
TunerT_TQ1 小时前
【智能体安全治理|专栏第9期】从“一堆规则”到“数字宪法”:智能体治理的下一个阶段
java·开发语言·安全·开源治理·大模型安全·ai基础设施·智能体安全
gitboyzcf1 小时前
mpegts.js解决Chrome无法播放7568×142分辨率报错 DOMException问题
前端·后端
legendary_bruce1 小时前
RAG知识库进阶-1
java·aigc
必须会一定会1 小时前
大模型手搓文件对比工具(6):差异不用再手选
java·人工智能·ai编程
yio_yin1 小时前
MyBatis
java·前端·mybatis
程序员-Benothing1 小时前
Java ForkJoinPool 详解:从分治思想到高性能并行计算
java·开发语言·后端·面试·职场和发展
上海安当技术2 小时前
单点登录 SSO 怎么选协议?SAML 2.0 / OAuth 2.0 / OIDC / CAS 对比与 ERP、OA、CRM 接入实战
java·开发语言
mifengxing2 小时前
Java集合与泛型
java·算法·复习笔记