本文从 CPU 总线、高速缓存、指令队列三个层面,讲解 volatile 的底层机制、它能解决什么问题、以及为什么它无法保证线程安全。


一、问题复现:不加任何修饰符的并发覆盖

public class Counter {
    private int count = 0; // 普通变量

    public void increment() {
        count++; // 非原子操作,分三步:读 → 加1 → 写
    }

    public int getCount() { return count; }
}
// 两个线程各自对 count 加 10000 次
Counter counter = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) counter.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();

System.out.println(counter.getCount()); // 期望 20000,实际结果不确定

实际运行结果:每次运行都不同。

原因:两个核心各自缓存了 count 的副本,写回内存的时机不同步,导致相互覆盖。


二、volatile到底做了什么?

count 加上 volatile 修饰:

private volatile int count = 0;

2.1 volatile 的两个承诺

承诺一:写回内存(保证可见性)

当一个线程修改了 volatile 变量,修改结果会立即写回主内存,而不是等到缓存满或操作系统调度时才写回。

承诺二:使其他缓存失效(MESI 协议)

写回内存时,通过总线发出信号(嗅探机制),使其他核心缓存中对应该地址的数据标记为"无效"。其他核心下次读取该变量时,发现缓存已失效,会重新从内存加载最新值。

线程1(核心1)修改 volatile count:
  1. 计算结果写回主内存(count = 10)
  2. 通过总线广播:地址 0x...的缓存无效

线程2(核心2)再次读取 count:
  1. 发现本地缓存已失效
  2. 从主内存重新加载(读到最新值 10)

这就是 volatile 保证可见性的底层原理。

2.2 底层实现:Lock 前缀指令

在汇编层面,volatile 写操作会生成一条 lock 前缀指令

; 普通写操作
mov [count], eax

; volatile 写操作(多了一个 lock 前缀)
lock mov [count], eax

根据 x86 架构开发者手册,lock 前缀指令在多核处理器下触发两件事:

  1. 将当前处理器缓存行的数据写回内存
  2. 使其他处理器缓存了该内存地址的数据失效

三、内存屏障(Memory Barrier)

volatile 还涉及另一个重要概念:内存屏障(Memory Barrier / Memory Fence)

内存屏障是一种约定机制,本质上是内存中的一个标志位

  • 标志为 0:允许访问
  • 标志为 1:禁止访问(等待屏障解除)

它解决的是指令重排序问题:编译器和 CPU 可能为了优化性能,将指令的执行顺序打乱。在多线程场景下,这种重排序可能导致逻辑错误。

volatile 通过插入内存屏障,禁止屏障前后的指令被重排序:

[普通指令A]
[普通指令B]
----- StoreStore屏障(volatile写之前)-----
[volatile 写操作]
----- StoreLoad屏障(volatile写之后)------
[普通指令C]

这保证了 volatile 写操作之前的所有操作都已完成,之后的操作都在写之后执行。


四、volatile 的致命缺陷:无法保证原子性

这是 volatile 最容易被误用的地方

4.1 为什么 volatile 不能保证 count++ 的正确性?

先看 volatile 按预期工作的场景:

线程1 修改 count(缓存中计算完毕)→ 写回内存 → 使其他缓存失效
线程2 读 count → 发现缓存失效 → 从内存读最新值 → 继续计算

看起来没问题——但忽略了一个关键环节:指令队列(Store Buffer)

4.2 指令队列的存在

CPU 核心与总线之间存在一个指令缓冲队列(Store Buffer)。当核心计算完毕要写回内存时,写指令并不是立刻通过总线,而是先进入这个队列排队等待传输(因为总线同一时刻只能传一个电压信号)。

核心1 计算结果: count=10
  → 写指令进入队列排队:[写 count=10]
  → 时间片到期,核心1 挂起

核心2 拿到时间片,也计算完:count=20
  → 写指令进入队列排队:[写 count=10, 写 count=20]

队列依次传输:
  写 count=10 → 内存中 count=10,其他缓存失效
  写 count=20 → 内存中 count=20(覆盖了 10)

问题在于:volatile 只能使缓存中的数据失效,无法撤销已经在指令队列中排队的写操作。

