【并发编程】详解volatile

【并发编程】

volatile是什么?它能解决什么问题?它的底层实现原理是什么?和synchronized有啥区别?哪些场景适合用volatile?

一、volatile是什么?

在Java并发编程里,多线程操作共享变量时,经常会出现"线程A改了变量,线程B却看不到"的情况。这时候,volatile就能派上用场------它是Java提供的轻量级同步关键字 ,专门用来保证共享变量的"可见性 ",同时还能禁止"指令重排序"

简单说,当一个变量被volatile修饰后,它就有了两个核心特性:

  1. 线程对变量的修改会立刻同步到主内存,不会只停留在自己的工作内存里;
  2. 其他线程读取这个变量时,会直接从主内存加载最新值,而不是用自己工作内存里的旧数据。

举个例子:没有volatile修饰时,线程1把flag改成true,线程2可能一直读的是自己工作内存里的false,导致循环无法结束;加了volatile后,线程1改完flag会马上同步到主内存,线程2每次读flag都会从主内存拿最新值,能立刻感知到变化。

java 复制代码
// 没有volatile,线程2可能永远看不到flag的变化
private boolean flag = false;

// 有volatile,线程2能实时感知flag的修改
private volatile boolean flag = false;

二、volatile的三大核心特性

1. 可见性

这是volatile最核心的作用。要理解可见性,得先知道Java的"内存模型"(【并发编程】彻底搞懂Java内存模型 JMM:从底层原理到并发问题解决)

  • 每个线程都有自己的"工作内存",操作变量时会先把主内存的变量拷贝到工作内存;
  • 线程修改变量后,会先更新工作内存里的值,再"不定期"同步回主内存;
  • 其他线程读变量时,也会"不定期"从主内存刷新工作内存里的副本。

这种"不定期"就导致了可见性问题。而volatile能强制线程做到两点:

  • 修改变量后,立刻将工作内存的新值刷回主内存
  • 读取变量前,先将工作内存的旧值清空,再从主内存重新加载

这样一来,所有线程操作的都是主内存的"最新值",不会出现"一个改了、一个没看见"的情况。

2. 顺序性

编译器和CPU为了提高性能,会对代码执行顺序做"合理调整"------这就是指令重排序。平时单线程下没问题,但多线程下可能出大错。

比如单例模式的"双重检查锁",没有volatile修饰instance时,可能会出现"对象还没初始化完,就被其他线程拿走用"的情况:

java 复制代码
// 有问题的双重检查锁(缺少volatile)
public class Singleton {
    private static Singleton instance; // 没有volatile
    
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 这里可能被重排序
                }
            }
        }
        return instance;
    }
}

new Singleton()其实分三步:

  1. 分配内存空间;
  2. 初始化对象;
  3. instance指向内存空间。

编译器可能会把步骤2和3重排序成"1→3→2"。这时候线程A执行到instance = new Singleton(),刚做完步骤3(instance不为null),还没做步骤2(对象没初始化);线程B过来判断instance != null,直接拿走没初始化的对象,一用就报错。

而volatile能禁止这种重排序,强制new操作按"1→2→3"的顺序执行,避免上述问题。

3. 不保证原子性

这是volatile最容易被误解的点------很多人以为它能替代synchronized,解决所有并发问题,但其实它管不了"多个线程同时修改变量"的原子性问题

比如两个线程同时执行count++,即使count被volatile修饰,最终结果也可能小于预期值。因为count++不是"一步操作",而是分三步:

  1. 从主内存读取count的最新值;
  2. 在工作内存里把count加1;
  3. 把新值刷回主内存。

这三步中间可能被其他线程打断。比如线程A读了count=10,还没来得及加1,线程B就也读了count=10;之后A加1变成11刷回主内存,B加1也变成11刷回主内存------原本该是12的结果,最终成了11。

所以,要解决原子性问题,还得用synchronizedReentrantLock或者AtomicInteger这类原子类。

三、volatile的底层实现原理

volatile的特性是依赖CPU的"内存屏障"(Memory Barrier)指令实现的。Java虚拟机在处理volatile变量时,会在生成的字节码里插入特定的内存屏障,从而约束编译器和CPU的行为。

1. 内存屏障的作用

内存屏障有两个核心功能:

  • 禁止屏障两侧的指令重排序;
  • 强制将工作内存的缓存数据刷回主内存(写屏障),或强制从主内存重新加载数据(读屏障)。

2. volatile变量的内存屏障插入规则

Java虚拟机会给volatile变量的读写操作加上不同的内存屏障,具体规则如下:

  • 写操作后 :插入"StoreStore屏障"和"StoreLoad屏障"。
    • StoreStore屏障:保证在当前volatile变量写操作之前,所有普通变量的写操作都已经刷回主内存;
    • StoreLoad屏障:保证当前volatile变量的写操作已经刷回主内存后,再执行后续的读操作。
  • 读操作前 :插入"LoadLoad屏障"和"LoadStore屏障"。
    • LoadLoad屏障:保证在读取当前volatile变量之前,先清空工作内存的旧数据,从主内存加载最新值;
    • LoadStore屏障:保证当前volatile变量的读操作完成后,再执行后续普通变量的写操作。

