杰瑞科技汇

java volatile 关键字

它是什么?

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

java volatile 关键字-图1
(图片来源网络,侵删)

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

  1. 可见性:当一个线程修改了一个 volatile 变量,新值对于其他线程来说会立即变得可见,也就是说,当一个线程读取一个 volatile 变量时,它总是会看到(读取到)这个变量的最新值。
  2. 禁止指令重排序:通过插入一个“内存屏障”(Memory Barrier),volatile 关键字可以禁止其前后的指令进行重排序优化,从而保证了代码的执行顺序。

为什么需要 volatile?—— 问题的根源:Java 内存模型

要理解 volatile,必须先了解 Java 内存模型,JVM 为了提高性能,允许线程对共享变量的操作在自己的工作内存(通常是 CPU 高速缓存)中进行,而不是直接在主内存中操作。

这就导致了经典问题:可见性有序性问题。

可见性问题

假设一个场景:一个主线程启动了一个工作线程,主线程设置一个 flagtrue 来通知工作线程停止运行。

java volatile 关键字-图2
(图片来源网络,侵删)
class Worker implements Runnable {
    // 普通变量
    private boolean flag = false;
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        while (!flag) {
            // do something...
            // 如果这里没有适当的同步机制,JVM可能会优化这个循环
            // 认为flag在循环中没有被修改,于是直接从自己的工作内存读取flag的初始值false
            // 导致while循环永远不会结束,即使主线程已经调用了setFlag(true)
        }
        System.out.println("Worker stopped.");
    }
}

问题分析

  1. 主线程将 flag 设置为 true,这个操作可能只写入了主线程自己的工作内存,还没有立即刷新到主内存。
  2. 工作线程在自己的工作内存中读取 flag,它看到的仍然是初始值 false
  3. 即使主线程调用了 setFlag(true),工作线程的 while 循环也永远不会结束。

有序性问题

指令重排序是 JIT 编译器为了优化性能而做的操作,在某些情况下,重排序可能会导致意想不到的结果。

最经典的例子是双重检查锁定(DCL, Double-Checked Locking)的单例模式实现。

class Singleton {
    private static Singleton instance = null; // 没有使用 volatile
    private Singleton() {
        // 构造函数
    }
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 问题所在
                }
            }
        }
        return instance;
    }
}

问题分析instance = new Singleton(); 这一行代码并非原子操作,大致可以分解为三步:

java volatile 关键字-图3
(图片来源网络,侵删)
  1. 分配对象的内存空间。
  2. 初始化对象(Singleton() 构造函数)。
  3. instance 引用指向分配好的内存地址。

在指令重排序优化下,执行顺序可能会变成 1 -> 3 -> 2

灾难性后果

  1. 线程 A 进入 synchronized 代码块,执行 instance = new Singleton();
  2. 由于重排序,线程 A 先执行了步骤 1 和 3。instance 已经不为 null 了,但对象还没有被初始化(步骤 2 未执行)。
  3. 就在这一刻,线程 B 调用 getInstance() 方法,它执行第一次检查 if (instance == null),发现 instance 不为 null
  4. 线程 B 直接返回 instance,这个 instance 是一个尚未初始化完成的对象,如果此时线程 B 试图访问 instance 的任何字段,都可能导致 NullPointerException 或其他不可预期的错误。

volatile 如何解决这些问题?

volatile 关键字就像一个信号灯,告诉编译器和虚拟机:“对这个变量的操作,请务必按照我写的顺序来,并且修改后要立刻通知其他线程!”

保证可见性

当一个变量被 volatile 修饰后:

  • 写操作:当一个线程修改 volatile 变量时,JMM 会将该线程工作内存中的值强制刷新到主内存中。
  • 读操作:当一个线程读取 volatile 变量时,JMM 会使其工作内存中的值失效,并直接从主内存中重新读取最新的值。

这样就解决了上面“Worker线程无法停止”的问题。

class Worker implements Runnable {
    // 使用 volatile 修饰
    private volatile boolean flag = false;
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        while (!flag) {
            // do something...
            // 现在每次循环都会从主内存读取flag的最新值
        }
        System.out.println("Worker stopped.");
    }
}

禁止指令重排序

volatile 关键字会插入一个“内存屏障”,内存屏障可以禁止其前后的指令进行重排序,并且可以保证其前面的所有写操作都对其他线程可见。

这解决了双重检查锁定的问题。

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 -> 3 -> 2 的顺序,必须是 1 -> 2 -> 3 完成后,instance 才会不为 null,从而保证了线程安全。


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

这是 volatile 最重要也最容易被误解的一点。

场景:一个计数器,多个线程同时对其进行 操作。

class Counter {
    // 使用 volatile 修饰
    private volatile int count = 0;
    public void increment() {
        count++; // 这不是原子操作!
    }
    public int getCount() {
        return count;
    }
}

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

  1. 读取:读取 count 的当前值。
  2. 修改:将值加 1。
  3. 写入:将新值写回 count

即使 countvolatile 的,也只能保证步骤 3 的写入对其他线程是可见的。它不能保证这三个步骤作为一个整体(原子性)被执行。

问题分析

  • 线程 A 读取 count (值为 0)。
  • 线程 B 读取 count (值也为 0)。
  • 线程 A 将 0 加 1,得到 1,并写回 count
  • 线程 B 也将 0 加 1,得到 1,并写回 count

count 的值是 1,而不是预期的 2,这就是典型的“丢失更新”问题。

如何保证原子性?

  • 使用 synchronized 关键字。
  • 使用 java.util.concurrent.atomic 包下的原子类,如 AtomicInteger

volatile vs. synchronized

特性 volatile synchronized
作用范围 只能修饰变量 可以修饰方法、代码块
原子性 不保证 保证(被同步的代码块/方法)
可见性 保证 保证(解锁前必须将数据刷新到主内存,加锁时必须清空工作内存)
有序性 禁止指令重排序 禁止指令重排序(一个线程的解锁 happens-before 另一个线程的加锁)
性能 较高,不会引起线程阻塞 较低,会引起线程上下文切换和阻塞
使用场景 一个线程写,多个线程读;或状态标志位 多个线程读写共享资源,需要保证复杂操作的原子性

总结与最佳实践

什么时候使用 volatile

  1. 状态标志位:这是 volatile 最经典和最合适的用途。private volatile boolean shutdownRequested;

  2. 一次性安全发布:当某个引用在构造函数中被正确初始化之后,它被发布给其他线程,可以将该引用声明为 volatile,确保其他线程看到的是完全初始化后的对象,双重检查锁定就是典型例子。

  3. “结果缓存”:当某个计算结果需要被多个线程使用,且计算过程不依赖于任何其他状态时,可以使用 volatile 缓存结果,避免重复计算。

    public class CachedCompute {
        private volatile Result result;
        public Result compute() {
            Result res = result;
            if (res == null) {
                synchronized (this) {
                    res = result;
                    if (res == null) {
                        res = computeExpensively();
                        result = res;
                    }
                }
            }
            return res;
        }
    }

什么时候不使用 volatile

  • 当你需要保证复合操作的原子性时(如 i++),不要使用 volatile,而应使用 synchronizedAtomic 类。
  • volatile 变量作为锁,或者一个线程的写操作依赖于另一个线程的读操作时(生产者-消费者模式中的 flag),volatile 是不够的,必须使用更高级的同步工具,如 BlockingQueueLock

volatile 是 Java 并发工具箱中的一把利剑,用对了地方,可以极大地提升程序性能和可读性;用错了地方,则会埋下难以发现的隐患,理解其原理和局限性至关重要。

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