volatile 详解

# volatile 详解

# 1、volatile 关键字的作用

保证变量的可见性和有序性。 那原子性呢?最后再说

# 2、volatile 关键字保证可见性的原理

依赖处理器的lock前缀指令和处理器的缓存一致性协议。

# 先看个可见性问题的例子:

import java.util.concurrent.TimeUnit;

public class TestA {
    private static boolean flag = false;

    public static void main(String[] args) {

        Thread t = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
        });

        t.start();

        int i = 0;
        while (!flag) {
            i++;
            if (flag) {
                break;
            }
        }
        System.out.println("循环了" + i + "次");
    }
}

运行结果:
会出现死循环。
因为共享变量flag没有使用 volatile关键字修饰,所以没办法保证flag变量的可见性,main线程可能无法读取到t线程对flag共享变量的最新修改结果,导致死循环。
如果我们使用 volatile关键字修饰flag变量,则程序可以正常结束循环,并打印出循环次数。

# 从程序运行结果分析volatile的作用:

我们可以这么理解,当一个变量被volatile关键字修饰后,一个线程修改了自己工作内存中的该共享变量后,会立即被更新到主内存中, 同时其他线程的工作内存存储的该变量副本变为失效状态,当其他线程再次读取或者修改该共享变量时,会直接从主内存中读取最新的值。

# 从底层实现分析:

下面会涉及处理器的一些简单知识点,有这些作为铺垫更方便理解volatile的底层原理。

# 先思考在处理器(CPU)层面实现变量的可见性,有哪些方式?

主要有下面几种方式:

# ①、缓存一致性协议(MESI)

我们电脑上的处理器大都是多核处理器,可以使用缓存一致性协议(如 MESI 协议)来确保多个处理器核心的缓存数据一致。通过缓存行状态的管理和消息传递机制,确保一个核心对共享变量的更新可以被其他核心及时看到。

介绍下缓存行
缓存行(Cache Line)是 CPU 缓存中的一个基本单位,用于存储从主内存加载的数据块。缓存行的大小通常为 32 字节、64 字节或 128 字节,这取决于具体的处理器架构(大多数现代 x86-64 架构处理器,如 Intel 和 AMD 的大部分64位处理器,都使用 64 字节的缓存行大小)。

当 CPU 需要访问内存中的某个数据时,它会将包含该数据的整个缓存行加载到缓存中。缓存行不仅包含目标数据,还包含其周围的一些数据。这种批量加载的方式有助于提高缓存命中率,因为程序中的数据访问通常具有局部性,即连续的内存地址更有可能被连续访问。

在多核处理器中,每个核心都有自己的 L1 和 L2 缓存,并共享一个 L3 缓存。缓存一致性协议确保多个核心之间的数据一致性。例如,当一个核心修改了某个缓存行的数据时,协议会确保其他核心中相应的缓存行无效,以便它们下次访问时从主内存或共享缓存中获取最新的数据。(这就能和上面说的同时其他线程的工作内存存储的该变量副本变为失效状态对应起来)

L1、L2、L3三级缓存设计的作用? L1缓存是最接近CPU核心的,具有最快的访问速度,但容量相对较小。L2缓存位于L1缓存之外,容量更大,访问速度略慢于L1,但仍比主内存快得多。L1和L2缓存都是每个核心私有的,这意味着它们只服务于其对应的核心。
L3(三级)缓存则是共享的,所有核心都可以访问。L3缓存的容量比L1和L2都要大,但访问速度慢于这两者。L3缓存的存在是为了进一步减少主内存访问的需求,并充当不同核心之间的数据共享区域,特别是在多线程或多任务环境中,当多个核心可能需要访问相同数据集的情况。

再介绍下MESI 协议 MESI 协议是最常见的缓存一致性协议之一,包含四种缓存行状态:
Modified(修改态):缓存行的数据已被修改,且该数据只存在于当前缓存中,其他缓存中没有该数据。 Exclusive(独占态):缓存行的数据是最新的,但还没有被修改,且该数据只存在于当前缓存中。 Shared(共享态):缓存行的数据没有被修改,可以存在于多个缓存中。 Invalid(无效态):缓存行的数据无效。 当一个核心写入一个缓存行时,会将其他核心中相同的缓存行状态设为无效(Invalid)。这样,其他核心在访问该数据时必须从主内存中重新读取最新的数据。

# ②、CPU内存屏障指令

先理解下Store和Load,可以把Store认为是保存或者写的含义,把Load认为是加载或者读的含义。

