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 包中。

常见类型包括:

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference
  • AtomicIntegerArray
  • LongAdder

它们通常基于 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 解决改得安全,锁解决一组操作不能被拆开。

并发工具没有谁更高级,只有当前问题到底需要哪种保证。

更多推荐

章节目录