跳过导航

volatile 为什么保证可见性,却不能保证原子性?

约 6 分钟...次浏览
专栏JVM 与 Java 核心第 4 篇

volatile 经常被概括为“让变量在线程间可见”,但这句话容易让人误以为:只要变量加了 volatile,所有并发操作就安全了。真正的边界是:volatile 读写本身具备可见性和特定有序性,但由多步组成的业务操作不会因此自动成为一个不可分割的整体。

1. 可见性问题从哪里来

编译器和 CPU 为提高性能,会重排不影响单线程结果的指令;CPU 还有多级缓存和写缓冲区。没有同步关系时,一个线程写入普通字段,另一个线程何时观察到新值并无保证。

class Worker {
    boolean stopped;

    void run() {
        while (!stopped) {
            // work
        }
    }
}

JIT 甚至可能认为循环内没有修改 stopped 的路径,从而减少重复读取。把字段声明为 volatile boolean stopped 后,每次 volatile 读都必须遵守内存模型的同步语义。

2. happens-before 才是核心契约

Java 内存模型规定:对某个 volatile 变量的写,happens-before 后续任意线程对同一变量的读。happens-before 不只是时间先后,它意味着写线程在 volatile 写之前的操作结果,对成功观察到该写的读线程可见。

class ConfigHolder {
    int timeout;
    String endpoint;
    volatile boolean ready;

    void load() {
        timeout = 3000;
        endpoint = "https://example.com";
        ready = true;
    }

    void use() {
        if (ready) {
            // 能观察到 ready=true 时,也应看到此前的配置写入
            send(endpoint, timeout);
        }
    }
}

ready 在这里承担“发布屏障”。注意读线程必须实际读取这个 volatile 字段并观察到相应状态,不能完全绕开它。

3. 为什么 count++ 仍然不安全

volatile int count;

void increment() {
    count++;
}

count++ 至少包含三步:读取 count、加一、写回。两个线程可能都读到 10,分别算出 11,再都写回 11,最终丢失一次更新。volatile 保证每次读写不会长期使用陈旧值,却不能把“读—计算—写”锁成一个整体。

解决方式根据语义选择:

AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

或在需要维护多个变量不变式时使用锁:

synchronized void transfer() {
    if (balance >= amount) {
        balance -= amount;
        target += amount;
    }
}

原子类适合单变量 CAS 更新;锁更适合跨字段、带条件判断的复合状态变更。

补充一个常见旧知识点:自 Java 5 起,普通 longdouble 的单次读写也要求原子,不需要为了防止“撕裂读取”机械地加 volatile。但普通读写仍不建立跨线程可见性与有序性;需要发布关系时依然要使用 volatile、锁或其他同步机制。

4. 内存屏障与硬件实现

从实现角度,JIT 会在 volatile 访问周围放置必要的内存屏障,限制特定方向的重排,并使用目标 CPU 提供的缓存一致性与指令能力。不同架构强弱不同,生成的机器指令也不同。

因此不要把 volatile 简化成“每次都从物理内存读取”。现代 CPU 并不是这样工作的。Java 只承诺内存模型效果,具体通过缓存一致性协议、屏障、带锁指令等共同完成。

可借助 JITWatch、JMH 的 perfasm 或诊断版 JVM 查看生成代码,但结论必须区分 Java 语义与某款 CPU 的实现细节。

5. volatile 与禁止重排

经典双重检查单例必须给实例引用加 volatile:

class Singleton {
    private static volatile Singleton instance;

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

没有正确发布时,引用写入可能被其他线程观察到,而对象初始化产生的字段写入尚未被正确观察。volatile 写与读建立发布关系,避免读线程拿到未安全发布的对象。

实际项目通常更推荐枚举单例、静态字段或初始化按需持有者,代码更简单,正确性由类初始化机制保证。

6. volatile 适合什么场景

适合:

  • 停止标志、开关和最新配置快照;
  • 单写多读,且新值不依赖旧值的状态;
  • 安全发布不可变对象;
  • 配合锁实现快速路径检查。

不适合:

  • 计数器的 ++
  • “余额不能为负”等跨步骤不变式;
  • 多字段必须同时变化;
  • 需要等待条件、排队公平性或临界区互斥。

若在实现高性能基础设施并确实需要比 volatile 更精细的语义,可评估 VarHandle 的 opaque、acquire/release 和 volatile 访问模式。它们不是普通业务代码的“更快替代品”:内存序越弱,正确性证明越难,必须用 jcstress 等并发测试验证。

7. 不可变快照是一种好搭档

record Config(String endpoint, int timeout, Set<String> features) {
    Config {
        features = Set.copyOf(features);
    }
}

private volatile Config config = load();

void refresh() {
    config = load(); // 一次替换完整快照
}

读取者只读一个稳定快照,更新者构造新对象后一次替换引用。前提是对象内部也真正不可变,不能把可变集合直接暴露出去。

8. 检查清单

  • 操作是否依赖变量旧值?若是,单用 volatile 通常不够。
  • 是否需要维护多个字段之间的一致性?考虑锁或不可变快照。
  • 读写双方是否通过同一个 volatile 变量建立发布关系?
  • 是否错误地把 volatile 当作物理内存直读?
  • 能否用 Atomic 类、并发容器或更简单的消息传递替代共享可变状态?

volatile 的价值不是“轻量锁”,而是以较低成本建立清晰的可见性与有序性边界。它解决发布问题,不自动解决复合操作的互斥问题。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。