并发编程之volatile深度解析(二)

前言

在上一篇文章中讲到了并发编程的整体知识梳理,这篇文章让我们来看看volatile在并发编程中的使用

前置知识:MESI 缓存一致性协议

CPU 缓存有 4 种状态:M (修改)、E (独占)、S (共享)、I (失效)。

  • 多个线程读取同一个 volatile 变量 → 各自缓存行都是 S(共享)
  • 线程 A 修改变量:
    1. 发送指令,让其他所有 CPU 的该缓存行立刻变成 I(失效)
    2. 线程 A 把修改后的数据写回主内存

一、volatile核心本质

volatile 是 Java 轻量级同步关键字,不保证原子性,只保证可见性 + 禁止指令重排序 ,基于 Java 内存模型(JMM)的内存屏障和 CPU 缓存一致性协议(MESI)实现,性能远高于 synchronized。它的核心作用是解决多线程并发下「变量读写的内存可见性」与「指令重排序导致的逻辑错乱」,但绝对不能替代锁做复合操作同步

二、两大核心特性

1.保证可见性(基于MESI缓存一致性协议)

多线程环境下,变量的存储分为「主内存」(所有线程共享)和「线程工作内存」(每个线程独立的缓存)。普通变量的读写存在一个致命问题:线程修改变量后,数据会先暂存在自己的工作内存中,不会立刻刷新到主内存;其他线程读取时,会直接从自己的工作内存读取,无法获取最新值,导致数据不一致。

volatile 修饰变量后,会强制触发两个规则(依赖 CPU 的 MESI 缓存一致性协议):

写操作:线程修改 volatile 变量后,会立刻将修改后的值刷新到主内存,不滞留于工作内存;

读操作:线程读取 volatile 变量时,会强制从主内存加载最新值,不读取自己工作内存的缓存。

这里先埋下一个关键伏笔:MESI 协议会保证「一个线程修改并写回主内存时,其他线程的对应缓存行会立刻失效」

2.禁止指令重排序(基于内存屏障)

CPU 和编译器为了优化性能,会在不影响单线程执行结果的前提下,打乱代码的实际执行顺序(即指令重排)。单线程下这不会有问题,但多线程下,指令重排会导致线程执行逻辑错乱,比如对象未初始化就被其他线程引用。

volatile 通过在变量读写前后插入「内存屏障」,禁止指令重排:

  • 写屏障:volatile 写操作之后,禁止上方的代码重排到写操作之后;

  • 读屏障:volatile 读操作之前,禁止下方的代码重排到读操作之前。

简单说,volatile 会保证变量的读写顺序,严格按照代码编写的顺序执行,避免多线程下的逻辑错乱。

重点提醒:volatile 最大的短板的是「不保证原子性」。原子性是指「读取-修改-写入」这类复合操作的整体不可分割性,而 volatile 只能保证单次读写的安全性,无法锁住整个复合操作

三、volatile的错误使用

一般都是在复合操作里面导致volatile使用错误,没有达到人们想要的效果

1.根本原因

当使用volatile修饰变量时,多个线程同时修改该变量,虽然说在MESI协议中:一个线程把缓存中的修改写回主内存时,会让其他线程对该变量同样的修改操作立刻失效

大家会以为失效了就不会导致数据覆盖,因为正常逻辑来讲,当其他操作都失效了,等到再重新读,这个时候读的数据就是写回之后的新数据,我就能在这个新值的基础上进行修改

但是!修改操作始终不是原子操作,当我把其他操作都失效了,可是在内存存到磁盘的路上要经过指令队列,不能保证指令队列里面没有对该数据的操作指令,所以,即使我让其他线程对该变量的操作都失效,也会出现在数据覆盖的现象

如图:两个对a的操作指向的小方块就比做指令队列,右边大框指的是磁盘,即使内存(左边大框)中的缓存(五角星右边的长方形)对a的操作已消除,但是指令队列里面还是会存留其他线程对a的操作

2.错误例子

这个错误使用准确来讲是:volatile的写抢占丢失问题,根本原因是因为volatile只能保证"缓存失效,数据实时刷新",但是不能保证其中间过程

利用代码来看一下不正确的使用:

