杰瑞科技汇

volatile如何保证Java线程可见性?

volatile 是什么?

volatile 是 Java 语言提供的一种轻量级的同步机制,它是一个修饰符,可以用来修饰成员变量和静态成员变量,当一个变量被声明为 volatile 后,它将具备两种特性:

volatile如何保证Java线程可见性?-图1
(图片来源网络,侵删)
  1. 保证可见性
  2. 禁止指令重排序

核心特性详解

1 保证可见性

这是 volatile 最核心、最重要的特性。

问题背景:JMM (Java Memory Model) 与工作内存

为了理解可见性,我们首先需要了解 Java 内存模型 的一个核心概念:主内存工作内存

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

线程在执行时,不能直接读写主内存中的变量,线程的操作流程如下:

volatile如何保证Java线程可见性?-图2
(图片来源网络,侵删)
  1. 从主内存中读取变量到自己的工作内存。
  2. 对工作内存中的变量副本进行操作。
  3. 将操作后的结果写回主内存。

这就带来了问题:如果一个线程修改了工作内存中的变量副本,但没有立即写回主内存,那么其他线程就无法感知到这个变化,它们看到的仍然是旧的值,这就导致了可见性问题。

volatile 如何解决可见性问题?

当一个变量被 volatile 修饰后,JMM 会做以下特殊规定:

  1. 写操作:当线程 A 修改一个 volatile 变量时,JMM 会强制要求线程 A 立即将修改后的值刷新到主内存中。
  2. 读操作:当线程 B 读取一个 volatile 变量时,JMM 会强制要求线程 B 从主内存中读取该变量,而不是从自己的工作内存中读取。

通过这种方式,volatile 就像是在线程之间建立了一条直接的、可见的通道,确保一个线程对 volatile 变量的修改,对其他线程来说是立即可见的。

volatile如何保证Java线程可见性?-图3
(图片来源网络,侵删)

代码示例:经典的可见性问题

下面是一个没有使用 volatile 导致可见性问题的例子。

public class VisibilityProblem {
    // private boolean flag = false; // 不使用 volatile
    private volatile boolean flag = false; // 使用 volatile 修复
    public void writer() {
        System.out.println("Writer: Setting flag to true...");
        flag = true; // 1. 修改 flag
        System.out.println("Writer: Flag has been set to true.");
    }
    public void reader() {
        System.out.println("Reader: Waiting for flag to become true...");
        while (!flag) { // 2. 一直读取 flag 的值,如果为 false,则循环
            // 空循环,防止 CPU 占用过高
            // 如果不加 volatile,这里的 flag 可能永远读取的是工作内存中的旧值 false
        }
        System.out.println("Reader: Flag is now true. Exiting loop.");
    }
    public static void main(String[] args) throws InterruptedException {
        VisibilityProblem problem = new VisibilityProblem();
        Thread readerThread = new Thread(problem::reader);
        readerThread.start();
        // 让 reader 线程先运行一会儿
        Thread.sleep(1000); 
        Thread writerThread = new Thread(problem::writer);
        writerThread.start();
    }
}

运行分析(不使用 volatile 时):

  • readerThread 启动,进入 while (!flag) 循环,它从主内存读取 flag 的初始值 false 到自己的工作内存。
  • writerThread 启动,执行 flag = true,它在自己的工作内存中将 flag 设为 true,但可能没有立即写回主内存
  • readerThread 一直在循环中,因为它每次读取的都是自己工作内存中的 false,即使 writerThread 已经修改了 flag,它也感知不到。
  • 结果:readerThread 的循环永远不会结束,程序会卡住。

运行分析(使用 volatile 时):

  • writerThread 执行 flag = true,JMM 强制将 true 写回主内存。
  • readerThread 在下一次循环读取 flag 时,JMM 强制它从主内存读取,此时它读到了 true
  • 循环条件 !flag 不再满足,readerThread 退出循环。
  • 结果:程序正常结束。

2 禁止指令重排序

这个特性涉及到有序性

问题背景:指令重排序

为了提高性能,编译器和处理器可能会对输入的代码进行优化,改变语句的执行顺序,这种优化在单线程中是无害的,但在多线程环境下可能会导致意想不到的错误。

volatile 关键字可以禁止指令重排序,它插入了一个内存屏障

内存屏障的作用:

  1. 保证屏障前的所有读写操作,都完成后才能执行屏障后的操作。
  2. 保证屏障后的所有读写操作,都必须在屏障前操作执行完后才能开始。

这确保了 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;
    }
}

为什么必须用 volatile?问题出在 new Singleton() 这句代码上。

它在 JVM 中大致可以分解为三个步骤:

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

如果没有 volatile,由于指令重排序,执行顺序可能会变成 1 -> 3 -> 2。

灾难场景:

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

volatile 如何解决? volatile 会确保 new Singleton() 的三个步骤不会被重排序,必须按 1->2->3 的顺序执行,这样,只有当对象完全初始化后,instance 才会被赋值,从而避免了上述问题。


volatile 的局限性

volatile 虽然强大,但它不是万能的,它不能替代 synchronized

volatile 不具备原子性

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

经典例子:i++ 操作

i++ 这个操作看似简单,实际上在 JVM 中可以分解为三个步骤:

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

这三个步骤不是原子操作,如果多个线程同时执行 i++,就会发生问题。

public class VolatileAtomicity {
    // volatile 只能保证每次读取都是从主内存,写回也是立即写回主内存
    // 但不能保证 i++ 这个复合操作的原子性
    private volatile int count = 0;
    public void increment() {
        count++; // 非原子操作!
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileAtomicity va = new VolatileAtomicity();
        Thread[] threads = new Thread[100];
        for (int i = 0; i < 100; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    va.increment();
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) {
            t.join(); // 等待所有线程执行完毕
        }
        // 预期结果是 100 * 1000 = 100000,但实际结果会小于这个值
        System.out.println("Final count: " + va.count);
    }
}

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


volatile vs. synchronized

特性 volatile synchronized
可见性 保证 保证(释放锁时将变量刷回主内存,获取锁时从主内存读取)
原子性 不保证 保证(被 synchronized 包裹的代码块是原子执行的)
有序性 禁止指令重排序 禁止指令重排序synchronized 同样会禁止临界区内外的重排序)
锁机制 不涉及锁,不会引起线程阻塞 基于锁,可能会引起线程阻塞和上下文切换
使用场景 适用于一个线程写、多个线程读的场景,如状态标志位 适用于多个线程同时读写共享资源的场景,确保线程安全
性能 性能较好,无上下文切换开销 性能相对较差,有锁竞争时的开销

何时使用 volatile

当你需要满足以下所有条件时,可以考虑使用 volatile

  1. 写操作不依赖于当前值flag = true,而不是 count++
  2. 该变量没有包含在具有其他变量的不变式中:即该变量的状态是独立的。
  3. 只有一个线程写,多个线程读:这是 volatile 最经典的应用场景,如状态标志位。

一句话总结:

volatile 保证了变量的可见性和有序性,但不保证原子性,它适用于“一个线程写,多个线程读”的场景,是一种轻量级的同步机制,对于需要原子性的复合操作,请使用 synchronizedjava.util.concurrent.atomic 包。

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