前言
当共享变量被声明为 volatile 后, 对这个变量的读 / 写操作都会很特别, 下面我们就揭开 volatile 的神秘面纱.
1.volatile 的内存语义
1.1 volatile 的特性
一个 volatile 变量自身具有以下三个特性:
可见性: 即当一个线程修改了声明为 volatile 变量的值, 新值对于其他要读该变量的线程来说是立即可见的. 而普通变量是不能做到这一点的, 普通变量的值在线程间传递需要通过主内存来完成.
有序性: volatile 变量的所谓有序性也就是被声明为 volatile 的变量的临界区代码的执行是有顺序的, 即禁止指令重排序.
受限原子性: 这里 volatile 变量的原子性与 synchronized 的原子性是不同的, synchronized 的原子性是指只要声明为 synchronized 的方法或代码块儿在执行上就是原子操作的. 而 volatile 是不修饰方法或代码块儿的, 它用来修饰变量, 对于单个 volatile 变量的读 / 写操作都具有原子性, 但类似于 volatile++ 这种复合操作不具有原子性. 所以 volatile 的原子性是受限制的. 并且在多线程环境中, volatile 并不能保证原子性.
1.2 volatile 写 - 读的内存语义
volatile 写的内存语义: 当写线程写一个 volatile 变量时, JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存.
volatile 读的内存语义: 当读线程读一个 volatile 变量时, JMM 会把该线程对应的本地内存置为无效, 线程接下来将从主内存读取共享变量.
2.volatile 语义实现原理
在介绍 volatile 语义实现原理之前, 我们先来看两个与 CPU 相关的专业术语:
内存屏障 (memory barriers): 一组处理器指令, 用于实现对内存操作的顺序限制.
缓存行 (cache line):CPU 高速缓存中可以分配的最小存储单位. 处理器填写缓存行时会加载整个缓存行.
2.1 volatile 可见性实现原理
volatile 可见性的内存语义是如何实现的呢? 下面我们看一段代码, 并将代码生成的处理器的汇编指令打印出来 (关于如何打印汇编指令, 我会在文章末尾附上), 看下对 volatile 变量进行写操作时, CPU 会做什么事情:
- public class VolatileTest {
- private static volatile VolatileTest instance = null;
- private VolatileTest(){}
- public static VolatileTest getInstance(){
- if(instance == null){
- instance = new VolatileTest();
- }
- return instance;
- }
- public static void main(String[] args) {
- VolatileTest.getInstance();
- }
- }
以上的代码是一个我们非常熟悉的在多线程环境中不能保证线程安全的单例模式代码, 这段代码中特殊的地方是, 我将实例变量 instance 加上了 volatile 修饰, 下面看打印的汇编指令:
上面截图中, 我们看到我划线的一行的末尾有一句汇编注释: putstatic instance, 了解 JVM 字节码指令的小伙伴都知道, putstatic 的含义是给一个静态变量设置值, 在上述代码中也就是给静态变量 instance 赋值, 对应代码: instance = new VolatileTest(); 在 getInstance 方法中为 instance 实例化, 因为 instance 加了 volatile 修饰, 所以给静态变量 instance 设置值也是在写一个 volatile 变量.
看到上述有汇编指令, 也有字节码指令, 大家会不会混淆这两种指令, 这里我指明一下字节码指令和汇编指令的区别:
我们都知道 java 是一种跨平台的语言, 那么 java 是如何实现这种平台无关性的呢? 这就需要我们了解 JVM 和 java 的字节码文件. 这里我们需要有一点共识, 就是任何一门编程语言都需要转换为与平台相关的汇编指令才能够最终被硬件执行, 比如 C 和 C++ 都将我们的源代码直接编译成与 CPU 相关的汇编指令给 CPU 执行. 不同系列的 CPU 的体系架构不同, 所以它们的汇编指令也有不同, 比如 X86 架构的 CPU 对应于 X86 汇编指令, ARM 架构的 CPU 对应于 ARM 汇编指令. 如果将程序源代码直接编译成与硬件相关的底层汇编指令, 那么程序的跨平台性也就大打折扣, 但执行性能相对较高. 为了实现平台无关性, java 的编译器 javac 并不是将 java 的源程序直接编译成与平台相关的汇编指令, 而是编译成一种中间语言, 即 java 的 class 字节码文件. 字节码文件, 顾名思义存的就是字节码, 即一个一个的字节. 有打开过 java 字节码文件研读过的小伙伴可能会发现, 字节码文件里面存的并不是二进制, 而是十六进制, 这是因为二进制太长了, 一个字节要由 8 位二进制组成. 所以用十六进制标表示, 两个十六进制就可以表示一个字节. java 源码编译后的字节码文件是不能够直接被 CPU 执行的, 那么该如何执行呢? 答案是 JVM, 为了让 java 程序能够在不同的平台上执行, java 官方提供了针对于各个平台的 java 虚拟机, JVM 运行于硬件层之上, 屏蔽各种平台的差异性. javac 编译后的字节码文件统一由 JVM 来加载, 最后再转化成与硬件相关的机器指令被 CPU 执行. 知道了通过 JVM 来加载字节码文件, 那么还有一个问题, 就是 JVM 如何将字节码中的每个字节和我们写的 java 源代码相关联, 也就是 JVM 如何知道我们写的 java 源代码对应于 class 文件中的哪段十六进制, 这段十六进制是干什么的, 执行了什么功能? 并且一大堆的十六进制, 我们也看不懂啊. 所以这就需要定义一个 JVM 层面的规范, 在 JVM 层面抽象出一些我们能够认识的指令助记符, 这些指令助记符就是 java 的字节码指令.
再看上面的截图, 当写 instance 这个 volatile 变量时, 发现 add 前面加个一个 lock 指令, 我在截图中框了出来, 如何不加 volatile 修饰, 是没有 lock 的.
lock 指令在多核处理器下会引发下面的事件:
将当前处理器的缓存行的数据写回到系统内存, 同时使其他 CPU 里缓存了该内存地址的数据置为无效.
为了提高处理速度, 处理器一般不直接和内存通信, 而是先将系统内存的数据读到内部缓存后再进行操作, 但操作完成后并不知道处理器何时将缓存数据写回到内存. 但如果对加了 volatile 修饰的变量进行写操作, JVM 就会向处理器发送一条 lock 前缀的指令, 将这个变量在缓存行的数据写回到系统内存. 这时只是写回到系统内存, 但其他处理器的缓存行中的数据还是旧的, 要使其他处理器缓存行的数据也是新写回的系统内存的数据, 就需要实现缓存一致性协议. 即在一个处理器将自己缓存行的数据写回到系统内存后, 其他的每个处理器就会通过嗅探在总线上传播的数据来检查自己缓存的数据是否已过期, 当处理器发现自己缓存行对应的内存地址的数据被修改后, 就会将自己缓存行缓存的数据设置为无效, 当处理器要对这个数据进行修改操作的时候, 会重新从系统内存中把数据读取到自己的缓存行, 重新缓存.
总结下: volatile 可见性的实现就是借助了 CPU 的 lock 指令, 通过在写 volatile 的机器指令前加上 lock 前缀, 使写 volatile 具有以下两个原则:
写 volatile 时处理器会将缓存写回到主内存.
一个处理器的缓存写回到内存会导致其他处理器的缓存失效.
2.2 volatile 有序性的实现原理
volatile 有序性的保证就是通过禁止指令重排序来实现的. 指令重排序包括编译器和处理器重排序, JMM 会分别限制这两种指令重排序.
那么禁止指令重排序又是如何实现的呢? 答案是加内存屏障. JMM 为 volatile 加内存屏障有以下 4 种情况:
在每个 volatile 写操作的前面插入一个 StoreStore 屏障, 防止写 volatile 与后面的写操作重排序.
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障, 防止写 volatile 与后面的读操作重排序.
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障, 防止读 volatile 与后面的读操作重排序.
在每个 volatile 读操作的后面插入一个 LoadStore 屏障, 防止读 volatile 与后面的写操作重排序.
上述内存屏障的插入策略是非常保守的, 比如一个 volatile 的写操作后面需要加上 StoreStore 和 StoreLoad 屏障, 但这个写 volatile 后面可能并没有读操作, 因此理论上只加上 StoreStore 屏障就可以, 的确, 有的处理器就是这么做的. 但 JMM 这种保守的内存屏障插入策略能够保证在任意的处理器平台, volatile 变量都是有序的.
3.JSR-133 对 volatile 内存语义的增强
在 JSR-133 之前的旧的 java 内存模型中, 虽然不允许对 volatile 变量之间的操作进行重排序, 但允许对 volatile 变量与普通变量之间进行重排序. 比如内存屏障前面是一个写 volatile 变量的操作, 内存屏障后面的操作是一个写普通变量的操作, 即使这两个写操作可能会破坏 volatile 内存语义, 但 JMM 是允许这两个操作进行重排序的.
在 JSR-133 以及后面的新的 java 内存模型中, 增强了 volatile 的内存语义. 只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义, 这种重排序就会被编译器重排序规则和处理器内存屏障出入策略禁止.
附: 配置 idea 打印汇编指令
工具包下载地址: 链接: https://pan.baidu.com/s/11yRnsOHca5EVRfE9gAuVxA
提取码: gn8z
将下载的工具包解压, 复制到 jdk 安装目录的 jre 路径下的 bin 目录中, 如图:
然后配置 idea, 在 VM options 选项中输入:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,* 类名. 方法名
JRE 选项选择已放入工具包的 jre 路径.
下图是我的 idea 配置:
以上配置好后运行就可以打印汇编指令了.
来源: https://www.cnblogs.com/wildwolf0/p/11449506.html