杰瑞科技汇

volatile关键字如何保证可见性与禁止指令重排?

核心定义:volatile 是什么?

volatile 是 Java 提供的一种轻量级的同步机制,它不像 synchronized 那样能保证原子性和代码块的互斥执行,但它能保证可见性禁止指令重排序

volatile关键字如何保证可见性与禁止指令重排?-图1
(图片来源网络,侵删)

当一个变量被声明为 volatile 后,它就具备了以下两个特性:

  1. 可见性:一个线程对 volatile 变量的修改,对其他所有线程都是立即可见的。
  2. 有序性:禁止指令重排序优化,保证了程序的执行顺序符合代码的逻辑顺序。

volatile 解决的核心问题

为了理解 volatile 的作用,我们首先需要了解它在并发环境下要解决的两个核心问题:可见性指令重排序

可见性

背景:Java 内存模型

Java 内存模型是定义线程如何与内存进行交互的规范,为了提高性能,JMM 定义了主内存工作内存的概念:

volatile关键字如何保证可见性与禁止指令重排?-图2
(图片来源网络,侵删)
  • 主内存:所有线程共享,存储了所有实例字段、静态字段和构成数组对象的元素。
  • 工作内存:每个线程独有,是线程的私有数据区域,存储了主内存中部分变量的副本。

线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。

问题所在: 当一个线程修改了一个共享变量的值,它只是在自己的工作内存中修改了这个副本,这个修改并不会立刻同步回主内存,其他线程也无法感知到这个变化,它们可能仍然在使用自己工作内存中的旧值,这就导致了可见性问题。

经典案例:停止线程

public class VolatileDemo implements Runnable {
    // 不使用 volatile,线程可能永远不会停止
    // private boolean isRunning = true;
    // 使用 volatile,可以保证可见性,线程能正常停止
    private volatile boolean isRunning = true;
    public void setRunning(boolean running) {
        isRunning = running;
    }
    @Override
    public void run() {
        System.out.println("子线程启动...");
        while (isRunning) {
            // do something...
            // 即使这里没有操作,isRunning 的值也可能不会被刷新
        }
        System.out.println("子线程结束...");
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileDemo demo = new VolatileDemo();
        new Thread(demo).start();
        Thread.sleep(1000); // 主线程休眠1秒
        System.out.println("主线程即将修改 isRunning 的值...");
        demo.setRunning(false); // 主线程修改 isRunning
        System.out.println("主线程已修改 isRunning 的值");
        Thread.sleep(1000);
        System.out.println("主线程结束");
    }
}

分析

volatile关键字如何保证可见性与禁止指令重排?-图3
(图片来源网络,侵删)
  • 没有 volatile 的情况:主线程将 isRunning 设为 false,这个修改可能只存在于主线程的工作内存中,而子线程一直在循环读取自己的工作内存中的 isRunning,它永远是 true,导致子线程无法停止。
  • volatile 的情况:当主线程修改 isRunning 时,volatile 会确保这个修改被立刻刷新到主内存,当子线程需要读取 isRunning 时,volatile 会强制它从主内存中重新读取最新的值,从而看到了 false,循环得以终止。

指令重排序

背景:CPU 和编译器的优化

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

  • 编译器优化重排序:在不改变单线程程序执行结果的前提下,重新安排语句的执行顺序。
  • 处理器运行时重排序:为了充分利用CPU的内部资源(如流水线、乱序执行),处理器可能会对指令进行重排。

问题所在: 在多线程环境下,一些看似没有数据依赖关系的代码,经过重排序后,可能会改变其并发执行的结果,导致程序出错。

经典案例:双重检查锁定实现的单例模式

public class Singleton {
    // 没有 volatile,可能会出现问题
    // private static Singleton instance = null;
    // 使用 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;
    }
}

分析instance = new Singleton(); 这一行代码,大致可以分解为三个步骤:

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

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

发生顺序可能变为:1 -> 3 -> 2

