volatile到底有什么用?可见性、有序性与使用场景


从一次"明明改了标志位,线程却停不下来"的诡异bug出发,深度剖析volatile关键字的三大核心作用:保证可见性、禁止指令重排序、以及单次读写的原子性。通过CPU缓存一致性协议的底层原理、双重检查锁(DCL)单例模式的经典案例、以及为什么volatile无法保证i++的原子性,揭秘volatile的使用场景与边界。配合内存屏障的图解和真实代码示例,给出volatile的最佳实践与常见误区。


一、一个停不下来的线程

周五下午,哈吉米跑过来:"哥,后台任务停不下来,点停止按钮没反应,你看看?"

我看了眼代码,逻辑很简单:

public class TaskRunner {
    private boolean running = true; // 标志位
    
    public void start() {
        new Thread(() -> {
            while (running) { // 根据标志位循环
                // 处理任务
                System.out.println("任务执行中...");
            }
            System.out.println("任务已停止");
        }).start();
    }
    
    public void stop() {
        running = false; // 改标志位
        System.out.println("已设置停止标志");
    }
}

哈吉米演示了一遍:

  1. 调用start(),任务开始执行
  2. 调用stop(),控制台输出"已设置停止标志"
  3. 但任务还在跑,"任务执行中..."一直打印
  4. 完全停不下来

我当时懵了:明明已经把running改成false了,为什么while还在循环?

南北绿豆路过,插了一句:"你确定主线程改了,工作线程能看到吗?"

一语惊醒梦中人。调试发现,主线程里running确实是false,但工作线程里running还是true,两个线程看到的值不一样

我加了一行volatile

private volatile boolean running = true; // 加上volatile

哈吉米再测,秒停止。"卧槽,这么神奇?"

这就是可见性问题。


二、可见性:CPU缓存导致的"幻觉"

为什么会看到不同的值?

阿西噶阿西凑过来看热闹:"这是CPU缓存的锅。"

现代CPU为了提高性能,每个核心都有自己的缓存:

CPU核心1 → L1缓存 → L2缓存 ↘
                              L3缓存(共享) → 主内存
CPU核心2 → L1缓存 → L2缓存 ↗

问题来了

  1. 主线程(CPU核心1)把running改成false,先写到L1缓存
  2. 工作线程(CPU核心2)从自己的L1缓存读running,还是true
  3. 两个线程各看各的缓存,值不同步

南北绿豆画了个图:

sequenceDiagram
    participant M as 主线程(核心1)
    participant C1 as L1缓存1
    participant MM as 主内存
    participant C2 as L1缓存2
    participant W as 工作线程(核心2)
    
    Note over M,C1: 初始:running=true
    M->>C1: 1.写入running=false
    Note over C1: running=false (缓存)
    
    Note over W,C2: 同时在执行
    W->>C2: 2.读取running
    C2->>W: 3.返回true (旧值)
    
    Note over W: 💀 while(running)继续循环
    Note over M,W: 两个线程看到的值不一样

哈吉米:"所以缓存没同步,就出问题了?"

对,就是这样。

volatile怎么解决的?

加上volatile后,JVM会插入内存屏障(Memory Barrier),强制做两件事:

  1. 写操作:写完立即刷新到主内存,并通知其他CPU缓存失效
  2. 读操作:每次都从主内存读取最新值,不用缓存
sequenceDiagram
    participant M as 主线程
    participant MM as 主内存
    participant W as 工作线程
    
    M->>MM: 1.写入running=false(立即刷新到主内存)
    MM->>W: 2.通知缓存失效
    W->>MM: 3.从主内存读取
    MM->>W: 4.返回false(最新值)
    
    Note over W: ✅ while(running)停止循环

阿西噶阿西补充:"底层是CPU的lock指令加MESI缓存一致性协议,保证所有CPU看到的都是最新值。"

哈吉米:"听不懂,但很牛逼。"


三、有序性:指令重排序的坑

