Skip to content

可见性

一个线程修改了共享变量,另一个线程却「看不到」。

这听起来违反直觉,但这就是多核 CPU 环境下的常见问题——可见性

问题的根源:CPU 缓存

为什么会出现可见性问题?因为 CPU 有缓存:

┌─────────────┐      ┌─────────────┐
│   CPU 1     │      │   CPU 2    │
│  ┌───────┐  │      │  ┌───────┐  │
│  │ L1/L2 │  │      │  │ L1/L2 │  │
│  │ Cache │  │      │  │ Cache │  │
│  └───────┘  │      │  └───────┘  │
└──────│──────┘      └──────│──────┘
       │                    │
       └────────┬───────────┘

           ┌────┴────┐
           │  主内存   │
           └─────────┘

每个 CPU 核心有自己的缓存。线程 A 在 CPU 1 上运行,写入变量 x=1 到缓存;线程 B 在 CPU 2 上运行,读取到的 x 还是 0(因为缓存还没刷新到主内存)。

可见性问题演示

java
public class VisibilityProblemDemo {

    private static boolean flag = false;
    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(() -> {
            int count = 0;
            while (!flag) {
                // 编译器优化:认为 flag 不会变,直接在内联后无限循环
                count++;
                if (count > 1_000_000) {
                    // 加一点内存屏障操作,防止完全死循环
                    Thread.yield();
                    count = 0;
                }
            }
            System.out.println("读取到 number: " + number);
        }, "Reader");

        Thread writer = new Thread(() -> {
            number = 42;
            flag = true;  // 写操作
            System.out.println("写入完成");
        }, "Writer");

        writer.start();
        reader.start();

        reader.join(2000);
        if (reader.isAlive()) {
            System.out.println("⚠️ Reader 可能陷入无限循环(编译器优化了可见性)");
            reader.interrupt();
        }
    }
}

这个问题有两个层面:

  1. JMM 层面flag 的修改对 reader 线程可能不可见
  2. 编译器优化层面:JIT 编译器可能将 while(!flag) 优化为 while(true)(因为它认为 flag 不会变)

volatile:保证可见性

volatile 关键字告诉编译器:这个变量可能被其他线程修改,不要优化,具体保证两点:

  1. 写之后立即刷新到主内存:不留在 CPU 缓存
  2. 读之前立即从主内存读取:不用 CPU 缓存
java
public class VolatileVisibilityDemo {

    // volatile 保证可见性
    private static volatile boolean flag = false;
    private static volatile int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(() -> {
            while (!flag) {
                Thread.yield();
            }
            // 一定能读取到 number=42
            System.out.println("读取到 number: " + number);
        }, "Reader");

        Thread writer = new Thread(() -> {
            number = 42;
            flag = true;  // 写入后立即刷新到主内存
            System.out.println("写入完成");
        }, "Writer");

        writer.start();
        reader.start();
        reader.join();
    }
}

volatile 的 happens-before 语义

volatile 的读写之间有 happens-before 关系:

java
public class HappensBeforeDemo {

    private int value = 0;
    private volatile boolean ready = false;

    // 线程 A(写)
    public void writer() {
        value = 42;       // A1
        ready = true;     // A2 (volatile 写)
    }

    // 线程 B(读)
    public void reader() {
        if (ready) {       // B1 (volatile 读)
            int x = value; // B2
            // B2 一定能看到 A1 的结果
        }
    }
}

happens-before 语义保证:

  • A2 happens-before B1(volatile 规则)
  • A1 happens-before A2(程序顺序规则)
  • B1 happens-before B2(程序顺序规则)
  • 传递性:A1 happens-before B2 → x 一定等于 42

synchronized 同样保证可见性

进入 synchronized 会强制刷新 CPU 缓存,退出时会强制将修改写回主内存:

java
public class SynchronizedVisibilityDemo {

    private static int number = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            synchronized (lock) {
                number = 42;  // 退出 synchronized 时写回主内存
            }
        }, "Writer");

        Thread reader = new Thread(() -> {
            synchronized (lock) {
                // 进入 synchronized 时从主内存读取
                System.out.println("读取: " + number);  // 一定是 42
            }
        }, "Reader");

        writer.start();
        reader.start();
    }
}

经典问题:单例模式中的 volatile

双重检查锁定(Double-Checked Locking)是可见性和有序性的综合考察:

java
public class Singleton {

    // volatile 两个作用:
    // 1. 防止指令重排序(new Singleton() 的三步可能乱序)
    // 2. 保证实例的写入对其他线程可见
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查
            synchronized (Singleton.class) {        // 同步
                if (instance == null) {            // 第二次检查
                    instance = new Singleton();   // 创建实例
                    // 如果没有 volatile,以下顺序可能被重排:
                    // 1. 分配内存
                    // 2. 调用构造函数
                    // 3. 将引用赋值给 instance
                    // 重排后:1 -> 3 -> 2,导致其他线程看到未初始化的对象
                }
            }
        }
        return instance;
    }
}

可见性 vs 原子性

维度volatilesynchronized
可见性✅ 保证✅ 保证
原子性❌ 不保证✅ 保证
性能

一个常见的误解:以为 volatile 可以替代 synchronized

java
// ❌ 错误用法
private volatile int counter = 0;

public void increment() {
    counter++;  // counter++ 不是原子操作!volatile 不保证原子性
}

// ✅ 正确用法
private final AtomicInteger counter = new AtomicInteger(0);

public void increment() {
    counter.incrementAndGet();  // 原子操作
}

选择决策树

是否需要保证原子性?
  ├── 否 → 是否需要可见性?
  │         ├── 是 → volatile
  │         └── 否 → 不需要同步
  └── 是 → 是否是单个变量的原子操作?
            ├── 是 → Atomic* 类
            └── 否 → synchronized / ReentrantLock

注意事项

  1. volatile 不保证原子性i++ 这类复合操作不行
  2. synchronized 同时保证可见性和原子性
  3. Atomic 类同时保证可见性和原子性*(通过 CAS)
  4. long/double 的 volatile:JDK 8 之前,64 位变量的读写可能非原子,现在已修复
  5. 编译器优化:JIT 可能优化掉看似无用的读取,volatile 阻止这种优化

要点回顾

  • 可见性问题的根源是 CPU 缓存
  • volatile 保证:写操作立即刷新到主内存,读操作立即从主内存读取
  • volatile 的读写之间有 happens-before 关系
  • volatile 不保证原子性,复合操作仍需 synchronized 或原子类
  • 单例模式中 volatile 同时防止重排序和可见性问题

相关链接

基于 VitePress 构建