首页 / Java 入门教程 / volatile 与可见性

Java 入门教程

volatile 与可见性

本教程共 100 篇 · 第 98 篇 · 更新于 2026-08-05 · 约 5 分钟阅读

JavaJava 入门教程volatile可见性Java内存模型原子类

本节目标:理解可见性问题的成因,掌握 volatile 的作用与边界,知道什么时候该换成原子类。

一个停不下来的循环

先看一段诡异的代码:

public class VisibilityProblem {
    private static boolean running = true;   // 注意:没加 volatile

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long count = 0;
            while (running) {                // 等待标志位变化
                count++;
            }
            System.out.println("循环结束,共执行 " + count + " 次");
        });

        worker.start();
        Thread.sleep(1000);

        running = false;                     // 主线程改标志位
        System.out.println("已设置 running = false");

        worker.join();
        System.out.println("程序结束");
    }
}

按常理,主线程把 running 改成 false,子线程的循环就该退出。

但实际运行(用 java -server 或者稍等一会儿)大概率是这样:

已设置 running = false
(然后程序一直卡着,永远打印不出"程序结束")

主线程明明改了,子线程为什么看不见?

Java 内存模型

答案藏在 JMM(Java 内存模型)里。

JMM 规定:所有共享变量存放在主内存中,但每个线程都有自己的工作内存

线程操作变量时,先把主内存的值拷贝一份到自己的工作内存,之后所有读写都在这个副本上进行,最后某个时刻再刷回主内存。

问题就出在这里:主线程改的是主内存(或它自己的副本),子线程读的是自己的副本。副本没更新,子线程就一直看到旧值 true

这个「一个线程的修改,另一个线程看不到」的现象,叫可见性问题

Note

工作内存不是凭空发明的概念,它对应真实硬件里的 CPU 寄存器和各级缓存。CPU 访问主存很慢,所以把数据缓存在离自己近的地方,多核之间的缓存不会自动实时同步。JMM 只是把这套硬件行为抽象成了统一的语言规范。

除了缓存,JIT 编译器的优化也会加剧问题。它看到循环里没人改 running,可能直接把代码优化成 while (true),连读都不读了。

volatile 保证可见性

给变量加上 volatile 就能解决:

private static volatile boolean running = true;

只改这一个词,重新运行:

已设置 running = false
循环结束,共执行 1873492013 次
程序结束

立刻正常了。

volatile 对 JVM 提出了两条硬性要求:

  • 写操作:修改后立即刷回主内存
  • 读操作:每次都从主内存重新读,不用工作内存的副本

同时它会阻止 JIT 做那种「把变量提到循环外」的优化。

这样一来,一个线程的修改,别的线程马上就能看到。

volatile 禁止指令重排

volatile 还有第二个作用,容易被忽略。

为了提升执行效率,编译器和 CPU 会在不改变单线程执行结果的前提下,调整指令的执行顺序。这叫指令重排序

单线程下重排完全安全,多线程下就可能出乱子。经典例子是双重检查锁定的单例:

public class Singleton {
    private static volatile Singleton instance;   // volatile 不能省

    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();   // 这一行有三步
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 实际分三步:

  1. 分配内存空间
  2. 执行构造方法初始化对象
  3. instance 指向这块内存

第 2 步和第 3 步可能被重排成 3、2。一旦这样,另一个线程在外层看到 instance != null 就直接返回了,但对象其实还没初始化完,拿到的是个半成品,用起来必然出错。

加上 volatile 后,JVM 会插入内存屏障禁止这段重排,问题消失。

volatile 不保证原子性

这是最容易踩的坑:volatile 只管可见性和有序性,不管原子性

public class VolatileNotAtomic {
    private static volatile int count = 0;   // 加了 volatile 也没用

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                count++;
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("结果:" + count);  // 仍然小于 200000
    }
}

输出仍然不足 20 万。

原因上一节讲过:count++ 是「读-改-写」三步。volatile 只能保证每一步读到的是最新值,管不了三步之间会不会被打断。

线程 A 读到最新值 100,还没写回,线程 B 也读到 100。两边都算出 101,写回两次,还是丢了一次。

Warning

一句话判断:volatile 适用于「一个线程写、多个线程读」的场景。只要写操作依赖变量的旧值(比如自增、累加、判断后修改),volatile 就不够用。

正确的计数方案

需要原子的自增,有两个选择。

方案一:synchronized。可靠,但每次都要抢锁,竞争激烈时开销大。

方案二:原子类java.util.concurrent.atomic 包提供了一批,底层用 CPU 的 CAS 指令实现,性能更好:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicDemo {
    private static final AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                count.incrementAndGet();   // 原子自增
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("结果:" + count.get());
    }
}

输出稳定:

结果:200000

常用的原子类和方法:

用途
AtomicInteger原子的 int
AtomicLong原子的 long
AtomicBoolean原子的 boolean
AtomicReference<T>原子的对象引用

AtomicInteger 的常用方法有 incrementAndGet()(先加后取)、getAndIncrement()(先取后加)、addAndGet(n)compareAndSet(期望值, 新值)

CAS 的思路是「比较并交换」:先看当前值是不是我以为的那个,是就改,不是就说明被别人抢先了,重新读一遍再试。整个过程由 CPU 指令保证原子性,不需要加锁。

Tip

高并发计数(比如统计接口调用量)用 LongAdderAtomicLong 更快。它把计数拆到多个单元上分散竞争,求和时再合并。代价是 sum() 拿到的可能不是某个精确瞬间的值,做统计完全够用。

三者怎么选

需求方案
状态标志位,一写多读volatile
计数器、累加器原子类
一段复合逻辑要整体互斥synchronizedLock

volatile 是最轻量的,没有加锁开销,但能力也最弱。原子类适合单个变量的原子操作。涉及多个变量的一致性,就只能上锁。

顺便说一句,synchronized 其实同时保证了可见性——线程释放锁时会把工作内存刷回主内存,获取锁时会清空工作内存重新读。所以用了 synchronized 保护的变量,不需要再加 volatile

本节小结

  • JMM 规定共享变量在主内存,每个线程有自己的工作内存副本。
  • 副本不同步导致可见性问题,典型症状是标志位改了循环却不退出。
  • volatile 保证写立即刷回主内存、读每次从主内存获取。
  • volatile 还能禁止指令重排,双重检查单例必须加它。
  • volatile 不保证原子性,count++ 加了也照样出错。
  • 计数场景用 AtomicInteger,底层 CAS 无锁,性能优于加锁。
  • 高并发计数可以考虑 LongAdder
  • synchronized 自带可见性保证,无需重复加 volatile