Skip to content
Java 并发与锁机制 · 第 2 篇 / 共 9 篇
领域编程与框架
专题Java 专题
当前序列Java 并发与锁机制
阅读位置第 2 篇 / 共 9 篇当前专题第 3 个序列 / 共 10 个序列

volatile 的作用与边界:可见性、有序性,但不保证复合操作原子性

volatile 是 Java 并发里非常常见、也非常容易被误用的关键字。很多人记住了它“线程可见”,但不一定真的理解它在什么场景下有用,什么场景下完全不够。

先说结论

volatile 主要解决两个问题:

  • 保证变量修改对其他线程可见
  • 在一定范围内禁止特定的指令重排序

但它不能单独解决这类问题:

  • i++
  • count = count + 1
  • 先判断再修改的复合逻辑

因为这些问题本质上需要的是更强的原子性保证,而不是只有可见性。

什么是内存可见性

理解 volatile,离不开 Java 内存模型,也就是 JMM

可以先用一个更容易理解的方式来看:

  • 共享变量最终存放在主内存中
  • 每个线程执行时,会把自己要用到的数据读到工作内存中
  • 线程对变量的读写,很多时候发生在自己的工作内存副本上

这就带来一个问题:

一个线程已经修改了共享变量,但另一个线程读到的仍然是旧值。

这时我们就说:这个变量在多个线程之间“不可见”。

volatile 的作用,就是让这个变量在多个线程之间的修改更快地对彼此可见。

并发里常说的三个关键词

讨论 volatile 时,经常会同时提到三个概念:

1. 原子性

一个操作要么全部执行成功,要么完全不执行,不会只做一半。

例如:

  • 读一个 int
  • 给一个 int 赋值

这种基础读写通常可以看作原子操作。

但像下面这种就不是:

java
count++;

因为它实际至少包含:

  1. 读取 count
  2. 计算 count + 1
  3. 把新值写回去

多线程同时执行时,很容易丢失更新。

2. 可见性

一个线程修改了共享变量后,其他线程能够及时看到这个新值。

volatile 可以解决这个问题。

3. 有序性

为了优化性能,编译器和处理器可能会对指令进行重排序。

Java 内存模型允许一定范围内的重排序,但前提是:

  • 不能改变单线程语义
  • 不能破坏 JMM 规定的 happens-before 关系

volatile 可以在一定程度上阻止与它相关的危险重排序。

volatile 能做什么

保证可见性

当一个变量被声明为 volatile 后:

  • 线程写入这个变量时,会把新值刷新到主内存
  • 其他线程读取这个变量时,会优先从主内存中读最新值

一个典型场景是“停止标记”:

java
public class TaskRunner {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void run() {
        while (running) {
            // do something
        }
    }
}

这里如果 running 不加 volatile,线程可能一直读到自己工作内存里的旧值,从而无法及时退出循环。

保证一定的有序性

volatile 还常见于“双重检查锁”的场景:

java
public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里 volatile 的核心意义之一,就是避免对象初始化过程中的重排序问题。

volatile 不能做什么

不能保证复合操作的原子性

下面这种写法仍然是有问题的:

java
private volatile int count = 0;

public void increment() {
    count++;
}

即使 countvolatile,也不能避免并发下的丢失更新。

如果要解决这种问题,通常应该考虑:

  • synchronized
  • ReentrantLock
  • AtomicInteger
  • LongAdder

实际开发里怎么判断该不该用 volatile

可以用一个很朴素的判断标准:

适合用 volatile 的场景

  • 一个线程写,多个线程读
  • 变量本身代表状态位、开关、标记
  • 不依赖当前值参与复合计算
  • 需要一定的可见性和有序性保证

不适合只靠 volatile 的场景

  • 计数器累加
  • 复合读写逻辑
  • 先判断再更新
  • 需要严格线程安全的共享对象状态修改

一句话总结

volatile 很有用,但它不是轻量版锁。

更准确地说:

  • 它擅长解决“看不见”和“顺序被打乱”的问题
  • 它不擅长解决“多个线程一起改同一份数据”的问题

如果你面对的是共享状态的复合修改,第一反应不应该是 volatile,而应该先想:这里是不是需要真正的同步机制。

延伸阅读相关文章优先当前专题,再补跨专题关联。
同一序列 · 顺着当前主线继续读锁与 CAS 怎么选适合沿着 JUC、volatile、CAS、AQS 和锁实现这条主线往下看。Java 专题 · Java 并发与锁机制同一序列 · 顺着当前主线继续读AQS 到底解决了什么适合沿着 JUC、volatile、CAS、AQS 和锁实现这条主线往下看。Java 专题 · Java 并发与锁机制同专题其他序列 · 共享标签:并发线程池参数怎么配适合把线程池参数、拒绝策略、CompletableFuture 和 ForkJoin 放在一起连续看。Java 专题 · Java 线程池与异步编排同专题其他序列 · 共享标签:并发CAS 的 ABA 问题怎么理解适合把 synchronized、CAS、LongAdder 和并发容器的适用边界放到一起看。Java 专题 · Java 锁实现与并发细节跨专题关联 · 同场景:基础学习autocommit 为什么也会影响锁行为适合把 next-key lock、一致性读、复制延迟和分库分表边界放回同一条一致性主线上判断。MySQL 专题 · MySQL 事务、锁与高可用边界跨专题关联 · 同场景:基础学习@Conditional 系列注解怎么配合使用适合把自动装配、条件装配、Profile 和循环依赖放回 Spring Boot 启动过程里理解。Spring 专题 · Spring Boot 启动、装配与配置
继续阅读Java 并发与锁机制当前序列第 2 篇 / 共 9 篇当前专题第 3 个序列 / 共 10 个序列
往前看
上一篇JUC 总览回到当前序列上一章上一序列Java 集合与容器从第 1 篇开始:Java 集合怎么选
往后看
下一篇锁与 CAS 怎么选继续当前序列下一章下一序列Java 线程池与异步编排从第 1 篇开始:线程池参数怎么配

把零散经验整理成可查、可复用、可持续更新的企业级知识门户