什么是指令重排序?

午饭时,南北绿豆问:"你们知道单例模式的双重检查锁吗?不加volatile会死人的。"

哈吉米:"知道啊,synchronized加两层检查,防止多次创建。"

南北绿豆:"那为什么instance要加volatile?"

哈吉米:"呃...不知道。"

南北绿豆开始讲课:"因为指令重排序。"

为了优化性能,编译器和CPU会重排指令:

// 原始代码
int a = 1;  // 语句1
int b = 2;  // 语句2
int c = a + b; // 语句3

// CPU可能重排成
int b = 2;  // 语句2先执行
int a = 1;  // 语句1后执行
int c = a + b; // 语句3

单线程没问题(结果不变),但多线程就炸了

经典案例:双重检查锁(DCL)

单例模式的DCL写法,不加volatile会出问题:

public class Singleton {
    private static Singleton instance; // 没有volatile,有问题
    
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 问题在这
                }
            }
        }
        return instance;
    }
}

哈吉米:"看起来没问题啊。"

南北绿豆:"问题大了。"

问题出在哪?

new Singleton()在JVM里分三步:

// JVM指令(简化版)
memory = allocate();   // 1.分配内存空间
ctorInstance(memory);  // 2.初始化对象
instance = memory;     // 3.instance指向内存地址

CPU可能重排成

memory = allocate();   // 1.分配内存
instance = memory;     // 3.先赋值(对象还没初始化!)
ctorInstance(memory);  // 2.后初始化

阿西噶阿西补充:"并发场景下的灾难:"

sequenceDiagram
    participant T1 as 线程1
    participant T2 as 线程2
    participant I as instance
    
    T1->>I: 1.分配内存
    T1->>I: 2.instance=memory(未初始化)
    
    Note over T2: 线程2进来了
    T2->>I: 3.检查instance!=null
    T2->>T2: 4.直接返回instance
    Note over T2: 💀 拿到半初始化的对象,空指针或脏数据
    
    T1->>I: 5.初始化对象(太晚了)

哈吉米:"卧槽,线程2拿到的是半成品?"

对,可能字段都是null,用起来就爆炸。

加volatile修复

private static volatile Singleton instance; // 加volatile

南北绿豆:"volatile禁止指令重排序,保证顺序:1.分配内存 → 2.初始化对象 → 3.赋值给instance,线程2拿到的一定是完整对象。"

哈吉米:"懂了懂了。"


四、volatile不保证原子性

下午,哈吉米又来了:"哥,我照着你说的加了volatile,怎么计数器还是不准?"

看代码:

public class Counter {
    private volatile int count = 0; // 加了volatile
    
    public void increment() {
        count++; // 💀 不是原子操作
    }
}

// 10个线程,每个+1000次
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) {
            counter.increment();
        }
    }).start();
}

// 预期:10000
// 实际:9856(每次都不一样,总是小于10000)

哈吉米:"不是说volatile能保证可见性吗?怎么还丢数据?"

阿西噶阿西:"volatile不保证原子性啊,老弟。"

为什么volatile救不了i++?

count++在JVM里分三步:

// 伪代码
int temp = count;  // 1.读取
temp = temp + 1;   // 2.+1
count = temp;      // 3.写回

南北绿豆:"volatile只保证每一步的可见性,但不保证三步合在一起是原子的:"

时间轴:
T1读取count=0
T2读取count=0 (T1还没写回,T2也读到0)
T1计算0+1=1
T2计算0+1=1
T1写回count=1
T2写回count=1 (覆盖了T1的结果)

预期:count=2
实际:count=1 (丢了一次+1)

哈吉米:"那怎么办?"

正确做法

// 方法1:AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet(); // CAS原子操作
}

// 方法2:synchronized
private int count = 0;
public synchronized void increment() {
    count++;
}

// 方法3:LongAdder(高并发推荐)
private LongAdder count = new LongAdder();
public void increment() {
    count.increment();
}

