杰瑞科技汇

volatile关键字在Java中如何保证可见性?

这是一个非常重要且基础的并发编程概念,我会从它的作用、原理、使用场景、与 synchronized 的区别以及局限性等多个方面进行阐述。

volatile关键字在Java中如何保证可见性?-图1
(图片来源网络,侵删)

volatile 是什么?

volatile 是 Java 语言提供的一种轻量级的同步机制,它是一个修饰符,用来修饰被多个线程共享的变量。

当一个变量被声明为 volatile 后,它将具备两种特性:

  1. 保证可见性
  2. 禁止指令重排序

volatile 的核心作用

1 保证可见性

这是 volatile 最核心、最重要的作用。

问题背景:CPU 缓存与主内存

volatile关键字在Java中如何保证可见性?-图2
(图片来源网络,侵删)

在多核 CPU 的计算机中,每个 CPU 核心都有自己的高速缓存,而主内存是所有 CPU 共享的,为了提高性能,CPU 在操作一个变量时,会先把该变量从主内存加载到自己的高速缓存(CPU Cache / 工作内存)中,然后进行操作,操作完成后,再在某个时间点写回主内存。

这就带来了一个问题:线程间的可见性问题

假设一个线程修改了一个共享变量的值,但这个修改只保存在它自己的 CPU 缓存中,没有立即写回主内存,其他线程就无法看到这个最新的值,它们读取的仍然是自己缓存中过时的值。

volatile 如何解决可见性问题?

volatile关键字在Java中如何保证可见性?-图3
(图片来源网络,侵删)

当一个变量被 volatile 修饰后,它就相当于一个“信号灯”,JVM 会确保:

  • 写操作:当一个线程修改一个 volatile 变量时,JMM(Java 内存模型)会强制将该线程工作内存中的值立即刷新到主内存
  • 读操作:当一个线程读取一个 volatile 变量时,JMM 会强制让该线程的工作内存失效,然后必须从主内存中重新读取最新的值。

通过这种方式,volatile 就像在各个线程的缓存和主内存之间建立了一条直接的通信通道,确保了任何一个线程对这个 volatile 变量的修改,对其他线程都是立即可见的。

示例代码:

public class VolatileVisibilityExample {
    // private boolean flag = false; // 没有 volatile,线程 B 可能永远看不到变化
    private volatile boolean flag = false;
    public static void main(String[] args) {
        VolatileVisibilityExample example = new VolatileVisibilityExample();
        // 线程 A:负责修改 flag
        new Thread(() -> {
            System.out.println("Thread A is running, and will set flag to true...");
            try {
                Thread.sleep(1000); // 模拟耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            example.flag = true;
            System.out.println("Thread A set flag to true.");
        }).start();
        // 线程 B:负责读取 flag
        new Thread(() -> {
            System.out.println("Thread B is running, waiting for flag to become true...");
            // 使用 while 循环不断检查 flag
            // 如果没有 volatile,这里的 flag 可能一直读取的是线程 B 工作内存中的 false
            while (!example.flag) {
                // do nothing, just wait
            }
            System.out.println("Thread B sees flag is true, now exiting.");
        }).start();
    }
}

在这个例子中,如果没有 volatile,线程 B 的 while 循环可能会因为读取到自己工作内存中过期的 false 值而无限循环下去,加上 volatile 后,线程 A 修改 flagtrue 后,会立即写回主内存,线程 B 在下一次读取时,必须从主内存读取,从而能看到 true 值,并退出循环。

2 禁止指令重排序

问题背景:指令重排序

为了优化性能,编译器和处理器可能会对输入的代码进行乱序执行优化,即“指令重排序”,重排序分为两种:

  1. 编译器优化重排序:在不改变单线程程序语义的前提下,编译器可以调整语句的执行顺序。
  2. 处理器运行时重排序:为了最大化 CPU 流水线的利用率,处理器可以乱序执行指令。

在多线程环境下,这种重排序可能会导致意想不到的结果。

volatile 如何禁止指令重排序?

当一个变量被 volatile 修饰后,JMM 会插入一个“内存屏障”(Memory Barrier),内存屏障可以禁止其前后的指令进行重排序优化,从而保证了程序的执行顺序。

经典应用场景:双重检查锁定实现的单例模式

这是 volatile 最经典的应用,用来解决 DCL 中的指令重排序问题。

public class Singleton {
    // volatile 关键字防止指令重排序
    private static volatile Singleton instance = null;
    private Singleton() {
        // 私有构造函数
    }
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查(无锁,快速)
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查(有锁,确保唯一)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么需要 volatile

关键在于 instance = new Singleton(); 这一行,它并非一个原子操作,大致可以分解为三步:

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

如果没有 volatile,由于指令重排序,步骤 2 和 3 的顺序可能会被颠倒(即先执行步骤 3,再执行步骤 2)。

假设线程 A 正在创建单例,它执行了步骤 3(instance 指向了内存地址),但还没有执行步骤 2(对象还未初始化),线程 B 调用 getInstance() 方法,在第一次检查时发现 instance 不再为 null,于是直接返回 instance,但线程 B 拿到的其实是一个未初始化完成的对象,如果此时访问其内部属性,就会抛出 NullPointerException 或其他错误。

加上 volatile 后,内存屏障确保了 instance = new Singleton(); 的三个步骤不会被重排序,必须按 1->2->3 的顺序完成,从而保证了线程 B 拿到的是一个完全初始化好的对象。


volatile 的局限性

volatile 虽然强大,但它并不能解决所有并发问题。它不保证原子性

什么是原子性?

一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。

示例:volatile 不保证原子性

public class VolatileAtomicityExample {
    // volatile 保证了 i 的可见性,但不保证 i++ 的原子性
    private volatile int i = 0;
    public void increase() {
        i++; // i++ 不是原子操作
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileAtomicityExample example = new VolatileAtomicityExample();
        Thread[] threads = new Thread[10];
        for (int j = 0; j < 10; j++) {
            threads[j] = new Thread(() -> {
                for (int k = 0; k < 1000; k++) {
                    example.increase();
                }
            });
            threads[j].start();
        }
        // 等待所有线程执行完毕
        for (Thread t : threads) {
            t.join();
        }
        // 预期结果是 10000,但实际结果通常小于 10000
        System.out.println("Final value of i: " + example.i);
    }
}

为什么 i++ 不是原子操作?

i++ 实际上包含三个步骤:

  1. 读取:从主内存读取 i 的值到工作内存。
  2. 修改:将 i 的值加 1。
  3. 写入:将 i 的新值写回主内存。

虽然 volatile 保证了第 3 步的写入对其他线程立即可见,但它没有将这三个步骤打包成一个不可分割的整体,在多线程环境下,可能会发生以下情况:

  • 线程 A 读取 i=0
  • 线程 B 也读取 i=0
  • 线程 A 将 i 加 1,得到 1,并写回主内存。
  • 线程 B 也将 i 加 1,得到 1,并写回主内存。

i 的值是 1,而不是预期的 2,一次修改“丢失”了。

如何解决原子性问题?

如果需要保证复合操作的原子性,应该使用 synchronized 关键字或者 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。


volatile vs. synchronized

特性 volatile synchronized
本质 轻量级的同步机制,修饰变量 重量级的同步机制,修饰代码块/方法
原子性 不保证 保证(被synchronized保护的代码块是原子的)
可见性 保证(读写都会作用于主内存) 保证(线程解锁前必须把变量刷新回主内存,加锁时清空工作内存)
有序性 禁止指令重排序 禁止指令重排序happens-before原则)
不涉及锁,不会引起线程阻塞 涉及锁,可能会引起线程阻塞和上下文切换,性能开销大
使用场景 适用于一个线程写,多个线程读的场景(如状态标志位) 适用于多个线程写,或者读-写复合操作的场景
性能 性能较高,不会阻塞线程 性能较低,涉及用户态到内核态的切换

总结与最佳实践

volatile 是 Java 并发编程中一个不可或缺的工具,它通过保证可见性禁止指令重排序,解决了多线程编程中的两个核心问题。

  • 保证可见性:确保一个线程的修改对其他线程立即可见。
  • 禁止指令重排序:防止编译器和处理器为了优化而打乱代码执行顺序,导致并发逻辑错误。

但它最大的局限性不保证原子性

最佳实践

在以下场景中,volatile 是一个非常理想的选择:

  1. 状态标志位:这是最经典、最推荐的使用场景,一个线程通过修改 volatile 布尔变量来通知其他线程终止任务或改变状态。

    private volatile boolean shutdownRequested;
    public void shutdown() {
        this.shutdownRequested = true;
    }
    public void doWork() {
        while (!shutdownRequested) {
            // do work
        }
    }
  2. 双重检查锁定(DCL):在实现懒加载的单例模式时,使用 volatile 来防止指令重排序导致的错误。

  3. 一次性安全发布:当某个引用的值在构造后就不需要再被修改,并且需要被安全地发布给其他线程时,可以将该引用声明为 volatile

何时选择 synchronizedjava.util.concurrent.atomic

  • 当你需要保证复合操作的原子性时(如 i++, count++),请使用 synchronizedAtomicInteger 等原子类。
  • 当你需要保护一段代码块,确保同一时间只有一个线程能执行它时,请使用 synchronized

一句话总结:

volatile 用于“状态”的可见性,synchronized 用于“操作”的原子性。 理解并正确使用它们,是编写高质量 Java 并发程序的关键一步。

分享:
扫描分享到社交APP
上一篇
下一篇