内存屏障类型名称 指令名称 描述 示例
读屏障 lfence Load Fence,防止读操作的重排序。确保在它之后的所有读操作都在它之前的读操作完成后执行。 在读取敏感数据前,确保所有先前的读操作已完成。
写屏障 sfence Store Fence,确保所有先前的写操作都已在它之前完成,并且不会被重新排序到该指令之后。 在关键数据写入后,确保所有先前的写操作已完成并反映在内存中。
全屏障 mfence Memory Fence,防止读/写的重排序。确保在它之后的所有读/写操作都在它之前的读/写操作完成后执行。 在并发操作中,确保所有内存在它之前的操作按顺序执行,常用于多线程同步。

在 Java 中,volatile 关键字会在读和写操作前后插入相应的内存屏障(JVM层面实现和上面三个指令没啥关系),以确保内存操作的顺序和可见性(稍后会再详细分析)。

# ③、带lock 前缀的指令

处理器提供的 lock 前缀可以用于一些指令(比如 add),确保这些指令在多处理器系统中的原子性和可见性。

我们在JIT编译器编译后的汇编代码中可以找到这种带 lock 前缀的指令。

先下载hsdis工具,下载地址。https://chriswhocodes.com/hsdis/ (opens new window)
我是Window 64位系统就下载 红框里这个

mixureSecure

IDEA中添加下面的JVM启动参数:
参数如下

-server -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation

运行代码(注意Java代码中变量flag加了volatile), 在IDEA的日志打印界面看到,JVM打印出由即时编译器(JIT Compiler)生成的本地机器码(assembly code)。我运行Java程序的机器是64位Windows系统,所以这里生成的本地机器码是指 x86-64架构下的汇编代码。

补充小知识点: x86: x86 是一系列基于英特尔架构(Intel Architecture,IA)的计算机芯片指令集架构的总称,最初由英特尔公司在 1978 年推出的 16 位微处理器 8086 开始。

x86-64: x86-64,也称为 AMD64 或 x64,是一种由 AMD 设计的 64 位微处理器架构。

下图是利用 hsdis工具,配合JVM启动命令来打印的JIT编译器编译后的本地机器码(assembly code)。

mixureSecure

可以看到这里面就有用到lock前缀的汇编指令。
这里先对 lock addl $0x0,(%rsp)留个印象,后面会讲到这个指令。

# ④、总线锁定

总线锁定是一种通过锁定系统总线来确保内存操作原子性的技术。当一个处理器执行一个涉及总线锁定的操作时,其他处理器无法访问内存。这种方法效率较低,因为它会阻止其他处理器的全部访问内存的操作。 总线锁定通常用于需要高度同步的场合,例如在执行复杂的内存事务或硬件初始化过程中,但它可能导致显著的性能下降,尤其是在高并发的多处理器系统中。

# 区分JVM实现的内存屏障方法和CPU的内存屏障指令

我们需要先明确一点,无论使用什么编译型的编程语言,最终所有的代码都是在处理器(CPU)上运行的,并且需要经过以下步骤:

1.源代码 -> 2. 编译 -> 3. 汇编代码 -> 4. 二进制机器码 -> 5. 处理器执行 每一步都是将代码转换为更接近处理器能够直接理解和执行的形式,最终实现程序的功能。

JVM内存屏障方法依赖于CPU的内存屏障指令来最终实现底层的内存操作顺序控制和可见性保证。

# JVM内存屏障方法的实现

先下载OpenJDK的jdk8u版本的 hotspot vm 源码看一下: (亲自去找一下JVM对于内存屏障的实现源码,方便下面区分JVM层面和CPU指令层面的内存屏障)
OpenJDK的jdk8u版本的 hotspot源码下载地址:https://hg.openjdk.org/jdk8u/jdk8u/hotspot/archive/tip.zip (opens new window) 可以直接使用IDEA打开去查看源码。
我这里为了和自己电脑的CPU架构对应起来,直接去看的windows_x86 的 orderAccess_windows_x86.inline.hpp文件,因为我是用Windows系统运行的上面Java代码。

C++小知识点:
.hpp 和 .cpp 是C++编程中常见的文件扩展名,它们分别代表了头文件(Header File)和源文件(Source File)。

.hpp:头文件通常包含类的声明、函数原型、常量和宏定义等。这些文件被其他源文件通过 #include 指令引用,使得在编译时可以访问其中定义的类型和函数。使用头文件可以避免代码重复,并且方便在多个地方共享相同的代码定义。

.cpp:源文件包含了具体的函数实现和代码逻辑,并可以引入.hpp文件。 例如:unsafe.cpp 就引入了 orderAccess_windows_x86.inline.hpp

mixureSecure

我们看下 orderAccess_windows_x86.inline.hpp里面对于内存屏障的实现:

mixureSecure

可以看到源码中有这么几行:

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

