杰瑞科技汇

java中volatile关键字

volatile 是 Java 并发编程中一个非常重要但相对底层的关键字,它主要用于修饰被多个线程共享的变量,要理解 volatile,我们首先需要了解 Java 内存模型以及它试图解决的问题。


为什么需要 volatile?—— JMM 与可见性问题

1 Java 内存模型

为了提高性能,现代计算机体系结构在硬件层面做了很多优化,其中一个重要的优化是 高速缓存,CPU 的运算速度远快于主内存的读写速度,因此每个 CPU 核心都有自己的高速缓存(Cache),线程在运行时,会把主内存中的数据拷贝一份到自己的工作内存(即高速缓存或寄存器)中。

这就带来了问题:

  1. 可见性:当某个线程修改了一个共享变量的值,这个新值并不会立刻同步到主内存中,其他线程在使用这个变量时,可能直接从自己的工作内存中读取,而不是从主内存读取,这就导致了它们读取到的可能是一个过期的、不一致的值。

  2. 指令重排:为了优化性能,编译器和处理器可能会对输入的代码进行乱序执行优化,即改变代码的执行顺序,在单线程环境下,这不会影响最终结果,但在多线程环境下,这种重排可能会导致意想不到的后果。

2 volatile 的核心作用:保证可见性

volatile 关键字就是用来解决 可见性 问题的。

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

  1. 保证可见性:当一个线程修改了一个 volatile 变量,新值会立刻同步到主内存中,当其他线程需要读取这个 volatile 变量时,会直接从主内存中读取,而不是从各自的工作内存中读取,这就保证了线程间变量的可见性。

  2. 禁止指令重排(部分有序性)volatile 关键字会插入一个“内存屏障”(Memory Barrier),内存屏障可以禁止其前后的指令进行重排序优化,从而保证了程序的执行顺序。


volatile 的两个核心语义详解

1 保证可见性

工作原理: volatile 的可见性是通过内存屏障来实现的,当一个写操作发生在一个 volatile 变量上时,JVM 会在写操作后插入一个 StoreStoreStoreLoad 屏障,当一个读操作发生在一个 volatile 变量上时,JVM 会在读操作前插入一个 LoadLoadLoadStore 屏障。

这些屏障确保了:

  • 写操作后的任何普通写操作都不能重排到 volatile 写操作之前。
  • 读操作前的任何普通读操作都不能重排到 volatile 读操作之后。
  • 保证了 volatile 变量的写对后续的任何读(无论是否 volatile)都是可见的。

示例:

class Worker implements Runnable {
    // 使用 volatile 修饰,保证 flag 的可见性
    private volatile boolean flag = true;
    public void stop() {
        this.flag = false;
    }
    @Override
    public void run() {
        System.out.println("Worker thread started.");
        while (flag) {
            // do something...
            // 如果没有 volatile,线程可能一直从自己的工作内存中读取 flag=true,
            // 导致即使 main 线程调用了 stop(),也无法及时退出循环。
        }
        System.out.println("Worker thread stopped.");
    }
}
public class VolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker();
        new Thread(worker).start();
        Thread.sleep(1000); // 主线程休眠1秒
        System.out.println("Main thread is about to stop the worker...");
        worker.stop(); // 修改 flag
        System.out.println("Main thread has stopped the worker.");
    }
}

在这个例子中,如果没有 volatileWorker 线程可能永远无法感知到 flag 的变化,导致程序无法正常退出,加上 volatile 后,main 线程对 flag 的修改会立刻对 Worker 线程可见。

2 禁止指令重排

volatile 关键字可以保证变量的读写操作不会被重排序,这在双重检查锁定实现的单例模式中至关重要。

经典应用:双重检查锁定单例模式

public class Singleton {
    // volatile 是必须的!
    private static volatile Singleton instance;
    private Singleton() {
        // private constructor
    }
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么需要 volatile

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

  1. 分配内存memory = allocate()
  2. 初始化对象ctorInstance(memory) (填充对象的各个字段)。
  3. 建立引用instance = memory (将 instance 引用指向分配的内存地址)。

在没有 volatile 的情况下,由于指令重排,执行顺序可能会变成 1 -> 3 -> 2

这会导致什么问题?

  • 假设线程 A 进入 synchronized 代码块,执行了 13,但还没执行 2instance 已经不为 null 了。
  • 就在此时,线程 B 调用 getInstance(),它执行第一次检查 if (instance == null),由于 instance 已经不为 null,条件判断为 false,线程 B 直接返回 instance
  • 线程 B 拿到的 instance 是一个 未初始化完成的对象!如果此时线程 B 试图访问 instance 的任何字段,都可能会导致 NullPointerException 或其他不可预知的错误。

volatile 如何解决这个问题?

volatile 关键字会插入内存屏障,确保 instance = new Singleton(); 的三个步骤不会被重排序,必须按照 1 -> 2 -> 3 的顺序完成,这样,在线程 A 完成整个操作之前,线程 B 看到的 instance 始终是 null,从而保证了线程安全。


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

volatile 只能保证可见性和禁止指令重排,但它 不能保证复合操作的原子性

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

示例:

class Counter {
    // volatile 保证了 count 的可见性,但不能保证 count++ 的原子性
    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 (值为 10)。
  • 线程 B 读取 count (值也为 10)。
  • 线程 A 执行加 1,准备写回 11。
  • 线程 B 也执行加 1,准备写回 11。
  • 线程 A 写回 11。
  • 线程 B 写回 11。

count 的结果是 11,而不是预期的 12,这就是典型的 “丢失更新” 问题。

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

// 使用 AtomicInteger 保证原子性
class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    public void increment() {
        count.incrementAndGet(); // 底层使用 CAS 机制,是原子操作
    }
}

volatile vs. synchronized

特性 volatile synchronized
作用范围 只能修饰变量 可以修饰代码块、方法
原子性 不保证 保证 (被 synchronized 修饰的代码块是原子的)
可见性 保证 (对单个变量的读写) 保证 (对一个线程解锁 happens-before 于另一个线程加锁)
有序性 禁止指令重排 禁止指令重排 (一个线程解锁 happens-before 于另一个线程加锁)
锁机制 不涉及锁,轻量级 涉及锁,重量级
性能 较高 较低(锁的获取和释放有开销)

使用场景总结:

  • 使用 volatile

    • 写入变量不依赖于当前值。
    • 该变量没有包含在具有其他变量的不变式中。
    • 当一个线程需要读取另一个线程写入的值,且不需要基于旧值计算新值时,volatile 是一个很好的选择,状态标志(isRunning)、一次性发布(Event Bus)等。
  • 使用 synchronized 或原子类

    • 当你需要保证一个或多个操作的原子性时(如 i++)。
    • 当你需要保护一个代码块,确保同一时间只有一个线程执行它。

volatile 是 Java 并发工具箱中一个轻量级的同步工具。

  1. 核心功能

    • 保证可见性:确保一个线程的修改对其他线程立即可见。
    • 禁止指令重排:保证程序的执行顺序,防止因重排序导致的逻辑错误。
  2. 核心限制

    • 不保证原子性:不能用于替代 synchronized 来处理复合操作。
  3. 适用场景

    • 状态标志(如 boolean isRunning)。
    • 一次性安全发布(Event Bus 的发布者)。
    • double-checked locking 单例模式中的实例引用。
  4. 不适用场景

    • 依赖旧值计算新值的场景(如 count++),应使用 synchronizedAtomicInteger

理解 volatile 的原理和适用范围,对于编写正确且高效的并发程序至关重要,它是在 synchronized 之外,解决并发问题的一个重要工具。

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