正是这些内存屏障,让volatile实现了"可见性"和"禁止重排序"的特性。

四、volatile和synchronized的核心区别

很多人会把volatile和synchronized(【并发编程】深入理解Synchronized:从并发问题到锁升级的完整解析)搞混,其实它们的定位和能力完全不同,核心区别有4点:

对比维度 volatile synchronized
作用层级 变量级(只修饰变量) 代码块/方法级(修饰代码块或方法)
原子性 不保证 保证(同一时间只有一个线程执行)
可见性 保证(通过内存屏障) 保证(释放锁时刷回主内存)
有序性 保证(禁止重排序) 保证(单线程执行,天然有序)
性能 轻量级,几乎无开销 重量级,有锁竞争时会阻塞线程

简单地说:volatile是"轻量级同步手段",只解决可见性和有序性,适合变量的"单次读/写"场景;synchronized是"重量级锁",能解决原子性、可见性、有序性所有问题,但性能开销更大,适合复杂的同步逻辑。

五、volatile的典型使用场景

volatile不是"万能药",但在某些场景下,它比synchronized更高效,是最佳选择。

1. 状态标志位

这是volatile最常用的场景------用一个volatile变量作为"线程间的信号开关",控制线程的启动、停止或执行逻辑切换。

比如线程A负责循环执行任务,线程B通过修改volatile修饰的stopFlag,让线程A停止循环:

java 复制代码
public class VolatileDemo {
    // 用volatile修饰状态标志位
    private volatile boolean stopFlag = false;

    // 线程A:循环执行任务,直到stopFlag为true
    public void startTask() {
        new Thread(() -> {
            while (!stopFlag) {
                // 执行具体任务
                System.out.println("线程A正在执行任务...");
            }
            System.out.println("线程A停止执行");
        }).start();
    }

    // 线程B:修改stopFlag,让线程A停止
    public void stopTask() {
        stopFlag = true;
    }
}

这里用volatile正好合适:stopFlag的操作是"单次写"(线程B)和"单次读"(线程A),没有原子性问题,用volatile保证可见性即可,性能比synchronized高。

2. 双重检查锁的单例模式

前面提到过,双重检查锁的单例模式里,instance必须用volatile修饰,否则会因为指令重排序出现"对象未初始化完成就被使用"的问题。正确的写法如下:

java 复制代码
public class Singleton {
    // 关键:用volatile修饰instance
    private static volatile Singleton instance;

    private Singleton() {} // 私有构造,防止外部new

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查(无锁,提高效率)
            synchronized (Singleton.class) { // 加锁
                if (instance == null) { // 第二次检查(防止多线程重复创建)
                    instance = new Singleton(); // 禁止重排序
                }
            }
        }
        return instance;
    }
}

3. 轻量级的共享变量传递

当多个线程需要共享一个"单次赋值、多次读取"的变量时,用volatile修饰可以保证所有线程读到的都是最新值。

比如线程A加载配置文件后,把配置对象赋值给volatile修饰的config变量;其他线程读取config时,能立刻拿到最新的配置,不用加锁。

扩展:volatile和AtomicInteger

很多人会问:既然volatile不保证原子性,那AtomicInteger为什么能保证原子性?其实AtomicInteger的底层是"volatile+CAS(Compare And Swap)"的组合。

  • AtomicInteger的value变量被volatile修饰,保证可见性;
  • 它的incrementAndGet()等方法,通过CPU的CAS指令实现"原子性修改"------CAS会先比较value的当前值和预期值,如果一致才修改,不一致就重试,直到成功。

简单说:volatile负责"看见最新值",CAS负责"修改时不被打断",二者结合才实现了原子性。而volatile单独使用时,没有CAS的"比较-修改"逻辑,所以解决不了原子性问题。

相关推荐
小安同学iter19 小时前
天机学堂-排行榜功能-day08(六)
java·redis·微服务·zset·排行榜·unlink·天机学堂
电饭叔19 小时前
利用类来计算点是不是在园内《python语言程序设计》2018版--第8章18题第3部分
开发语言·python
hgz071019 小时前
Spring Boot Starter机制
java·spring boot·后端
daxiang1209220519 小时前
Spring boot服务启动报错 java.lang.StackOverflowError 原因分析
java·spring boot·后端
我家领养了个白胖胖19 小时前
极简集成大模型!Spring AI Alibaba ChatClient 快速上手指南
java·后端·ai编程
jiayong2319 小时前
Markdown编辑完全指南
java·编辑器
heartbeat..19 小时前
深入理解 Redisson:分布式锁原理、特性与生产级应用(Java 版)
java·分布式·线程·redisson·
一代明君Kevin学长19 小时前
快速自定义一个带进度监控的文件资源类
java·前端·后端·python·文件上传·文件服务·文件流
aiopencode19 小时前
上架 iOS 应用到底在做什么?从准备工作到上架的流程
后端