稍微解释下源码的含义:

    // acquire() 方法在非 AMD64 平台上插入一个对 esp 的读操作,
    // 以阻止编译器和 CPU 在其前后的内存读操作之间进行重排序。(具体是怎么防止编译器重排序的还不太清楚)
    inline void acquire() {
    #ifndef AMD64
        __asm {
            mov eax, dword ptr [esp]; // 读 esp 的值到 eax,创建一个读屏障。
        }
    #endif // !AMD64
    }

    // release() 方法通过一个C++代码的 volatile 存储操作,确保其前面的写操作
    // 不会被重排序到其后面。
    inline void release() {
        // 使用 C++ 的 volatile变量 存储操作提供 release 语义。
        volatile jint local_dummy = 0; // 一个无实际赋值作用的 volatile 变量存储操作,但是尤其独特的其他作用。
    }

    // fence() 方法实现了一个全内存屏障,确保在屏障前的所有写操作完成后
    // 再执行屏障后的读操作。
    inline void fence() {
    #ifdef AMD64
        // 对于 AMD64 平台,调用平台特定的内存屏障函数。
        StubRoutines_fence();
    #else
        // 对于非 AMD64 平台,如果系统是多处理器系统,
        // 使用内联汇编的 lock 指令提供全内存屏障。
        if (os::is_MP()) {
            __asm {
                lock add dword ptr [esp], 0; // 使用 lock 前缀的 add 指令创建一个全内存屏障。
            }
        }
    #endif // AMD64
    }

// 函数实现解释:

// acquire() 函数在非 AMD64 平台上使用内联汇编,通过读取 esp 寄存器的值
// 到 eax 寄存器,防止编译器和 CPU 对前后的内存读操作进行重排序。
// 这种方式通常被称为读屏障。

// release() 函数通过一个C++语言的volatile变量的存储操作,防止编译器和 CPU 对前后的
// 内存写操作进行重排序。这种方式通常被称为写屏障。C++的 volatile 关键字确保了
// 编译器不会优化掉这个存储操作,提供了必要的顺序保证。

// fence() 函数通过平台特定的方法提供全内存屏障。在 AMD64 平台上,调用
// StubRoutines_fence() 函数(该函数是平台特定的内存屏障实现,从我打印的汇编指令来看x86-64平台是基于lock前缀指令实现的内存屏障)。
// 在非 AMD64 平台上,如果系统是多处理器系统(os::is_MP() 返回 true),
// 使用 lock 前缀的 add 指令创建一个全内存屏障,防止 CPU 对前后的内存操作进行重排序。
// 全内存屏障确保屏障前的所有存储操作在屏障后的读取操作之前完成。

内联汇编: 内联汇编是一种在高级语言(如C/C++)中直接嵌入汇编代码的技术。

补充知识点 C++ 语言特性: 在 C++ 中,也有volatile 关键字,volatile告诉编译器不要对这个变量进行任何优化。
对于 volatile jint local_dummy = 0;,编译器会确保实际生成一条写入指令,将 0 写入 local_dummy。
尽管 local_dummy 变量本身没有实际作用,但通过对它的写操作,可以创建一个 "release" 屏障,防止编译器对前后的内存操作进行重排序。(这涉及到下面要说的有序性原理,现在只讨论可见性,先不展开讨论有序性)

# 铺垫:Java语言是编译和解释共存的语言

Java语言的执行模型开始时对源代码进行编译(这里是指使用javac进行编译生成与平台无关的.class字节码文件),然后在运行时通过JVM解释执行字节码。但是,通过JIT(即时编译器Just-In-Time Compiler)的介入,它能够动态地将部分热点字节码转换为机器码(特定平台架构下的汇编程序)并缓存起来,从而获得接近于编译型语言的性能。

javac编译阶段: Java源代码(.java 文件)首先被Java编译器(如 javac)编译成字节码(.class 文件)。字节码是一种中间代码,它不是针对任何特定硬件平台的机器代码,而是为Java虚拟机(JVM)设计的。这一阶段类似于编译型语言,因为代码在运行之前就被转换成了另一种形式,而不是直接解释执行(只不过这里编译后的是字节码面对的是JVM)。

解释阶段:
生成的字节码随后由JVM加载和执行。早期的JVM完全以解释方式执行字节码,即逐条读取字节码指令并立即执行。这种方式类似于解释型语言,因为它不需要在执行前生成最终的机器代码。

即时编译(JIT): 随着时间的推移,JVM引入了一项重要技术——即时编译器(Just-In-Time Compiler)。JIT编译器监控程序运行,并识别出那些频繁执行的“热点”代码段。当检测到热点代码时,JIT编译器会将相应的字节码编译成本地机器代码(上面的例子就是编译成了x86-64平台架构的汇编代码),这大大提高了执行效率。编译后的代码被缓存,以便在后续调用中直接使用,避免了重复编译。这种方式结合了编译型语言的性能优势,因为代码在运行时被优化和转换为高效的机器码(汇编)。

