占小狼 转载请注明原创出处,谢谢!html
Java中volatile这个热门的关键字,在面试中常常会被说起,在各类技术交流群中也常常被讨论,但彷佛讨论不出一个完美的结果,带着种种疑惑,准备从JVM、C++、汇编的角度从新梳理一遍。java
volatile的两大特性:禁止重排序、内存可见性,这两个概念,不太清楚的同窗能够看这篇文章 -> java volatile关键字解惑ios
概念是知道了,但仍是很迷糊,它们究竟是如何实现的?c++
本文会涉及到一些汇编方面的内容,若是多看几遍,应该能看懂。面试
为了理解重排序,先看一段简单的代码缓存
public class VolatileTest {
int a = 0;
int b = 0;
public void set() {
a = 1;
b = 1;
}
public void loop() {
while (b == 0) continue;
if (a == 1) {
System.out.println("i'm here");
} else {
System.out.println("what's wrong");
}
}
}
复制代码
VolatileTest类有两个方法,分别是set()和loop(),假设线程B执行loop方法,线程A执行set方法,会获得什么结果?bash
答案是不肯定,由于这里涉及到了编译器的重排序和CPU指令的重排序。异步
编译器在不改变单线程语义的前提下,为了提升程序的运行速度,能够对字节码指令进行从新排序,因此代码中a、b的赋值顺序,被编译以后可能就变成了先设置b,再设置a。ide
由于对于线程A来讲,先设置哪一个,都不影响自身的结果。函数
CPU指令重排序又是怎么回事? 在深刻理解以前,先看看x86的cpu缓存结构。
一、各类寄存器,用来存储本地变量和函数参数,访问一次须要1cycle,耗时小于1ns; 二、L1 Cache,一级缓存,本地core的缓存,分红32K的数据缓存L1d和32k指令缓存L1i,访问L1须要3cycles,耗时大约1ns; 三、L2 Cache,二级缓存,本地core的缓存,被设计为L1缓存与共享的L3缓存之间的缓冲,大小为256K,访问L2须要12cycles,耗时大约3ns; 四、L3 Cache,三级缓存,在同插槽的全部core共享L3缓存,分为多个2M的段,访问L3须要38cycles,耗时大约12ns;
固然了,还有平时熟知的DRAM,访问内存通常须要65ns,因此CPU访问一次内存和缓存比较起来显得很慢。
对于不一样插槽的CPU,L1和L2的数据并不共享,通常经过MESI协议保证Cache的一致性,但须要付出代价。
在MESI协议中,每一个Cache line有4种状态,分别是:
一、M(Modified) 这行数据有效,可是被修改了,和内存中的数据不一致,数据只存在于本Cache中
二、E(Exclusive) 这行数据有效,和内存中的数据一致,数据只存在于本Cache中
三、S(Shared) 这行数据有效,和内存中的数据一致,数据分布在不少Cache中
四、I(Invalid) 这行数据无效
每一个Core的Cache控制器不只知道本身的读写操做,也监听其它Cache的读写操做,假若有4个Core: 一、Core1从内存中加载了变量X,值为10,这时Core1中缓存变量X的cache line的状态是E; 二、Core2也从内存中加载了变量X,这时Core1和Core2缓存变量X的cache line状态转化成S; 三、Core3也从内存中加载了变量X,而后把X设置成了20,这时Core3中缓存变量X的cache line状态转化成M,其它Core对应的cache line变成I(无效)
固然了,不一样的处理器内部细节也是不同的,好比Intel的core i7处理器使用从MESI中演化出的MESIF协议,F(Forward)从Share中演化而来,一个cache line若是是F状态,能够把数据直接传给其它内核,这里就不纠结了。
CPU在cache line状态的转化期间是阻塞的,通过长时间的优化,在寄存器和L1缓存之间添加了LoadBuffer、StoreBuffer来下降阻塞时间,LoadBuffer、StoreBuffer,合称排序缓冲(Memoryordering Buffers (MOB)),Load缓冲64长度,store缓冲36长度,Buffer与L1进行数据传输时,CPU无须等待。
一、CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操做,稍后再处理这个读请求的结果。 二、CPU执行store写数据时,把数据写到StoreBuffer中,待到某个适合的时间点,把StoreBuffer的数据刷到主存中。
由于StoreBuffer的存在,CPU在写数据时,真实数据并不会当即表现到内存中,因此对于其它CPU是不可见的;一样的道理,LoadBuffer中的请求也没法拿到其它CPU设置的最新数据;
因为StoreBuffer和LoadBuffer是异步执行的,因此在外面看来,先写后读,仍是先读后写,没有严格的固定顺序。
从上面的分析能够看出,实际上是CPU执行load、store数据时的异步性,形成了不一样CPU之间的内存不可见,那么如何作到CPU在load的时候能够拿到最新数据呢?
写一段简单的java代码,声明一个volatile变量,并赋值
public class VolatileTest {
static volatile int i;
public static void main(String[] args){
i = 10;
}
}
复制代码
这段代码自己没什么意义,只是想看看加了volatile以后,编译出来的字节码有什么不一样,执行 javap -verbose VolatileTest
以后,结果以下:
让人很失望,没有找相似关键字synchronize编译以后的字节码指令(monitorenter、monitorexit),volatile编译以后的赋值指令putstatic没有什么不一样,惟一不一样是变量i的修饰flags多了一个ACC_VOLATILE
标识。
不过,我以为能够从这个标识入手,先全局搜下ACC_VOLATILE
,无从下手的时候,先看看关键字在哪里被使用了,果真在accessFlags.hpp文件中找到相似的名字。
经过is_volatile()
能够判断一个变量是否被volatile修饰,而后再全局搜"is_volatile"被使用的地方,最后在bytecodeInterpreter.cpp
文件中,找到putstatic字节码指令的解释器实现,里面有is_volatile()
方法。
固然了,在正常执行时,并不会走这段逻辑,都是直接执行字节码对应的机器码指令,这段代码能够在debug的时候使用,不过最终逻辑是同样的。
其中cache变量是java代码中变量i在常量池缓存中的一个实例,由于变量i被volatile修饰,因此cache->is_volatile()
为真,给变量i的赋值操做由release_int_field_put
方法实现。
再来看看release_int_field_put
方法
内部的赋值动做被包了一层,OrderAccess::release_store
究竟作了魔法,可让其它线程读到变量i的最新值。
奇怪,在OrderAccess::release_store的实现中,第一个参数强制加了一个volatile,很明显,这是c/c++的关键字。
c/c++中的volatile关键字,用来修饰变量,一般用于语言级别的 memory barrier,在"The C++ Programming Language"中,对volatile的描述以下:
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.
volatile是一种类型修饰符,被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操做该变量的代码再也不进行优化,下面写两段简单的c/c++代码验证一下
#include <iostream>
int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
复制代码
代码中的变量i实际上是无效的,执行g++ -S -O2 main.cpp
获得编译以后的汇编代码以下:
能够发现,在生成的汇编代码中,对变量a的一些无效负责操做果真都被优化掉了,若是在声明变量a时加上volatile
#include <iostream>
int foo = 10;
volatile int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
复制代码
再次生成汇编代码以下:
和第一次比较,有如下不一样:
一、对变量a赋值2的语句,也保留了下来,虽然是无效的动做,因此volatile关键字能够禁止指令优化,其实这里发挥了编译器屏障的做用;
编译器屏障能够避免编译器优化带来的内存乱序访问的问题,也能够手动在代码中插入编译器屏障,好比下面的代码和加volatile关键字以后的效果是同样
#include <iostream>
int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
__asm__ volatile ("" : : : "memory"); //编译器屏障
a = foo + 10;
__asm__ volatile ("" : : : "memory");
int b = a + 20;
return b;
}
复制代码
编译以后,和上面相似
二、其中_a(%rip)
是变量a的每次地址,经过movl $2, _a(%rip)
能够把变量a所在的内存设置成2,关于RIP,能够查看 x64下PIC的新寻址方式:RIP相对寻址
因此,每次对变量a的赋值,都会写入到内存中;每次对变量的读取,都会从内存中从新加载。
感受有点跑偏了,让咱们回到JVM的代码中来。
执行完赋值操做后,紧接着执行OrderAccess::storeload()
,这又是啥?
其实这就是常常会念叨的内存屏障,以前只知道念,殊不知道是如何实现的。从CPU缓存结构分析中已经知道:一个load操做须要进入LoadBuffer,而后再去内存加载;一个store操做须要进入StoreBuffer,而后再写入缓存,这两个操做都是异步的,会致使不正确的指令重排序,因此在JVM中定义了一系列的内存屏障来指定指令的执行顺序。
JVM中定义的内存屏障以下,JDK1.7的实现
一、loadload屏障(load1,loadload, load2) 二、loadstore屏障(load,loadstore, store)
这两个屏障都经过acquire()
方法实现
其中__asm__
,表示汇编代码的开始。 volatile,以前分析过了,禁止编译器对代码进行优化。 把这段指令编译以后,发现没有看懂....最后的"memory"是编译器屏障的做用。
在LoadBuffer中插入该屏障,清空屏障以前的load操做,而后才能执行屏障以后的操做,能够保证load操做的数据在下个store指令以前准备好
三、storestore屏障(store1,storestore, store2) 经过"release()"方法实现:
在StoreBuffer中插入该屏障,清空屏障以前的store操做,而后才能执行屏障以后的store操做,保证store1写入的数据在执行store2时对其它CPU可见。
四、storeload屏障(store,storeload, load) 对java中的volatile变量进行赋值以后,插入的就是这个屏障,经过"fence()"方法实现:
看到这个有没有很兴奋?
经过os::is_MP()
先判断是否是多核,若是只有一个CPU的话,就不存在这些问题了。
storeload屏障,彻底由下面这些指令实现
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
复制代码
为了试验这些指令到底有什么用,咱们再写点c++代码编译一下
#include <iostream>
int foo = 10;
int main(int argc, const char * argv[]) {
// insert code here...
volatile int a = foo + 10;
// __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
volatile int b = foo + 20;
return 0;
}
复制代码
为了变量a和b不被编译器优化掉,这里使用了volatile进行修饰,编译后的汇编指令以下:
从编译后的代码能够发现,第二次使用foo变量时,没有从内存从新加载,使用了寄存器的值。
把__asm__ volatile ***
指令加上以后从新编译
相比以前,这里多了两个指令,一个lock,一个addl。 lock指令的做用是:在执行lock后面指令时,会设置处理器的LOCK#信号(这个信号会锁定总线,阻止其它CPU经过总线访问内存,直到这些指令执行结束),这条指令的执行变成原子操做,以前的读写请求都不能越过lock指令进行重排,至关于一个内存屏障。
还有一个:第二次使用foo变量时,从内存中从新加载,保证能够拿到foo变量的最新值,这是由以下指令实现
__asm__ volatile ( : : : "cc", "memory");
复制代码
一样是编译器屏障,通知编译器从新生成加载指令(不能够从缓存寄存器中取)。
一样在bytecodeInterpreter.cpp
文件中,找到getstatic字节码指令的解释器实现。
经过obj->obj_field_acquire(field_offset)
获取变量值
最终经过OrderAccess::load_acquire
实现
inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
复制代码
底层基于C++的volatile实现,由于volatile自带了编译器屏障的功能,总能拿到内存中的最新值。