阿西噶阿西:"推荐AtomicInteger或LongAdder,性能比synchronized好。"

哈吉米改完代码,测试通过:"完美!"


五、volatile的使用场景

晚上复盘,南北绿豆总结:

✅ 适合用volatile的场景

1. 状态标志

private volatile boolean shutdown = false;

// 线程1
while (!shutdown) {
    // 处理任务
}

// 线程2
public void stop() {
    shutdown = true; // 立即可见
}

南北绿豆:"就像今天早上那个bug,标志位最适合。"

2. 双重检查锁(DCL)

private volatile static Singleton instance;

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton(); // 禁止重排序
            }
        }
    }
    return instance;
}

3. 独立观察

private volatile long lastUpdateTime;

// 线程1:更新时间
public void updateTime() {
    lastUpdateTime = System.currentTimeMillis();
}

// 线程2:读取时间
public long getLastUpdateTime() {
    return lastUpdateTime; // 总是读到最新值
}

❌ 不适合用volatile的场景

1. 复合操作

// ❌ 错误
private volatile int count;
public void increment() {
    count++; // 不是原子操作
}

// ✅ 正确
private AtomicInteger count;
public void increment() {
    count.incrementAndGet();
}

哈吉米:"这个我今天踩过了。"

2. 依赖旧值的更新

// ❌ 错误
private volatile int value;
public void update() {
    value = value * 2 + 1; // 依赖旧值,不安全
}

// ✅ 正确
private int value;
public synchronized void update() {
    value = value * 2 + 1;
}

3. 多个变量之间的不变式

// ❌ 错误
private volatile int lower = 0;
private volatile int upper = 10;

public void setRange(int lower, int upper) {
    this.lower = lower; // 两个赋值不是原子的
    this.upper = upper; // 中间状态可能lower>upper
}

// ✅ 正确
private int lower = 0;
private int upper = 10;

public synchronized void setRange(int lower, int upper) {
    this.lower = lower;
    this.upper = upper;
}

六、总结与对比

阿西噶阿西最后总结:

volatile三大作用

作用说明底层实现
可见性一个线程修改,其他线程立即看到内存屏障+缓存一致性协议
有序性禁止指令重排序内存屏障
单次读写原子性一次读/写操作是原子的CPU保证(64位JVM)

volatile vs synchronized

对比项volatilesynchronized
原子性❌ 不保证✅ 保证
可见性✅ 保证✅ 保证
有序性✅ 保证✅ 保证
性能🚀 高(无锁)🐢 低(加锁)
使用场景状态标志、DCL复合操作、临界区

南北绿豆:"记住一句话:volatile适合'一写多读'的简单场景,synchronized适合'多写'的复合操作。"

哈吉米:"如果不确定用哪个呢?"

阿西噶阿西:"就用synchronized,安全第一。"


七、常见面试题

第二天早会,南北绿豆突然袭击:

南北绿豆:"哈吉米,volatile能保证原子性吗?"
哈吉米:"不能。只保证单次读/写的原子性,不保证复合操作(如i++)的原子性。昨天刚踩过坑。"

南北绿豆:"阿西噶阿西,volatile和synchronized的区别?"
阿西噶阿西:"volatile轻量级,不阻塞线程,但只保证可见性和有序性;synchronized重量级,会阻塞,但保证原子性+可见性+有序性。"

南北绿豆:"为什么DCL要加volatile?"
哈吉米:"防止指令重排序,避免拿到半初始化的对象。"

南北绿豆:"volatile的底层实现?"
阿西噶阿西:"内存屏障+MESI缓存一致性协议,写时刷新主内存并通知其他缓存失效,读时从主内存读最新值。"

南北绿豆:"可以,都学会了。散会!"


参考资料

  • 《Java并发编程的艺术》- 方腾飞
  • 《深入理解Java虚拟机》- 周志明
  • Doug Lea - JSR 133 (Java Memory Model)