# 搞清楚内存屏障(Memory Barriers)这个概念的双层含义

通过上面的铺垫再继续分析,内存屏障(Memory Barriers)。

第一层:编译器层面 在编译阶段,编译器(对于Java来说,例如:JIT编译器)会尝试优化代码,这可能包括重新安排指令的顺序以便更高效地利用处理器资源。然而,某些操作的顺序对于程序的正确运行至关重要,特别是当涉及到多线程或多处理器环境下的共享内存访问时。

所以我们讨论的内存屏障第一层含义有编译器屏障的意思。 编译屏障(Compiler Barrier) 是一种指示给编译器的指令,告诉它不要在屏障之前的指令与之后的指令之间进行任何优化或重排序。这样可以确保编译器生成的汇编代码保持程序员期望的执行顺序,从而维护内存操作的顺序性。

第二层:CPU执行指令层面 在硬件层面上,CPU为了提高效率,可能会采用乱序执行。这意味着处理器可能不会按照指令的原始顺序执行它们,而是基于资源可用性、依赖性分析等策略来动态调整执行顺序。此外,处理器还有可能使用缓存来进一步优化内存访问。

所以内存屏障第二层含义有硬件内存屏障。
硬件内存屏障或者叫内存栅栏(Memory Fence)或者叫CPU屏障(随便你怎么叫,只要理解这是CPU硬件层面的屏障即可), 是CPU提供的一种特殊指令(上面第二点②、CPU内存屏障指令 有介绍),它强制处理器在执行屏障后的指令前完成所有屏障前的内存操作。这确保了指令之间的内存访问顺序,并且使得所有处理器核心能够看到一致的内存状态(通过MESI协议和Snoopying机制,下面会介绍),这对于维持并发程序的正确性非常关键。

# 总结:

经过上面那么多的铺垫,我们再来总结下,整体思路就非常清晰了。

JVM中通过下面这四个函数loadload() 、storestore()、loadstore()、storeload() 来实现编译器屏障和 CPU指令(硬件)屏障

# JVM内存屏障方法

函数 作用 备注
loadload() 确保后续的读操作不会被重排序到前面的读操作之前 调用 acquire() 实现
storestore() 确保后续的写操作不会被重排序到前面的写操作之前 调用 release() 实现
loadstore() 确保后续的写操作不会被重排序到前面的读操作之前 调用 acquire() 实现
storeload() 确保后续的读操作不会被重排序到前面的写操作之前,并保证内存屏障 调用 fence() 实现

我们暂时不讨论有序性。 对于可见性主要看上面对于 storeload()源码的分析。

# CPU内存屏障指令

上面已经介绍过了,这里拿过来方便阅读:
先理解下Store和Load,可以把Store认为是保存或者写的含义,把Load认为是加载或者读的含义。

内存屏障类型名称 指令名称 描述 示例
读屏障 lfence Load Fence,防止读操作的重排序。确保在它之后的所有读操作都在它之前的读操作完成后执行。 读敏感数据前确保所有先前的读操作已完成。
写屏障 sfence Store Fence,防止写操作的重排序。确保在它之后的所有写入数据后确保所有先前的写操作已完成。 所有先前的写操作都已在它之前完成。
全屏障 mfence Memory Fence,防止读/写的重排序。确保在它之后的所有读/写操作都在它之前的读/写操作完成后执行。 确保所有内存在它之前的操作顺序。

实现CPU内存屏障指令的效果还有一个特殊的方式,那就是 前面提到的 lock 前缀指令。

JVM的4个内存屏障方法loadload() 、storestore()、loadstore()、storeload() , 并不是单独作用于编译器的指令确保编译器生成的代码保持程序员期望的执行顺序。这四个方法同样能够确保硬件层面上CPU指令之间的内存访问顺序。(这几个方法如何确保编译器有序性的底层机制还是不太清楚,可能需要再研究研究JIT编译器的细节,这里就不细说了,因为我不太了解JIT)

在上面知识铺垫的过程中,通过查看汇编代码和JVM的源码发现 JVM在处理 volatile 关键字的多线程写,最终生成的汇编代码是通过 lock前缀指令来实现的。 对应JVM源码 中storeload()方法 ,对应的汇编代码指令 lock addl $0x0,(%rsp)

