volatile 与 Atomic 全过程原理
volatile、Atomic 原子类、CAS 是 Java 并发里的基础骨架。很多面试回答只背一句“volatile 保证可见性,不保证原子性”,但真正写生产代码时,你必须知道:它为什么能让别的线程看见,为什么 count++ 仍然会错,Atomic 为什么能原子更新,CAS 为什么会自旋,什么时候不用无锁反而更稳。
学习目标
| 目标 | 你要能说明白 |
|---|---|
| 并发三大问题 | 可见性、原子性、有序性分别是什么 |
| volatile 原理 | volatile 写和读如何建立 happens-before |
| 内存屏障 | 为什么 volatile 能限制重排序和刷新可见性 |
| 原子性边界 | 为什么 volatile 读写单次可见,但 count++ 不是原子操作 |
| CAS 原理 | Compare And Swap 的比较和交换为什么必须不可拆分 |
| Atomic 原子类 | AtomicInteger、AtomicReference、AtomicStampedReference、LongAdder 怎么选 |
| 商业场景 | 开关、状态流转、指标统计、配置发布怎么落地 |
| 线上排查 | CPU 高、自旋重试、计数不准、状态错乱怎么分析 |
| 面试闭环 | 标准回答能跳回原理页解释为什么 |
先建立三件事
并发问题不是一个问题,而是三类问题。
flowchart TD
A["多个线程访问共享数据"] --> B["可见性问题"]
A --> C["原子性问题"]
A --> D["有序性问题"]
B --> E["一个线程改了值,另一个线程看不到"]
C --> F["一个动作由多步组成,中途被打断"]
D --> G["编译器或 CPU 为优化改变执行顺序"]| 问题 | 零基础解释 | 典型错误 |
|---|---|---|
| 可见性 | A 改了变量,B 还在看旧副本 | 普通 boolean running 停不下来 |
| 原子性 | 一个操作不能被拆开才安全 | 多线程 count++ 丢失更新 |
| 有序性 | 实际执行顺序可能和代码顺序不同 | 双重检查单例暴露半初始化对象 |
volatile 主要解决可见性和有序性,不解决复合操作原子性。Atomic 主要解决简单变量的原子更新。
JMM 视角:线程为什么会看不到最新值
Java 内存模型可以简化理解成:
flowchart TD
A["主内存中的共享变量"] --> B["线程 A 工作内存"]
A --> C["线程 B 工作内存"]
B --> D["线程 A 修改副本"]
D --> E["写回主内存的时机不确定"]
C --> F["线程 B 可能继续读旧副本"]这里的“工作内存”不是 JVM 运行时数据区里某个具体区域,而是 JMM 为了描述多线程可见性抽象出来的概念。真实硬件上可能对应寄存器、CPU 缓存、写缓冲、编译器优化后的临时值。
如果没有明确规则,下面代码可能停不下来:
public class StopFlagWrong {
private static boolean running = true;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
while (running) {
// busy work
}
System.out.println("stopped");
});
worker.start();
Thread.sleep(1000);
running = false;
}
}问题不是主线程没改,而是工作线程没有可靠规则保证一定重新读取到主线程改过的值。
volatile 做了什么
private static volatile boolean running = true;volatile 的核心语义:
| 能力 | 含义 |
|---|---|
| 可见性 | 一个线程写 volatile 变量后,其他线程后续读这个 volatile 变量能看到新值 |
| 有序性 | volatile 写之前的普通写,不能被重排到 volatile 写之后;volatile 读之后的普通读,不能被重排到 volatile 读之前 |
| 单次读写原子性 | 对 volatile 变量的一次读或一次写本身是原子的 |
| 不保证复合原子性 | count++ 这种读、改、写三步不安全 |
流程:
flowchart TD
A["线程 A 修改普通变量 x"] --> B["线程 A 写 volatile flag"]
B --> C["volatile 写建立发布语义"]
C --> D["线程 B 读 volatile flag"]
D --> E["volatile 读建立获取语义"]
E --> F["线程 B 能看到 A 写 flag 前的普通变量修改"]标准规则:
对一个 volatile 变量的写,happens-before 后续任意线程对这个 volatile 变量的读。它不是说“时间上 A 先写,B 后读就行”,而是说:如果 B 读到了 A 写入的 volatile 值,那么 A 在写 volatile 之前的结果,也必须对 B 可见。
内存屏障是什么
为了实现 volatile 语义,JVM 和底层 CPU 会使用内存屏障。你可以把内存屏障理解为一类约束:
| 屏障作用 | 解决什么 |
|---|---|
| 禁止特定重排序 | 不让关键读写被优化到错误位置 |
| 刷新写入 | 让写入更快对其他线程可见 |
| 重新读取 | 不让线程一直使用旧缓存或旧寄存器值 |
不用死背每个平台的指令,但要知道 volatile 的真实目的不是“每次都从内存慢慢读一下”这么简单,而是通过 JMM 规则和底层屏障,让不同平台上都能得到一致的并发语义。
volatile 写读全过程
以配置发布为例:
import java.util.Map;
public class ConfigPublish {
private Map<String, String> config;
private volatile boolean ready;
public void load() {
config = Map.of("timeout", "3000");
ready = true;
}
public String get(String key) {
if (!ready) {
return null;
}
return config.get(key);
}
}流程:
flowchart TD
A["线程 A 创建 config"] --> B["线程 A 写普通字段 config"]
B --> C["线程 A 写 volatile ready=true"]
C --> D["线程 B 读 volatile ready"]
D --> E{"ready 是否为 true"}
E -->|是| F["B 读取 config"]
E -->|否| G["配置未就绪"]为什么读到 ready == true 后可以读 config?
因为 config = ... 在 ready = true 之前,volatile 写读建立 happens-before,所以线程 B 读到 ready == true 后,能看到 A 写 ready 之前的 config 写入。
如果 ready 不是 volatile,线程 B 可能看到 ready == true 却看不到最新 config,也可能因为重排序导致发布顺序出问题。
volatile 为什么不能保证 count++
count++ 不是一个操作,而是三个动作:
flowchart TD
A["读取 count 当前值"] --> B["在当前值基础上加 1"]
B --> C["把新值写回 count"]两个线程同时执行:
count 初始值 = 0
线程 A 读到 0
线程 B 读到 0
线程 A 计算 1
线程 B 计算 1
线程 A 写回 1
线程 B 写回 1
执行了两次 ++,结果却是 1Demo:
public class VolatileNotAtomicDemo {
private static volatile int count = 0;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count++;
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(count);
}
}期望是 100000,实际经常小于它。原因是 volatile 保证“每次读写的可见性”,但不能把读、加、写三步合成一个不可打断的整体。
CAS 是什么
CAS 是 Compare And Swap,比较并交换。它依赖 CPU 原子指令保证下面动作不可拆分:
如果内存当前值 == 我预期的旧值,就改成新值;
否则说明别人改过,本次失败。流程:
flowchart TD
A["读取旧值 old"] --> B["根据 old 计算 new"]
B --> C["CAS 比较内存当前值"]
C --> D{"当前值是否仍等于 old"}
D -->|是| E["写入 new,更新成功"]
D -->|否| F["说明被其他线程改过"]
F --> G["重新读取并重试"]
G --> ACAS 的优势是:线程失败后不一定进入阻塞,可以重新尝试,所以低竞争、短逻辑的场景性能很好。
CAS 的代价是:竞争激烈时会不断失败重试,CPU 可能很高。
AtomicInteger 怎么工作
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterDemo {
private static final AtomicInteger COUNT = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
COUNT.incrementAndGet();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(COUNT.get());
}
}incrementAndGet() 可以理解成:
for (;;) {
int oldValue = get();
int newValue = oldValue + 1;
if (compareAndSet(oldValue, newValue)) {
return newValue;
}
}真实源码会使用 JVM 提供的底层能力,比如 Unsafe 或 VarHandle。核心思想不变:读取旧值、计算新值、CAS 更新,失败就重试。
AtomicReference:对象整体替换
多个字段要一起保持一致时,不要把每个字段都做成一个 Atomic,然后分别更新。更稳的方式是封装成不可变对象,用 AtomicReference 整体替换。
import java.util.concurrent.atomic.AtomicReference;
public class RuleConfigHolder {
private static class RuleConfig {
final int version;
final String mode;
final int timeoutMs;
RuleConfig(int version, String mode, int timeoutMs) {
this.version = version;
this.mode = mode;
this.timeoutMs = timeoutMs;
}
}
private final AtomicReference<RuleConfig> ref =
new AtomicReference<>(new RuleConfig(1, "NORMAL", 3000));
public boolean update(int oldVersion, String mode, int timeoutMs) {
RuleConfig oldConfig = ref.get();
if (oldConfig.version != oldVersion) {
return false;
}
RuleConfig newConfig = new RuleConfig(oldVersion + 1, mode, timeoutMs);
return ref.compareAndSet(oldConfig, newConfig);
}
public String currentMode() {
return ref.get().mode;
}
}这样可以保证读线程看到的永远是一份完整配置,不会看到 mode 已经更新、timeoutMs 还是旧值的半成品。
ABA 问题
CAS 只比较“当前值是不是旧值”,它不知道中间是否发生过变化。
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant V as 共享变量
T1->>V: 读取 A
T2->>V: A 改成 B
T2->>V: B 又改回 A
T1->>V: CAS 发现还是 A,更新成功线程 1 以为“没变化”,但实际上中间经历了 A -> B -> A。
解决思路:
| 方案 | 原理 | Java 工具 |
|---|---|---|
| 加版本号 | 值相同也要比较版本是否变过 | AtomicStampedReference |
| 加标记位 | 比较引用和布尔标记 | AtomicMarkableReference |
| 不用 CAS 做复杂状态 | 用锁或事务保证完整流程 | synchronized、Lock、数据库事务 |
Demo:
import java.util.concurrent.atomic.AtomicStampedReference;
public class AbaFixedDemo {
public static void main(String[] args) {
AtomicStampedReference<String> ref =
new AtomicStampedReference<>("A", 1);
int[] stampHolder = new int[1];
String oldValue = ref.get(stampHolder);
int oldStamp = stampHolder[0];
ref.compareAndSet("A", "B", 1, 2);
ref.compareAndSet("B", "A", 2, 3);
boolean success = ref.compareAndSet(oldValue, "C", oldStamp, oldStamp + 1);
System.out.println(success); // false
}
}虽然值又变回了 A,但版本已经从 1 变成 3,所以旧 CAS 失败。
LongAdder 为什么高并发计数更快
AtomicLong 所有线程竞争一个值:
flowchart TD
A["线程1"] --> D["同一个 AtomicLong value"]
B["线程2"] --> D
C["线程3"] --> D
D --> E["竞争激烈时 CAS 频繁失败"]LongAdder 把竞争拆散:
flowchart TD
A["线程1"] --> D["Cell 0"]
B["线程2"] --> E["Cell 1"]
C["线程3"] --> F["Cell 2"]
D --> G["sum 时汇总"]
E --> G
F --> G它适合高并发统计,比如 QPS、接口调用次数、限流指标。
import java.util.concurrent.atomic.LongAdder;
public class MetricCounter {
private final LongAdder requestCount = new LongAdder();
public void record() {
requestCount.increment();
}
public long total() {
return requestCount.sum();
}
}注意:LongAdder.sum() 不是强一致快照。统计指标一般可以接受短暂误差;如果你要用“当前值必须严格等于某个值才更新”的条件判断,应该使用 Atomic 或锁。
volatile、Atomic、LongAdder、锁怎么选
| 场景 | 推荐 | 原因 |
|---|---|---|
| 停止标记 | volatile boolean | 只需要可见性 |
| 配置是否加载完成 | volatile ready | 需要发布语义 |
| 简单计数,竞争不高 | AtomicInteger / AtomicLong | CAS 简单直接 |
| 高并发指标统计 | LongAdder | 分散热点,吞吐高 |
| 状态流转 | AtomicReference 或 AtomicInteger | CAS 控制从旧状态到新状态 |
| 多字段一致性 | 锁或不可变对象 + AtomicReference | 避免半更新 |
| 复杂业务流程 | synchronized / Lock / 事务 | 可读性和一致性更重要 |
不要迷信“无锁一定更快”。无锁适合逻辑短、冲突低、状态简单的场景。冲突高或业务复杂时,CAS 自旋会让 CPU 空转,代码也会变难维护。
商业场景一:功能开关与指标统计
import java.util.concurrent.atomic.LongAdder;
public class FeatureMetric {
private volatile boolean enabled = true;
private final LongAdder requestCount = new LongAdder();
public void handle() {
if (!enabled) {
return;
}
requestCount.increment();
// 执行业务处理
}
public void close() {
enabled = false;
}
public long count() {
return requestCount.sum();
}
}解释:
| 字段 | 为什么这样设计 |
|---|---|
enabled | 开关变化要尽快被业务线程看到,用 volatile |
requestCount | 只做高并发统计,用 LongAdder 分散竞争 |
handle | 读开关后处理,关闭后允许少量已进入请求继续完成 |
如果要求关闭瞬间绝对没有任何请求继续执行,只用 volatile 不够,需要锁、网关下线、流量切断或状态机控制。
商业场景二:订单状态 CAS 流转
订单状态必须按规则流转,例如只能从 WAIT_PAY 到 PAID。
import java.util.concurrent.atomic.AtomicReference;
public class OrderStateMachine {
enum Status {
WAIT_PAY, PAID, CLOSED
}
private final AtomicReference<Status> status =
new AtomicReference<>(Status.WAIT_PAY);
public boolean pay() {
return status.compareAndSet(Status.WAIT_PAY, Status.PAID);
}
public boolean close() {
return status.compareAndSet(Status.WAIT_PAY, Status.CLOSED);
}
public Status current() {
return status.get();
}
}这个 demo 适合本地内存状态流转。真实订单系统不能只靠 JVM 内存 Atomic,因为订单状态在数据库里,还要考虑数据库事务、幂等、分布式锁或乐观锁版本号。
线上排查
count++ 统计不准
排查顺序:
flowchart TD
A["统计结果少了"] --> B["检查是否多线程写普通 int/long"]
B --> C{"是否使用 count++"}
C -->|是| D["改 Atomic 或 LongAdder"]
C -->|否| E["检查是否多节点统计未聚合"]
E --> F["检查异步丢任务、异常吞掉、重复覆盖"]CPU 高但线程没阻塞
如果线程栈显示大量线程在 Atomic 更新、CAS 循环、无锁队列自旋,说明可能是高竞争自旋。
处理思路:
| 现象 | 处理 |
|---|---|
| AtomicLong 热点计数 CPU 高 | 改 LongAdder |
| CAS 状态更新失败率高 | 降低并发、分片、改锁 |
| 自旋逻辑里有复杂计算 | 不要用 CAS 包复杂业务,改锁或队列串行化 |
| 线上状态偶发错乱 | 检查是否多个 Atomic 字段分别更新导致不一致 |
volatile 开关不生效
先确认:
| 检查点 | 说明 |
|---|---|
| 是否所有线程读同一个字段 | 多实例、多 ClassLoader、多节点都可能不是同一个变量 |
| 字段是否真的加了 volatile | 只给局部变量或错误字段加没用 |
| 线程是否卡在阻塞调用里 | volatile 只能让循环读到新值,不能中断 socketRead 或 sleep |
| 是否需要跨 JVM 生效 | volatile 只在同一个 JVM 内有效,跨服务要用配置中心、Redis、MQ |
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 认为 volatile 等于线程安全 | 复合操作仍然丢更新 | 简单原子更新用 Atomic,复杂逻辑用锁 |
| 多字段分别 Atomic | 字段之间不一致 | 封装不可变对象整体 CAS,或加锁 |
| CAS 无限重试 | CPU 飙高 | 限制重试、退避、分片或改锁 |
| LongAdder 做严格判断 | sum() 不是强一致快照 | 严格条件更新用 Atomic 或锁 |
| volatile 控制跨服务开关 | 只对当前 JVM 有效 | 用配置中心或分布式存储 |
| AtomicReference 更新可变对象 | 引用没变但对象内部被改乱 | 用不可变对象整体替换 |
面试标准回答
volatile 有什么作用
volatile 主要保证可见性和有序性。对 volatile 变量的写 happens-before 后续对这个变量的读,所以一个线程写 volatile 前的普通写,对另一个读到该 volatile 值的线程可见。同时 volatile 会通过内存屏障限制关键重排序。但它不保证复合操作原子性,所以 volatile int count 的 count++ 仍然不安全。
volatile 为什么不能保证 count++
因为 count++ 不是单次写,而是读取、加一、写回三步。多个线程可能同时读到相同旧值,再各自写回相同新值,导致丢失更新。volatile 只能保证每次读写的可见性,不能把三步合成不可打断的整体。
AtomicInteger 为什么线程安全
AtomicInteger 内部使用 volatile 保存值,用 CAS 保证更新原子性。自增时先读取旧值,计算新值,再用 CAS 判断内存当前值是否仍然等于旧值;如果是就写入新值,如果不是说明被其他线程改过,就重新读取并重试。
CAS 有什么问题
CAS 常见问题有三个:ABA、自旋开销和只能原子更新单个变量。ABA 可以通过版本号或 AtomicStampedReference 解决;自旋开销在高竞争下会导致 CPU 升高;多个字段一致性不能靠多个 CAS 随便拼,应该用锁或不可变对象整体替换。
LongAdder 和 AtomicLong 怎么选
AtomicLong 适合需要精确当前值和条件 CAS 更新的场景;LongAdder 适合高并发统计指标,它通过多个 Cell 分散竞争,吞吐通常更高,但 sum() 不是严格一致快照,所以不适合做强一致条件判断。
关联知识点
| 知识点 | 为什么要看 |
|---|---|
| volatile与Atomic基础 | 快速回顾基本概念和入门 Demo |
| Java内存模型 | 理解 happens-before、可见性和有序性 |
| CAS原理 | 深入理解比较并交换、ABA 和自旋 |
| synchronized全过程 | 对比 volatile、Atomic 和锁的边界 |
| AQS与JUC全过程 | 理解 JUC 里 volatile state + CAS 的通用模型 |