排队中的写指令已经脱离了缓存,不受失效机制的影响,最终仍然会执行并覆盖。

4.3 用代码验证

public class VolatileTest {
    private volatile int count = 0;

    public void increment() {
        count++; // 即使是 volatile,count++ 仍然不是原子的
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest test = new VolatileTest();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) test.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) test.increment(); });
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println(test.count); // 仍然不是 20000!
    }
}

结论:volatile 保证可见性,但不保证原子性,因此不能用 volatile 替代 synchronized 来保证线程安全。


五、volatile 适合什么场景?

既然 volatile 不能保证原子性,它还有什么用?

场景一:状态标志位(最典型用法)

public class Worker implements Runnable {
    private volatile boolean running = true; // 用 volatile 修饰状态标志

    @Override
    public void run() {
        while (running) {
            // 执行任务...
        }
        System.out.println("线程安全退出");
    }

    public void stop() {
        running = false; // 其他线程修改后,Worker 线程立即可见
    }
}

这里只有写一次、读多次,不存在"读-改-写"的复合操作,volatile 完全够用。

场景二:双重检查锁(DCL)单例模式

public class Singleton {
    // 必须用 volatile,防止指令重排序导致返回未初始化的对象
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查(不加锁)
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查(加锁内)
                    instance = new Singleton();    // 若无 volatile,可能返回半初始化对象
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 在底层分三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存地址

没有 volatile,步骤 2 和 3 可能被重排序(先指向地址,再初始化),其他线程可能拿到一个未完成初始化的对象。

场景三:一写多读

// 配置类:主线程写一次,多个工作线程读
public class Config {
    public volatile String serverUrl;
    public volatile int timeout;
}

六、真正的线程安全:该用什么?

需求推荐方案
简单状态标志(一写多读)volatile
单个变量的原子自增/自减AtomicIntegerAtomicLong
复合操作(读-改-写)synchronizedLock
高并发计数器LongAdder(比 AtomicLong 更高效)
复杂的并发数据结构ConcurrentHashMapCopyOnWriteArrayList
// 用 AtomicInteger 解决 count++ 的线程安全问题
import java.util.concurrent.atomic.AtomicInteger;

public class SafeCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // 底层使用 CAS 原子指令,真正的原子操作
    }

    public int getCount() { return count.get(); }
}

七、其他问题

Q1:volatile 的作用是什么?

两个作用:① 保证可见性:修改后立即写回主内存,并使其他核心缓存失效;② 禁止指令重排序:通过内存屏障,防止编译器和 CPU 对 volatile 操作前后的指令重排序。

Q2:volatile 能保证线程安全吗?

不能。volatile 只保证可见性和有序性,不保证原子性。对于复合操作(如 i++),volatile 无法防止指令队列中的写操作相互覆盖,仍然会出现线程安全问题。

Q3:volatile 和 synchronized 的区别?

对比项volatilesynchronized
保证可见性
保证有序性
保证原子性
性能开销较小较大(涉及锁的获取/释放)
适用场景简单状态标志复合操作、临界区

Q4:DCL 单例中为什么必须用 volatile?

new Singleton() 在底层不是原子操作,可能发生指令重排序(先将引用指向内存地址,再完成对象初始化)。没有 volatile,其他线程可能通过第一次检查,拿到一个引用不为 null 但对象尚未初始化完成的半成品实例。volatile 的内存屏障禁止了这种重排序。

Q5:synchronized 和 Lock 各适合什么场景?

synchronized 语法简单,JVM 自动释放锁,适合简单同步场景;Lock(如 ReentrantLock)功能更丰富,支持公平锁、可中断锁、tryLock 超时等,适合复杂并发控制场景。


八、并发编程阶段总结

电压信号(物理层)
    ↓ 决定了
CPU 同一时刻只能执行一条指令
    ↓ 引出
操作系统任务调度 + 线程状态机
    ↓ 上下文切换的开销
多核 CPU 需要高速缓存
    ↓ 缓存带来了
可见性问题(多核各持副本,写回时机不确定)
    ↓ Java 的解决方案
volatile(保证可见性 + 禁止重排序,但不保证原子性)
synchronized / Lock / Atomic 类(保证原子性)
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