lock addl $0x0,(%rsp)指令的作用:
这条指令在 x86-64 架构的处理器中,虽然从表面上看,addl $0x0, (%rsp)只是将0加到%rsp指向的内存位置,并不会改变内存中的值,但由于addl操作涉及写入内存,所以这个操作仍然会触发内存访问和缓存一致性操作。 同时 lock 前缀确保了在多处理器系统中,当一个处理器正在执行这个指令时,其他处理器不能访问被锁定的内存位置(最小粒度锁的是缓存行),从而保证了操作的原子性。
并且CPU还会利用缓存一致性协议和Snoopying(嗅探)机制来实现缓存数据的一致性。

这种lock前缀指令用法常见于以下几种场景: 内存屏障(Memory Barrier):确保在此指令之前的所有内存读写操作在所有处理器看来已经完成。相当于一个全局的内存屏障。(涉及有序性后面再说)
刷新缓存:迫使处理器刷新其缓存,确保其他处理器看到最新的数据。(就是现在说的可见性)
实现锁机制:在一些低级别的同步原语实现中,用于确保内存访问的原子性。(涉及到原子性后面再说)

那为什么不用 lfence、sfence、mfence指令呢?
想一想,可能是因为 lock 前缀指令在提供必要的内存屏障(防止重排序)和实现缓存一致性方面(可见性)具有一些独特的优势。(经典废话文学。。。哈哈)

# lock 前缀指令的优势

lock前缀不是一种内存屏障,但它能完成类似内存屏障的功能。

统一的全内存屏障:
lock 前缀指令提供了一个统一的全内存屏障,既可以防止读操作和写操作的重排序,又能确保缓存一致性。 例如,lock add dword ptr [esp], 0 既会阻止指令重排序,又会确保所有处理器缓存中对于相应内存位置的修改是可见的。

缓存一致性:
lock 前缀指令通过使用总线锁定缓存锁定来确保在多处理器系统中缓存的一致性。这点需要再展开讲一下。

总线锁定: 在比较老的处理器中,或者当内存区域不在缓存中时,lock 前缀会触发总线锁定。此时,处理器通过拉起其 #LOCK 引脚电位来锁定总线,确保在指令执行期间其他处理器无法访问这段内存。但是这种方式会导致其他处理器(其他的CPU内核)无法访问内存,总线利用率低,影响系统性能。

缓存锁定:从 P6 架构(P6 是英特尔于1995年推出的一种处理器架构,首个使用该架构的处理器是 Pentium Pro)开始,如果指令访问的内存区域已经存在于处理器的内部缓存(CPU Cache)中,则 lock 前缀不会引起总线锁定,而是锁定本处理器的内部缓存。缓存一致性协议(如 MESI 协议)会确保其他处理器的缓存中相应的缓存行失效(invalidate),从而保持缓存一致性。

在现代 x86或者x86-64 架构处理器中,如 Intel Core 系列和 AMD Ryzen 系列,lock 前缀指令的实现如下: Intel Core 系列:
在 Core 微架构(如 Nehalem、Sandy Bridge、Skylake 等)中,lock 前缀指令通过锁定缓存行(仅锁定缓存行那一小块内存区域)并利用缓存一致性协议(如 MESI)来确保原子操作。 AMD Ryzen 系列:
在 AMD 的 Zen 微架构中,lock 前缀指令也主要通过锁定缓存行来实现,利用缓存一致性协议(如 MOESI)来确保数据的一致性和原子性。

关于CPU缓存锁定后的内存一致性实现机制再简单解释下:
Snoopying(嗅探机制): Snoopying 是一种基于总线的缓存一致性维护机制,其中每个处理器的缓存控制器snoop(监视)总线上发生的内存操作,以确保缓存一致性。
每个处理器的缓存控制器会监视总线上的内存请求。如果一个处理器请求访问一个内存地址,其他处理器的缓存控制器会检查其缓存是否包含该地址的最新副本,并作出相应的响应。

Snoopying 和 MESI 一起工作:在多处理器系统中,Snoopying 机制负责监视总线上所有的内存操作。当某个处理器发出读或写请求时,其他处理器会检测到这个请求并作出响应。MESI 协议则通过定义缓存行的四种状态(修改,独占,共享,无效),管理这些响应如何进行,确保缓存行的状态转换符合一致性要求。

举个栗子:
画个动画演示下比较好理解

mixureSecure

最后的状态再截个图吧,防止太快了需要眨眼补帧

mixureSecure

对于另外三个CPU内存屏障指令lfence、sfence、mfence 。我觉得都不如 lock前缀指令来的直接,并且现代处理器会利用缓存锁定的模式来提升性能。同时缓存锁定不仅仅是保证了可见性。(目前一直在讨论的可见性) 缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,所以也能够保证对锁定内存区域修改的原子性。(原子性最后再说) lock前缀指令还提供了一个统一的全内存屏障,可以防止读操作和写操作的重排序。(有序性下面再说)
所以lock前缀指令等于是个"全能型选手"。

