杰瑞科技汇

java中volatile的作用

volatile 是 Java 提供的一种轻量级的同步机制,它主要用来修饰被多个线程共享的变量。

java中volatile的作用-图1
(图片来源网络,侵删)

当一个变量被声明为 volatile 后,它会确保两点:

  1. 可见性:一个线程对 volatile 变量的修改,会立刻对其他所有线程可见。
  2. 禁止指令重排序:对 volatile 变量的读写操作,会插入内存屏障,禁止其前后的指令进行重排序优化。

volatile 的核心作用就是保证变量的可见性,并一定程度上禁止指令重排序以避免一些并发问题


保证可见性

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

问题背景:Java内存模型与工作内存

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

java中volatile的作用-图2
(图片来源网络,侵删)
  • 主内存:所有线程共享,存储所有实例字段、静态字段和构成数组对象的元素。
  • 工作内存:每个线程私有,是线程对主内存中变量操作的一个缓存,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量。

这个机制导致了可见性问题

  1. 线程A从主内存读取变量X到自己的工作内存。
  2. 线程A修改了X的值,但这个修改只存在于线程A的工作内存中,还未写回主内存。
  3. 线程B也需要读取变量X,它会直接从自己的工作内存(可能为空或旧值)读取,而不会去主内存获取最新的值。
  4. 这样,线程B就永远看不到线程A对X的修改,造成了数据不一致。

volatile 如何解决可见性问题

当一个变量被 volatile 修饰后,JVM 和编译器会做以下特殊处理:

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

通过这种“强制刷新”和“强制重读”的机制,volatile 变量的修改对所有线程都是立即可见的。

经典案例:状态标记位

这是 volatile 最常见的应用场景之一。

java中volatile的作用-图3
(图片来源网络,侵删)
class Worker implements Runnable {
    // 使用 volatile 修饰,确保 stopFlag 的状态对所有线程立即可见
    private volatile boolean stopFlag = false;
    public void stop() {
        this.stopFlag = true;
    }
    @Override
    public void run() {
        int i = 0;
        // 循环条件依赖于 stopFlag
        while (!stopFlag) {
            // 模拟工作
            i++;
            // do something...
        }
        System.out.println("Worker stopped.");
    }
}
public class VolatileExample {
    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker();
        new Thread(worker).start();
        // 主线程睡眠1秒后,尝试停止工作线程
        Thread.sleep(1000);
        System.out.println("Main thread is asking to stop the worker...");
        worker.stop();
        System.out.println("Main thread has asked to stop.");
    }
}

如果没有 volatile 会发生什么? while (!stopFlag) 这行代码可能会被编译器或 JVM 优化,将 stopFlag 的值从主内存读取到工作内存后,循环就不再去主内存检查,而是直接使用工作内存中的值,这样,即使主线程调用了 worker.stop() 将主内存中的 stopFlag 设为 true,工作线程因为看不到这个变化,会陷入死循环。

有了 volatile 之后: 当主线程执行 worker.stop()stopFlag 设为 true 并刷新到主内存后,工作线程在下一次循环读取 stopFlag 时,必须从主内存读取最新的值 true,从而能够正确地退出循环。


禁止指令重排序

volatile 的第二个作用是提供内存屏障,从而禁止指令重排序,这在双重检查锁定实现的单例模式中至关重要。

问题背景:指令重排序

为了提高性能,编译器和处理器可能会对输入的代码进行优化,改变指令的执行顺序,只要最终结果在单线程中是一致的,这在多线程环境下会引发灾难。

volatile 如何禁止重排序

当对一个 volatile 变量进行写操作时,JVM 会在其前后插入一个内存屏障,内存屏障可以禁止其前后的指令进行重排序优化,并保证屏障前的写操作对屏障后的读操作可见。

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

public class Singleton {
    // 1. 必须使用 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 = new Singleton(); 并非一个原子操作

在JVM中,这行代码大致可以分解为三个步骤:

  1. 分配内存:为 Singleton 对象分配一块内存空间。
  2. 初始化对象:调用 Singleton 的构造函数,填充对象的各个字段。
  3. 建立引用:将 instance 引用指向分配好的内存地址。

在单线程中,顺序是 1 -> 2 -> 3,但在多线程环境下,由于指令重排序,执行的顺序可能变成 1 -> 3 -> 2。

灾难场景:

  1. 线程A进入同步代码块,执行 instance = new Singleton();
  2. 由于指令重排序,JVM 先执行了步骤1(分配内存)和步骤3(建立引用),但步骤2(初始化对象)还没执行。
  3. instance 已经不为 null,但它指向的是一个未初始化完成的对象
  4. 就在这时,线程B调用 getInstance() 方法。
  5. 线程B执行第一次检查 if (instance == null),发现 instance 不为 null
  6. 线程B直接返回 instance
  7. 结果:线程B得到了一个尚未初始化完成的 Singleton 对象,如果此时去访问其任何字段,都可能导致 NullPointerException 或其他不可预期的错误。

volatile 如何解决: 通过在 instance 前加上 volatile 关键字,可以确保:

  • 分配内存、初始化对象、建立引用这三个步骤的顺序不会被重排序,必须严格按照 1 -> 2 -> 3 的顺序执行。
  • 当线程A完成赋值后,线程B能立刻看到这个已经完全初始化好的对象。

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

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

class Counter {
    // volatile 只能保证每次读取到的是最新值,但不能保证 i++ 的原子性
    private volatile int i = 0;
    public void increase() {
        i++; // 这不是一个原子操作
    }
}

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

  1. 读取 i 的值。
  2. i 的值加1。
  3. 将新值写回 i

即使 ivolatile 的,也只能保证步骤1读取到的是最新值,步骤3写回后其他线程能立刻看到,但在多线程环境下,线程A可能在执行完步骤1后,被线程B抢占,线程B也执行了完整的三个步骤,然后线程A再继续执行步骤2和3,最终结果会比预期的值小。

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


volatile vs. synchronized

特性 volatile synchronized
作用范围 只能修饰变量 可以修饰方法、代码块
原子性 不保证 保证(被同步的代码块/方法是原子的)
可见性 保证 保证(线程解锁前必须把变量刷新回主内存,加锁时必须清空工作内存)
有序性 禁止指令重排序 禁止指令重排序(由happens-before原则保证)
性能 较高 较低(涉及用户态和内核态的切换、锁的获取和释放)
本质 轻量级同步机制,基于内存屏障 重量级锁机制,基于操作系统层面的互斥锁

volatile 是 Java 并发编程中的一个重要工具,它像一把“瑞士军刀”,轻便且有效。

  • 使用场景

    1. 状态标记位:如上面的 stopFlag,一个线程写,多个线程读。
    2. 双重检查锁定:确保单例对象的完整初始化过程不会被重排序破坏。
    3. 一次性安全发布:当需要将一个构造完毕的对象引用发布给其他线程时,可以对该引用使用 volatile
  • 核心价值:在不需要锁的情况下,解决了可见性和有序性问题,性能开销远小于 synchronized

  • 核心限制不保证原子性,当需要对一个变量执行“读取-修改-写入”等复合操作时,volatile 无能为力,必须求助于 synchronizedjava.util.concurrent.atomic 包。

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