volatile和Atomic到底解决什么问题
volatile 和 Atomic 经常被放在一起讨论,但它们解决的并不是同一个问题:volatile 主要保证共享变量的可见性和一定的有序性,Atomic 则通过 CAS 提供原子更新能力。这篇结合停止标志、计数器和版本更新场景,讲清两者原理、边界、区别,以及什么时候仍然需要锁。
前面两篇分别讲了 synchronized 关键字到底锁住了什么和 Lock 和 Condition 怎么实现线程协作。
这次继续往下拆两个经常被放在一起比较的东西:volatile 和 Atomic 原子类。
很多并发问题其实不是因为“没加锁”这么简单,而是没有分清自己需要的是哪种能力:
- 只需要一个线程修改后,其他线程马上看得到:考虑
volatile - 需要对计数器做安全的加减:考虑 Atomic
- 需要保护多个变量组成的一组状态:通常还是需要 synchronized 或 Lock
volatile 和 Atomic 都可以减少部分锁的使用,但它们不是同一种工具,更不是所有并发问题的通用替代品。
并发问题从哪里来
Java 多线程问题通常可以拆成三个关键词:
- 原子性:一组操作不能被拆开观察
- 可见性:一个线程的修改能被其他线程及时看到
- 有序性:程序执行顺序不会被重排到破坏业务语义
线程A 修改共享变量
│
├── 其他线程看不到:可见性问题
├── 读改写被插入:原子性问题
└── 初始化顺序被打乱:有序性问题
volatile 主要解决可见性,并对相关读写建立有序性约束。
Atomic 主要解决某个变量更新动作的原子性,同时也具有相应的可见性保证。
如果要保护多个变量之间的一致关系,单独使用它们往往不够。
volatile 保证什么
volatile 用来修饰共享变量,告诉 JVM:这个变量的读取和写入不能只依赖线程自己的工作内存,要遵守更严格的可见性规则。
最经典的例子是停止标志:
public class Worker implements Runnable {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
doWork();
}
}
public void stop() {
running = false;
}
private void doWork() {
// 执行一小段任务
}
}
如果没有 volatile,工作线程可能一直读取到旧值,迟迟感知不到 stop 方法写入的 false。
加上 volatile 后,线程可以看到最新状态,循环能够正常退出。
控制线程:running = false
│
▼
volatile 写入建立可见性
│
▼
工作线程重新读取到 false
│
▼
循环退出
这类变量通常只有简单的独立读写,不涉及多个步骤的复合更新,非常适合使用 volatile。
volatile 不保证复合操作
下面这个例子很容易误判:
private volatile int count = 0;
public void increase() {
count++;
}
count++ 不是一个操作,而是三个步骤:
读取 count
↓
加一
↓
写回 count
两个线程可能同时读取到 10,然后都写回 11。
即使 count 是 volatile,也只能保证读写可见,不能把这三个动作合并成不可分割的一步。
线程A:读 10 -> 计算 11 -> 写 11
线程B:读 10 -> 计算 11 -> 写 11
期望 12,实际 11
这是 volatile 最重要的边界:可见,不等于复合操作原子。
volatile 还解决什么
除了可见性,volatile 写入和后续读取之间还会建立 happens-before 关系,并限制一部分指令重排序。
常见场景是安全发布一个已经构造完成的引用:
private volatile Config config;
public void refresh(Config newConfig) {
// newConfig 应该在这里之前已经完整构造
config = newConfig;
}
public Config getConfig() {
return config;
}
其他线程读取到新引用后,能够看到发布前已经完成的初始化结果。
但这不代表对象内部所有后续修改都自动线程安全。
如果对象本身还会被多个线程继续修改,仍然需要额外的同步设计。
不要把 volatile 理解成“给对象全家桶加锁”。
Atomic 是什么
Atomic 类位于 java.util.concurrent.atomic 包中。
常见类型包括:
AtomicIntegerAtomicLongAtomicBooleanAtomicReferenceAtomicIntegerArrayLongAdder
它们通常基于 CAS,也就是 Compare And Set。
CAS 的意思是:只有当变量仍然等于预期值时,才把它更新成新值;如果已经被其他线程改过,就更新失败或重试。
读取当前值 10
│
▼
比较:现在还是 10 吗?
├── 是:更新为 11
└── 否:说明有人改过,重新读取再尝试
这是一种乐观并发控制思路。
线程不会先把其他线程挡在门外,而是先尝试更新,发现冲突后再处理。
AtomicInteger 怎么用
用 AtomicInteger 改造计数器:
private final AtomicInteger count = new AtomicInteger();
public void increase() {
count.incrementAndGet();
}
public int current() {
return count.get();
}
incrementAndGet 会把读取、加一、写回作为一个原子更新操作处理。
常见方法还有:
count.get();
count.set(10);
count.incrementAndGet();
count.getAndIncrement();
count.addAndGet(5);
count.compareAndSet(10, 20);
incrementAndGet 返回加一后的值。
getAndIncrement 返回加一前的值。
compareAndSet 则允许我们显式表达“只有旧值还是预期值时才更新”。
AtomicReference
Atomic 不只支持数字,也可以原子更新对象引用:
private final AtomicReference<Config> current =
new AtomicReference<>();
public void update(Config next) {
current.set(next);
}
public Config get() {
return current.get();
}
如果需要基于旧对象进行条件更新,可以使用 CAS:
Config oldConfig = current.get();
Config newConfig = buildNext(oldConfig);
boolean success = current.compareAndSet(oldConfig, newConfig);
失败时说明期间有其他线程更新过,需要重新读取并重试,或者按业务返回失败。
ABA 问题
CAS 有一个经典问题叫 ABA。
线程 A 读取到值 A,准备更新;线程 B 把值从 A 改成 B,又改回 A;线程 A 再比较时发现还是 A,于是以为期间没有变化。
线程A:读取 A,准备 CAS
线程B:A -> B -> A
线程A:看到还是 A,CAS 成功
但 A 实际上已经被改过两次
如果业务只关心最终值,ABA 可能没有影响。
如果业务还关心版本变化,就需要把版本号一起纳入判断,或者使用 AtomicStampedReference。
数据:A
版本:1
A -> B -> A
版本:1 -> 2 -> 3
值相同,但版本已经不同
这也是 Atomic 不是“无脑安全”的原因。
它提供的是原子更新工具,业务语义仍然要自己设计。
volatile 和 Atomic 怎么比较
可以先看这张表:
| 能力 | volatile | Atomic |
|---|---|---|
| 可见性 | 有 | 有 |
| 一定程度的有序性 | 有 | 有 |
| 单变量简单读写 | 适合 | 适合 |
| count++ 这类复合更新 | 不保证 | 支持对应原子方法 |
| 多变量整体一致 | 不适合 | 通常也不够 |
| CAS 条件更新 | 没有 | 支持 |
| 代码复杂度 | 较低 | 中等 |
一句话区分:
volatile:我修改了,别人要尽快看到
Atomic:我想在并发下安全地更新这个变量
Lock:我需要保护一组复杂的共享状态
用 volatile 做状态,用 Atomic 做计数
这是一种常见组合:
private volatile boolean running = true;
private final AtomicLong processed = new AtomicLong();
public void run() {
while (running) {
processOne();
processed.incrementAndGet();
}
}
public void stop() {
running = false;
}
running 是简单状态通知,用 volatile。
processed 是并发计数,用 AtomicLong。
如果还要同时更新成功数、失败数和最近处理时间,并且这些字段必须保持一致,就不能只给每个字段各自加 volatile 或 Atomic。
这时通常需要锁、不可变对象整体替换,或者重新设计数据结构。
Atomic 和锁怎么选
Atomic 适合竞争逻辑简单、共享变量单一的场景。
单个计数器、序列号、状态引用、版本号
↓
Atomic 类通常很合适
锁适合多个变量需要一起修改,或者操作过程比较复杂的场景。
读取余额
检查余额
修改余额
记录流水
↓
多个步骤必须保持整体一致
↓
考虑 synchronized 或 Lock
不要把一大段复杂业务硬塞进 CAS 自旋。
CAS 失败后不断重试,竞争激烈时可能消耗大量 CPU。
高并发计数场景还可以考虑 LongAdder。
它通过分散热点降低竞争,适合统计类计数,但读取的是一个近似实时聚合值,不适合需要严格瞬时一致的业务判断。
使用时的常见误区
把 volatile 当锁
volatile 不能保证 if 判断和后续修改组成一个原子整体。
private volatile boolean available = true;
public void use() {
if (available) {
available = false;
doWork();
}
}
两个线程可能同时通过判断。
如果这个状态只能被一个线程消费,就需要 Atomic 的 CAS 或锁。
认为 Atomic 能保护多个字段
下面这种需求不能靠三个 Atomic 字段自然解决:
余额减少
订单状态变更
积分增加
如果三步必须同时成功或同时失败,就需要事务、锁或整体不可变状态设计。
每个 Atomic 字段各自安全,不代表字段之间的业务关系安全。
忽略失败结果
CAS 方法通常会返回是否成功。
如果调用 compareAndSet 后完全不处理 false,业务可能悄悄丢更新。
boolean success = version.compareAndSet(oldVersion, newVersion);
if (!success) {
// 重新读取、重试,或返回并发冲突
retryOrReject();
}
误以为无锁就是没有成本
Atomic 不需要互斥锁,不代表它没有竞争。
高竞争下 CAS 会反复失败,线程仍然会消耗 CPU。
是否更快,要结合竞争程度、临界区大小、线程数量和实际压测判断。
小结
volatile 和 Atomic 都服务于并发,但解决的问题不同:
volatile主要保证共享变量的可见性,并限制相关重排序- Atomic 通过 CAS 提供单变量的原子更新
volatile int不能让count++变成原子操作- Atomic 不能自动保证多个变量之间的一致关系
- 复杂复合业务仍然需要 synchronized、Lock、事务或整体状态设计
- 高竞争计数可以考虑 LongAdder,但要接受它的读取语义
可以按这个顺序选:
只是通知状态变化?
└── volatile
单个变量需要安全递增或 CAS?
└── Atomic
多个变量和多个步骤需要整体一致?
└── synchronized / Lock / 事务
一句话总结:volatile 解决看得见,Atomic 解决改得安全,锁解决一组操作不能被拆开。
并发工具没有谁更高级,只有当前问题到底需要哪种保证。