# 最后再整体总结下volatile保证可见性的底层实现

当一个变量被volatile关键字修饰时,JVM会在适当的位置插入内存屏障指令(通过loadload() 、storestore()、loadstore()、storeload() 方法),并依赖于特定的处理器指令(lock前缀指令)和处理器的缓存一致性协议来保证可见性。

具体流程如下:
写 volatile 变量:
JVM插入storeload()屏障 (这里不讨论有序性),storeload()会调用 fence() 向最终生成的汇编代码中插入
lock addl $0x0,(%rsp) 指令,这个指令保证volatile 变量的最新值被刷新到主内存。

更新 volatile 变量:
缓存一致性协议和Snoopying机制使得其他CPU核心的缓存中相应的缓存行无效 。

读 volatile 变量:
缓存一致性协议确保任何后续的读操作或更新操作,获取缓存中相应的缓存行无效时都从主内存中获取最新的值。

# 3、volatile 关键字保证有序性的原理

一定要把上面的知识点都看了,再来看这个。因为前面有大量的铺垫帮助理解。

# volatile 关键字保证有序性的两个方面

①、保证编译器生成的汇编代码的有序性
编译器在生成汇编代码时,通常会进行各种优化,包括生成汇编代码的时候把指令序列重排,以提高执行效率。然而,这些优化在多线程环境中可能会导致数据不一致性。volatile关键字通过告诉编译器,对volatile变量的访问不能被优化或重排,让编译器生成程序员期望的有序代码,从而保证访问的顺序性。

②、保证CPU执行指令的有序性 即使编译器生成了有序的汇编代码,CPU在执行这些指令时也可能进行重排,以优化执行效率。volatile关键字在一定程度上通过内存屏障来保证CPU执行指令的有序性。

# 先看编译器重排序规则

JMM针对编译器制定的volatile重排序规则: 下面表格内容来源于书籍:《并发编程的艺术》
这里重新制作了一下。
mixureSecure

JMM针对编译器制定的volatile重排序规则约束还是挺多的 只有第一个操作是普通读写,第二个操作也是普通读写。
第一个操作是普通读写,第二个操作是volatile读。这两种情况才允许编译器重排序这两个操作。
不过要明确,这两种情况也不是一定就会被编译器重排序。

在上一篇文章JMM(Java内存模型)详解 (opens new window)中有说过什么情况下可能发生重排序。

JMM内存屏障插入策略,这就能接上上面提到的 JVM内存屏障方法的实现了。

在每个volatile写操作的前面插入一个StoreStore屏障,对应JVM源码中的storestore()方法。
在每个volatile写操作的后面插入一个StoreLoad屏障,对应JVM源码中的storeload()方法。
在每个volatile读操作的后面插入一个LoadLoad屏障,对应JVM源码中的loadload()方法。
在每个volatile读操作的后面插入一个LoadStore屏障,对应JVM源码中的loadstore()方法。

把上面内容再拿下来一份:

函数 作用 备注
loadload() 确保后续的读操作不会被重排序到前面的读操作之前 调用 acquire() 实现
storestore() 确保后续的写操作不会被重排序到前面的写操作之前 调用 release() 实现
loadstore() 确保后续的写操作不会被重排序到前面的读操作之前 调用 acquire() 实现
storeload() 确保后续的读操作不会被重排序到前面的写操作之前,并保证内存屏障 调用 fence() 实现

上述内存屏障插入策略可以保证在任意的Java程序和任意的处理器平台都能得到正确的volatile作用。

下面是插入内存屏障后生成的指令序列示意图: 原图内容来源于书籍:《并发编程的艺术》
这里只是重做了一下图片。

mixureSecure

实际上编译器可以根据具体情况省略不必要的屏障但是前提是必须保证JMM对于volatile关键字作用的定义。

比如出现连续两个volatile写,那么编译器在生成汇编代码时,只会在第一个volatile写下面插入StoreStore屏障,在第二个volatile写后面插入StoreLoad屏障,这样能够保证JMM对于volatile关键字作用的定义,并且能够最小化插入屏障的数量来提高性能。

# x86架构处理器的指令重排序规则

x86处理器(这里包含了x86/x86-64),JVM源码注释中x86-64使用的名称是AMD64实际上就是x86-64, 仅会对写-读操作做重排序。x86不会对读-读、读-写和写-写操作做重排序。
所以JVM在实现loadload() 、storestore()、loadstore()、storeload() 方法时会针对特定处理器架构有不同的处理。
这里再把上面的JVM orderAccess_windows_x86.inline.hpp文件的部分源码拿下来:

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