这时,假设线程 A 进入 synchronized 代码块,执行了 instance = new Singleton();,但由于重排序,它先执行了步骤 3(instance 指向了内存地址,但对象还未初始化),就在这一瞬间,线程 B 调用 getInstance() 方法,执行第一次检查 if (instance == null),由于线程 A 已经执行了步骤 3,instance 不再是 null,所以判断为 false,线程 B 直接返回 instance,这个 instance 对象尚未初始化(步骤 2 还没执行),如果线程 B 此时访问 instance 的任何字段,都将会抛出 NullPointerException 或得到错误的数据。

volatile 的作用volatile 关键字可以禁止指令重排序,它插入了一个“内存屏障”(Memory Barrier),内存屏障可以确保其前后的指令不会进行重排序优化,加了 volatile 后,instance = new Singleton(); 的三个步骤顺序(1 -> 2 -> 3)将得到保证,从而避免了上述问题。


volatile 的三大特性总结

特性 作用 实现原理
保证可见性 当一个线程修改了 volatile 变量,新值对于其他线程来说会立即可见。 写操作时,会立刻将值刷新到主内存;读操作时,会直接从主内存读取,并使工作内存中的缓存失效。
不保证原子性 volatile 关键字不保证复合操作的原子性。 volatile 只是保证了单个读/写操作的原子性,但对于 i++ 这样的“读取-修改-写入”复合操作,无法保证其原子性。
禁止指令重排序 禁止 volatile 变量前后的指令进行重排序优化,保证了代码的执行顺序。 通过插入内存屏障,实现内存屏障前后的指令不能重排序。

volatile 的局限性:不保证原子性

这是 volatile 一个非常重要的限制。

经典案例:volatilei++ 问题

public class VolatileAtomicityDemo {
    // volatile 保证了可见性,但不保证 i++ 的原子性
    private static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 非原子操作
            }
        };
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count 的值: " + count); // 结果几乎总是小于 20000
    }
}

分析count++ 实际上是一个复合操作,包含三个步骤:

  1. 读取:从内存中读取 count 的值。
  2. 修改:将值加 1。
  3. 写入:将新值写回内存。

即使 countvolatile 的,它也只能保证每次读取都是从主内存读取,每次写入都是立刻刷新到主内存,但它不能保证这三个步骤作为一个整体原子性地执行。

竞态条件: 假设 count 当前值为 10。

  • 线程 A 读取 count 的值为 10。
  • 线程 B 也读取 count 的值为 10。
  • 线程 A 将值加 1,得到 11,并写回主内存。count 变为 11。
  • 线程 B 也将值加 1,得到 11,并写回主内存。count 变为 11。

理想情况下,两次 count++ 应该让 count 变成 12,但实际上只增加了 1,这就是竞态条件

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


volatile vs. synchronized

特性 volatile synchronized
作用范围 作用于变量 作用于代码块或方法
原子性 不保证 保证(对临界区代码互斥访问)
可见性 保证 保证(释放锁时刷新内存,获取锁时清空工作内存)
有序性 禁止指令重排序 禁止指令重排序happens-before 原则)
性能开销 较低 较高(涉及用户态到内核态的切换、上下文切换、锁的获取与释放)
使用场景 适用于一个线程写,多个线程读的场景。 适用于多个线程写,或读写操作复杂的场景。

适用场景

volatile 并非万能药,它适用于以下特定场景:

  1. 状态标记量:如上面的 isRunning 例子,一个线程负责修改状态,其他线程根据状态做判断。

    volatile boolean shutdownRequested;
    public void shutdown() {
        shutdownRequested = true;
    }
    public void doWork() {
        while (!shutdownRequested) {
            // do work
        }
    }
  2. 双重检查锁定:如单例模式例子,确保 instance 对象的创建过程不会被重排序。

  3. 发布一个事件触发器:当一个状态被更新后,需要触发一系列事件,使用 volatile 可以确保所有线程都能看到最新的状态。

  • volatile 是轻量级的同步机制,它解决了多线程环境下的可见性有序性问题,但不保证原子性
  • 它通过内存屏障来实现其语义,确保了变量的修改能被其他线程立即看到,并防止了指令重排序。
  • 当你只需要一个线程写、多个线程读,且不涉及复合操作时,volatile 是一个比 synchronized 更高效的选择。
  • 当你需要保证复合操作的原子性时,必须使用 synchronizedjava.util.concurrent.atomic 包下的原子类。

理解 volatile 是迈向精通 Java 并发编程的重要一步。

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