java 复制代码
public class VolatileErrorDemo {
    // volatile修饰计数变量
    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 开启2个线程,各自对count自增10万次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) count++;
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) count++;
        });
        t1.start();
        t2.start();
        t1.join(); // 等待t1执行完毕
        t2.join(); // 等待t2执行完毕
        // 预期结果:200000,实际结果永远小于200000
        System.out.println("最终count值:" + count);
    }
}

3.解决方案

(1)使用总线锁

定义:总线锁是 CPU 提供的一种硬件级锁机制,核心作用是锁住系统总线,让同一时刻只有一个线程能通过总线与主内存进行数据交互,从而强制实现"读-改-写"复合操作的原子性,彻底解决上面提到的即使让其他操作失效依旧存在数据覆盖的问题。

具体原理:当线程执行 volatile 变量的复合操作(如 count++)时,通过 CPU 指令(如 x86 架构的 LOCK 前缀)触发总线锁,此时总线被独占,其他线程的所有内存读写指令都无法通过总线传输,只能等待总线锁释放后才能执行。

这就相当于把"读-改-写"三步操作变成了不可分割的整体,即使多个线程并发执行,也会被强制排队,避免了指令交错和数据覆盖。

弊端:代价特别大,总线式所有CPU与主内存交互的唯一通道,一旦锁住总线,所有线程的内存操作就会被阻塞,即使是与当前volatile无关的操作,会严重降低系统并发性能

(2)追加字节(数据行填充)

核心思路:利用 CPU 缓存行的特性,通过填充数据,让被 volatile 修饰的变量所在的缓存行,只属于当前任务(线程),避免多个线程的变量共享同一个缓存行,间接规避上面提到的问题

注意:数据行填充并不能从根本上解决volatile不保证原子性的问题

四、volatile的正确用法

适用于单次读写、状态标记、禁止指令重排,不能用于复合操作

1.状态标记位(最常用)

场景:单线程修改、多线程读取状态

while 循环是高频读操作,volatile 强制子线程每次都从主内存读取 stop 的值,主线程修改后,通过 MESI 协议让子线程的缓存失效,子线程下次读取时会加载最新值,从而正常停止。

java 复制代码
private static volatile boolean stop = false;

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        // 每次循环都从主内存读取stop的最新值,感知状态变化
        while (!stop) {} 
        System.out.println("线程成功停止");
    }).start();
    Thread.sleep(1000);
    stop = true; // 主线程修改后,立刻刷新到主内存,子线程实时感知
}

2.读多写少的单次赋值

多线程读取同一个变量,只有一个线程(或少量线程)修改变量,且修改操作是"单次赋值",无任何复合运算

五、volatile VS synchronized

将之前讲的synchronized与这篇文章的volatile进行对比,能理解的更加深入:

特性 volatile synchronized
原子性 不保证(仅单次读写原子) 保证(复合操作也安全)
可见性 保证(MESI 协议) 保证(释放锁时刷新主内存)
禁止重排序 保证(内存屏障) 保证(同步块内顺序执行)
性能 轻量级,无锁,不阻塞 重量级,独占锁,会阻塞
适用场景 状态标记、单次读写 复合操作、计数、同步方法/代码块
相关推荐
me8321 小时前
【AI】踩坑LangChain4j集成千问模型:版本适配问题完整解决历程
java·spring·阿里云·ai
来恩10032 小时前
Java Web三大作用域对象
java·开发语言·前端
ゆづき2 小时前
Java 初学者入门指南:常见问题 + 核心知识点 + 进阶 20 道练习题
java·开发语言·学习·算法·水题
_Evan_Yao2 小时前
限流的艺术:令牌桶与滑动窗口的博弈,以及我为何在 AI 项目中选择了后者
java·后端·架构
TheRouter2 小时前
OpenClaw 上下文瘦身:3 个实验
开发语言·python·ai
LIUAWEIO2 小时前
接口 data 满屏反斜杠,怎么展开?
java·开发语言·数据库·json在线解析·data是字符串·json转义·二次json
wjs20242 小时前
MySQL 删除数据表
开发语言
lsx2024062 小时前
Dockerfile详解
开发语言