稍微解释下源码的含义:

    // acquire() 方法在非 AMD64 平台上插入一个对 esp 的读操作,
    // 以阻止编译器和 CPU 在其前后的内存读操作之间进行重排序。
    inline void acquire() {
    #ifndef AMD64
        __asm {
            mov eax, dword ptr [esp]; // 读 esp 的值到 eax,创建一个读屏障。
        }
    #endif // !AMD64
    }

    // release() 方法通过一个C++代码的 volatile 存储操作,确保其前面的写操作
    // 不会被重排序到其后面。
    inline void release() {
        // 使用 volatile 存储操作提供 release 语义。
        volatile jint local_dummy = 0; // 一个无实际赋值作用的 volatile 变量存储操作。
    }

    // fence() 方法实现了一个全内存屏障,确保在屏障前的所有写操作完成后
    // 再执行屏障后的读操作。
    inline void fence() {
    #ifdef AMD64
        // 对于 AMD64 平台,调用平台特定的内存屏障函数。
        StubRoutines_fence();
    #else
        // 对于非 AMD64 平台,如果系统是多处理器系统,
        // 使用内联汇编的 lock 指令提供全内存屏障。
        if (os::is_MP()) {
            __asm {
                lock add dword ptr [esp], 0; // 使用 lock 前缀的 add 指令创建一个全内存屏障。
            }
        }
    #endif // AMD64
    }

// 函数实现解释:

// acquire() 函数在非 AMD64 平台上使用内联汇编,通过读取 esp 寄存器的值
// 到 eax 寄存器,防止编译器和 CPU 对前后的内存读操作进行重排序。
// 这种方式通常被称为读屏障。

// release() 函数通过一个C++语言的volatile变量的存储操作,防止编译器和 CPU 对前后的
// 内存写操作进行重排序。这种方式通常被称为写屏障。C++的 volatile 关键字确保了
// 编译器不会优化掉这个存储操作,提供了必要的顺序保证。

// fence() 函数通过平台特定的方法提供全内存屏障。在 AMD64 平台上,调用
// StubRoutines_fence() 函数(该函数是平台特定的内存屏障实现)。
// 在非 AMD64 平台上,如果系统是多处理器系统(os::is_MP() 返回 true),
// 使用 lock 前缀的 add 指令创建一个全内存屏障,防止 CPU 对前后的内存操作进行重排序。
// 全内存屏障确保屏障前的所有存储操作在屏障后的读取操作之前完成。

可以看到,loadload()和loadstore()调用的是acquire()方法,这个方法中在ADM64平台并没做任何操作。
因为ADM64(x86-64)处理器平台的 读-读、读-写在处理器执行指令时并不会重排序。

处理器的重排序规则(N:表示处理器不允许两个操作重排序 Y: 表示允许两个操作重排序) 表格内容来源于书籍《并发编程的艺术》
这里重新做了一下表格样式。

mixureSecure

补充知识点:
编译器屏障:
在 C/C++ 中,常见的编译器屏障是 asm volatile ("" ::: "memory")。这个指令告诉编译器不要对内存操作进行重排序,它不会生成实际的机器指令,但会影响编译器的优化过程。
Java 中则没有直接的编译器屏障语法,但是JVM中的release()方法,利用C++语言的volatile变量的存储操作volatile jint local_dummy = 0;,防止编译器和 CPU 对前后的内存写操作进行重排序。(具体是怎么防止编译器重排序的不太清楚,没去深入研究过JIT编译器)

# 总结下:

volatile 关键字保证有序性的原理:
JVM通过JMM(Java内存模型)规范定义volatile关键字保证有序性,主要是通过内存屏障机制(包括编译器屏障,CPU指令屏障)来确保编译器生成的汇编代码保持程序员期望的执行顺序,并且确保处理器(CPU)按照顺序执行指令。

# 4、volatile无法保证原子性吗?

# 先说结论:

对任意单个volatile变量的读/写具有原子性,类似于count++这种复合操作不具有原子性。

具体分析: 对任意单个volatile变量的读/写具有原子性:
例如:

volatile int a = 1;
int b = a; // 单个volatile 读
a = 2;  // 单个volatile变量的写

但是也有例外(64位的long型和double型变量):

# 知识铺垫:总线事务 (这部分内容来源于书籍《并发编程的艺术》)

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。

JMM不保证对64位的long型和double型变量的写操作具有原子性。
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。

在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。通过确保读操作的原子性,JSR-133 保证了一个线程在读取 long 或 double变量时,要么看到的是完全的旧值,要么看到的是完全的新值,而不是混合的中间状态。

目前大部分商用JVM会把 64 位数据的读写操作作为原子操作来对待,这样即使我们共享long/double型变量,也不用加volatile关键字了。

# 类似于count++这种复合操作不具有原子性

