杰瑞科技汇

Java volatile作用是什么?

核心思想一句话总结

volatile 是 Java 提供的一种轻量级的同步机制,它保证了被修饰的变量的可见性禁止指令重排序,但不保证原子性。

Java volatile作用是什么?-图1
(图片来源网络,侵删)

为了彻底理解这句话,我们需要从 Java 内存模型 说起。


背景:Java 内存模型

为了理解 volatile 为什么必要,我们首先要理解一个多线程环境下的核心问题:内存可见性

在 Java 中,为了提高性能,JMM(Java Memory Model)定义了主内存工作内存的概念:

  • 主内存:所有线程共享,存储了所有实例的字段、静态变量和构成数组对象的元素。
  • 工作内存:每个线程私有,是线程的私有数据区,存储了主内存中部分变量的副本。

线程的执行过程如下:

Java volatile作用是什么?-图2
(图片来源网络,侵删)
  1. 线程从主内存中读取变量到自己的工作内存。
  2. 线程在工作内存中修改变量的值。
  3. 线程将工作内存中修改后的值写回主内存。

问题就出在这里:由于每个线程都有自己的工作内存,一个线程对变量的修改可能不会立刻被其他线程看到,这就导致了可见性问题


volatile 的三大作用

volatile 关键字就像一个“旗语”,它向 JVM 和 JIT 编译器发出信号,告诉它们这个变量是“不稳定的”,需要特殊对待。

保证可见性

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

当一个变量被 volatile 修饰后:

Java volatile作用是什么?-图3
(图片来源网络,侵删)
  • 写操作:当线程 A 修改一个 volatile 变量时,JMM 会强制将线程 A 工作内存中的值立即刷新到主内存
  • 读操作:当线程 B 读取一个 volatile 变量时,JMM 会强制让线程 B 的工作内存失效,并从主内存中重新读取最新的值。

通过这种方式,一个线程对 volatile 变量的修改,对其他线程来说都是立即可见的。

示例:一个经典的可见性问题场景

public class VolatileVisibilityExample {
    // private boolean flag = false; // 不使用 volatile
    private volatile boolean flag = false; // 使用 volatile
    public void writer() {
        flag = true; // 1. 修改 flag
        System.out.println("Writer thread has set flag to true.");
    }
    public void reader() {
        // while (!flag) { // 如果没有 volatile,这个循环可能永远不会结束
        //     // 空循环,等待 flag 变为 true
        // }
        // 使用了 volatile,JVM 会保证 reader 能看到 flag 的最新值
        if (flag) {
            System.out.println("Reader thread sees flag is true.");
        }
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileVisibilityExample example = new VolatileVisibilityExample();
        // 启动读线程
        new Thread(() -> {
            example.reader();
        }).start();
        // 主线程稍等一下,确保读线程已经进入循环
        Thread.sleep(100);
        // 启动写线程
        new Thread(() -> {
            example.writer();
        }).start();
    }
}
  • 不使用 volatilereader 线程可能一直读取到自己工作内存中 flag 的旧值 false,导致 while 循环无法退出,即使 writer 线程已经将 flag 设为 true 并写回了主内存。
  • 使用 volatilewriter 线程写回 true 后,reader 线程在下一次读取 flag 时,会强制从主内存读取,从而得到最新的值 true,程序可以正常继续执行。

禁止指令重排序

为了优化性能,编译器和处理器可能会对代码的执行顺序进行调整,只要最终的单线程结果不变,这种调整就被称为指令重排序

volatile 关键字可以禁止与该变量相关的指令进行重排序,从而保证了程序的有序性

这在双重检查锁定 实现的单例模式中至关重要。

示例:DCL 中的 volatile

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();
                }
            }
        }
        return instance;
    }
}

为什么 instance 必须是 volatilenew Singleton() 这一步并非原子操作,大致可以分解为三步:

  1. memory = allocate(); // 分配对象的内存空间
  2. ctorInstance(memory); // 初始化对象
  3. instance = memory; // 将 instance 引用指向分配的内存地址

如果没有 volatile,由于指令重排序,执行顺序可能变成 1 -> 3 -> 2。 另一个线程在第一次检查 if (instance == null) 时,发现 instance 已经不为 null 了(因为步骤 3 已经完成),于是它返回了一个尚未初始化完成的对象,导致程序出错。

volatile 会确保 instance = new Singleton() 的三个步骤不会被重排序,必须按 1->2->3 的顺序完成,从而避免了这个问题。


不保证原子性

这是 volatile 最容易让人误解的地方。volatile 不能替代 synchronized 来保证复合操作的原子性。

示例:volatile 的非原子性

public class VolatileAtomicityExample {
    // volatile 只能保证每次读取和写入的都是主内存的最新值
    // 但不能保证 count++ 这个复合操作的原子性
    private volatile int count = 0;
    public void increment() {
        count++; // 这不是原子操作!
    }
    public int getCount() {
        return count;
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileAtomicityExample example = new VolatileAtomicityExample();
        int threadCount = 1000;
        // 创建 1000 个线程,每个线程执行 1000 次 increment
        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    example.increment();
                }
            }).start();
        }
        // 等待所有线程执行完毕
        Thread.sleep(2000);
        System.out.println("Final count: " + example.getCount()); // 结果几乎肯定小于 1,000,000
    }
}

为什么 count++ 不是原子操作? 它包含了三个步骤:

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

在多线程环境下,可能发生这样的情况:

  • 线程 A 读取 count 为 100。
  • 线程 B 也读取 count 为 100。
  • 线程 A 将 count 加 1,写回 101。
  • 线程 B 也将 count 加 1,写回 101。
  • count 只增加了 1,而不是 2。

要解决 count++ 的原子性问题,必须使用 synchronized 或者 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。


volatile vs. synchronized

特性 volatile synchronized
作用范围 作用于变量 作用于代码块方法
保证原子性 不保证 保证
保证可见性 保证 保证(释放锁时,会刷回主内存;获取锁时,会从主内存加载)
保证有序性 保证(禁止指令重排序) 保证(一个锁一次只被一个线程持有,保证了临界区内代码的执行顺序)
性能 较高,是轻量级的同步机制 较低,涉及到用户态和内核态的切换,开销较大
使用场景 适用于一个线程写,多个线程读的场景(如状态标志位) 适用于多个线程写,或需要保护复杂临界区的场景

适用场景

volatile 通常用于以下场景:

  1. 状态标志位:这是最经典、最安全的用法。private volatile boolean shutdownRequested;
  2. 一次性安全发布:当某个引用的值只在构造函数中初始化,并且没有 this 引逸出时,可以将该引用声明为 volatile,以确保其他线程能看到已完全初始化的对象,双重检查锁定中的 instance
  3. 独立观察:某个场景下,一个线程的更新需要被其他线程观察到,但不影响该线程的后续执行逻辑。volatile 变量用于统计信息,其精确性不重要,但必须是最新的。

volatile 的核心三要素:

  1. 可见性:保证一个线程的修改对其他线程立即可见。
  2. 有序性:禁止指令重排序,防止因重排序导致的问题。
  3. 非原子性:不能保证复合操作的原子性,不能替代 synchronized

volatile 是 Java 并发编程中一个非常强大且高效的工具,但它是一把“手术刀”,精准但有限,在需要保证原子性的复杂场景下,synchronizedjava.util.concurrent 包下的工具类才是更合适的选择。

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