文章目录
- 一、前言
- 二、CPU的乱序执行
- 三、乱序可能会造成的问题
- 四、如何禁止指令重排序?
- 4.1 Java 代码层面
- 4.2 字节码层面
- 4.3 HotSpot 源码层面
- 4.4 CPU层面
- 五、 hanppens-before原则 与 hanppens-before原则
一、前言
volatile,这个关键字,是JDK提供给我们使用的,之前也总结过一篇文章不过有点浅显,只是说明一下它的特性,这篇文章打算详细地总结下相关的概念与底层细节。
之前的总结:JUC(8)Java内存模型-JMM和Volatile关键字
volatile的特性:
- 1、保证可见性 (这就要涉及JMM)
- 2、不保证原子性
- 3、禁止指令重排
二、CPU的乱序执行
CPU在执行代码时是乱序的,这里说的乱序是指代码编译后的指令的执行顺序,也就是说指令是乱序执行的。
为什么要打乱顺序呢?答:是为了提高CPU的执行效率。
这一块,具体的介绍可以看之前的读书笔记:为什么要进行指令重排呢?
在此,也可以给出一个代码示例来验证下:
执行下面代码,如果CPU没有乱序执行的话,那么 a = 1
必然在 x = b
前面,b = 1
必然在 y = a
的前面
我们可以得到什么结论,也就是 x
和 y
肯定不能同时为 0
。
但是实际情况是,我们会遇到 x
和 y
同时为 0
的情况。(可能要循环几百万次才能遇到一次)
三、乱序可能会造成的问题
先说答案,会引起数据错误(数据安全问题)。
用个例子来验证吧,背景:DCL单例模式加了volatile关键字。
反编译汇编码:
对汇编码逐步分析:
-
new #2 <T>
:创建m = 0
的对象并且栈帧中有一个引用指向该对象(此时m=0) -
dup
:在我们的栈帧中复制一份引用(此时m=0) -
invokespecial #3 <T.<init>>
:弹出一个栈帧中的值,实例化它的构造方法(此时m=8) -
astore_1
:将我们栈帧的引用赋值给 t,这里1
指的是我们本地变量表中的第一位(此时m=8)
因为乱序的存在,当我们的 astore_1
在我们的 invokespeecial # 3 <T.<init>>
执行前执行,会导致我们的将我们没有实例化的对象赋值给 t
,所以m = 0
。此时如果去使用t,肯定会造成数据错误。
所以为了避免这种现象,我们要对 DCL
加 volatile
,那问题来了,我们 volatile
是怎么保证有序性的呢?
四、如何禁止指令重排序?
对于禁止指令重排序,从以下四个方面来谈:
- 代码层面
- 字节码层面
- JVM层面
- CPU层面
4.1 Java 代码层面
直接加一个 volatile 关键字即可
4.2 字节码层面
在字节码层面,当对 volatile 进行反编译后,我们可以看到 VCC_volatile
我们对上述代码进行反编译,得到其 字节码
通过javac TestVolatile.java
将类编译为class文件,再通过 javap -v TestVolatile.class
命令反编译查看字节码文件
这里我们只展示这段代码得字节码:public static volatile int counter = 1;
- descriptor:代表参数的类型和修饰符
- flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE:标志
- putstatic:对静态属性进行操作
descriptor
,关于字节码的类型对应如下:B基本类型byte
C基本类型char
D基本类型double
F基本类型float
I基本类型int
J基本类型long
S基本类型short
Z基本类型boolean
V特殊类型void
L对象类型,以分号结尾,如Ljava/lang/Object;
对于数组类型,每一位使用一个前置的[字符来描述,如定义一个java.lang.String[][]类型的维数组,将被记录为[[Ljava/lang/String;
我们后续的操作都可以通过 ACC_VOLATILE 这个标志来知道该变量已被 volatile 所修饰
4.3 HotSpot 源码层面
对于带有 volatile 修饰的变量,我们的 JVM 是怎么去实现的呢?
这里涉及到四个屏障:StoreStore,StoreLoad,LoadStore,LoadLoad
JSR内存屏障:
- LoadLoad屏障:对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1;StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
- LoadStore屏障:对于这样的语句Load1; LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1;StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
我们的 JVM 确实是这样实现的,我们一起来看一下具体的实现吧。
Java中,静态属性属于类的。操作静态属性,对应的指令为 putstatic
我们以 openjdk8 根路径 jdk\src\hotspot\share\interpreter\zero
路径下的 bytecodeInterpreter.cpp
文件中,处理 putstatic
指令的代码:
这里贴一下 cache->is_volatile()
的源码,路径:jdk\src\hotspot\share\utilities\accessFlags.hpp
我们看一下赋值 obj->release_long_field_put(field_offset, STACK_LONG(-1))
的源代码:
jdk\src\hotspot\share\oops\oop.inline.hpp
我们前往 jdk\src\hotspot\share\runtime\atomic.hpp
看一下 Atomic::release_store
的方法:
我们可以清楚的看到,const volatile T* p 和 volatile D* p
在调用的时候,直接使用了C/C++
的 volatile
关键字
我们继续往下看,在我门执行完参数的赋值后,会有这个一个操作:OrderAccess::storeload();
我们观察 jdk\src\hotspot\share\runtime
的 orderAccess.hpp
文件,发现有这么一段代码:
我们可以清楚的看到,这就是我们之前提到的 JVM 的读写屏障
当然,我们还要看其在 linux_x86
实现方式,在 jdk\src\hotspot\os_cpu\linux_x86
的 orderAccess_linux_x86.hpp
下
4.4 CPU层面
在上面的代码中,我们可以看到,最关键的是这一行代码:__asm__ volatile ("" : : : "memory");
-
__asm__
:用于指示编译器在此插入汇编语句 - volatile :告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。即:原原本本按原来的样子处理这这里的汇编。
- (“” : : : “memory”):memory 强制 gcc 编译器假设 RAM 所有内存单元均被汇编指令修改,这样 cpu 中的 registers 和 cache 中已缓存的内存单元中的数据将作废。cpu 将不得不在需要的时候重新读取内存中的数据。这就阻止了 cpu 又将 registers, cache 中的数据用于去优化指令,而避免去访问内存。
简单概括:告诉我们的CPU,别瞎几把给我优化了,我就要串行执行。
这样我们可以看到,这些指令都是通过更改CPU的 寄存器 和 缓存
来保持有序性的。
还有,我们观察这些方法,会发现有一个叫 fence() 的方法,我们观察一下这个方法:
我们可以看到,我们的方法不建议我们使用我们的原语指令 mfence(内存屏障)
,因为 mfence 的资源消耗要比 locked
资源消耗的多。
补充:(Intel 的原语指令:
mfence内存屏障
,ifence读屏障
,sfence写屏障
)
直接判断是不是 AMD64
来对其不同的寄存器 rsp\esp
做处理
“lock; addl $0,0(%%rsp)”:在 rsp 寄存器上加一个 0) 指令是一个 Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个 CPU。
好了,到这里,我们的 volatile
基本差不多了。
五、 hanppens-before原则 与 hanppens-before原则
虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。
- 程序顺序原则:一个线程内保证语义的串行性。
- volatile规则:volatile变量的写先于读发生,这保证了volatile变量的可见性。
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
- 传递性:A先于B,B先于C,那么A必然先于C。
- 线程启动规则 :线程的start()方法先于它的每一个动作。
- 线程终止规则 :线程的所有操作先于线程的终结(Thread.join())。
- 线程中断规则 :线程的中断(interrupt())先于被中断线程的代码。
- 对象终结规则 :对象的构造函数的执行、结束先于finalize()方法。
这些原则都是为了保证指令重排不会破坏原有的语义结构。
as if serial 表达的就是:不管如何重排序,单线程执行的结果不会改变。
ok,结束!