主要是因为count++是复合操作包括以下步骤:

  • 读取count的值。
  • 对count加1。
  • 将count值写回内存。
    实际上已经包含了读写操作。

代码示例:

import java.util.concurrent.CountDownLatch;
public class TestA {
    private static volatile int count;

    public static void main(String[] args) throws Exception {

        CountDownLatch startSignal = new CountDownLatch(1);

        Thread t1 = new Thread(() -> {
            try {
                startSignal.await();
                for (int i = 0; i < 10000; i++) {
                    count++;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            try {
                startSignal.await();
                for (int i = 0; i < 10000; i++) {
                    count++;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t2");

        t1.start();
        t2.start();

        // 让t1,t2 同时开始
        startSignal.countDown();

        t1.join();
        t2.join();

        System.out.println(count); 
    }
}

实际上IDEA也会提示,对volatile变量进行了非原子操作。

mixureSecure

结果: 期望得到20000这个结果,但是实际上很难得到20000这个结果。

12435(也可能是其他大于0且小于等于20000的整数)

# 5、volatile怎么用

# 推荐用volatile的一种情况(纸上谈兵的时候)

就是如果你找工作面试的时候,有面试官问如何实现线程安全的单例模式。
那么这种情况下你可以利用volatile关键字和双检锁来实现,或者用静态内部类实现。

利用volatile关键字和双检锁代码如下:

public class TestA {
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            new Thread(()->{
                Singleton instance = Singleton.getInstance();
                // 验证是否单例
                System.out.println(instance.hashCode());
            }).start();
        }
    }
}


class Singleton {
    // volatile声明变量
    private volatile static Singleton instance;

    // 构造函数私有化
    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                // 双检锁
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

利用静态内部类实现:

public class Singleton {

    //声明为 private 避免调用默认构造方法创建对象
    private Singleton() {
    }

   // 声明为 private 表明静态内部该类只能在该 Singleton 类中被访问
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

# 生产不太推荐使用volatile

为什么不太推荐在生产环境使用volatile来实现并发安全的程序呢,主要还是容易出错。 以空间换时间或者时间换空间这类优化是常见的,但是不会有系统能够容忍以正确性换性能的情况。所以当你没有十足的把握掌握volatile 的正确使用时,保证并发安全,还是建议使用JDK提供的其他方式,比如锁或者原子类。

  • ①、volatile仅保证最低限度的原子操作。如果想保证原子性又不想显式加锁可以尝试使用原子类。大部分需要同步的并发操作还是推荐synchronized 、或者Lock锁,在JDK5及之后 synchronized 关键字已经被充分优化,JUC并发包中也使用了synchronized ,所以不用怀疑其性能(后面会详细介绍synchronized )。
  • ②、使用volatile对技术要求太高,使用不当很可能会导致程序出bug。

# 心得:

写完这篇文章,断断续续花了大概一周时间。
参考了很多资料,尤其是《并发编程的艺术》、《JAVA并发编程实战》这两本书,也使用了各种AI工具,最好用的还是ChatGPT4o,只不过免费版每天的提问次数有限。 还参考了一些博客下面附上部分参考的博客链接:
https://javaguide.cn/java/concurrent/jmm.html
https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html
https://javabetter.cn/thread/volatile.html

再回头去看写的这篇文章,有点感觉这不就是东拼西凑出来的东西吗?
很多内容理解的还是比较模糊,比如JVM中关于编译器屏障的一些细节,这个点我还是比较模糊的。 再比如JIT编译的 x86-64架构下的汇编代码,到底哪一条确定的指令对应着volatile变量的修改,我尝试把volatile变量修改成特殊的数字然后尝试找到具体对应的一条指令,但是没找到,我把特殊的数字转成16进制去搜索打印的汇编代码日志,也没搜到。 还有一些似懂非懂模糊不清的原理或者概念等问题。
但是呢,我觉得本来打算写这个 《构建自己的Java知识体系》 一系列文章的目的,本质上是为了梳理、巩固、拓展自己的Java知识体系,很多文章虽然看起来像是东拼西凑,实际上是有自己的思考和补充理解在里面,同时会按照符合自己阅读习惯的方式来记录。
有时候自认为了解或者已经掌握了某个知识点后,但当自己真正去整理的时候才会发现,自己许多的不足之处。
纸上谈兵终究浅显,实际操作才能深刻理解呀~

欢迎访问我的主页:https://deepjava-gm.github.io (opens new window) 里面有《构建自己的Java知识体系》系列技术博客,以及对开发者非常有用的站点分享。
如果github访问比较慢可以参考这篇文章加速 增加Github访问稳定性 (opens new window)
也欢迎关注我的公众号: DeepJava