原文出处: 占小狼
前言Java中volatile这个热门的关键字,在面试中经常会被提及,在各种技术交流群中也经常被讨论,但似乎讨论不出一个完美的结果,带着种种疑惑,准备从JVM、C++、汇编的角度重新梳理一遍。 volatile的两大特性:禁止重排序、内存可见性,这两个概念,不太清楚的同学可以看这篇文章 -> java volatile关键字解惑 概念是知道了,但还是很迷糊,它们到底是如何实现的? 本文会涉及到一些汇编方面的内容,如果多看几遍,应该能看懂。 重排序为了理解重排序,先看一段简单的代码
VolatileTest类有两个方法,分别是set()和loop(),假设线程B执行loop方法,线程A执行set方法,会得到什么结果? 答案是不确定,因为这里涉及到了编译器的重排序和CPU指令的重排序。 编译器重排序编译器在不改变单线程语义的前提下,为了提高程序的运行速度,可以对字节码指令进行重新排序,所以代码中a、b的赋值顺序,被编译之后可能就变成了先设置b,再设置a。 因为对于线程A来说,先设置哪个,都不影响自身的结果。 CPU指令重排序CPU指令重排序又是怎么回事? 1、各种寄存器,用来存储本地变量和函数参数,访问一次需要1cycle,耗时小于1ns; 当然了,还有平时熟知的DRAM,访问内存一般需要65ns,所以CPU访问一次内存和缓存比较起来显得很慢。 对于不同插槽的CPU,L1和L2的数据并不共享,一般通过MESI协议保证Cache的一致性,但需要付出代价。 在MESI协议中,每个Cache line有4种状态,分别是: 1、M(Modified) 2、E(Exclusive) 3、S(Shared) 4、I(Invalid) 每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,假如有4个Core: 当然了,不同的处理器内部细节也是不一样的,比如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无须等待。 1、CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。 因为StoreBuffer的存在,CPU在写数据时,真实数据并不会立即表现到内存中,所以对于其它CPU是不可见的;同样的道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据; 由于StoreBuffer和LoadBuffer是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序。 内存可见性如何实现从上面的分析可以看出,其实是CPU执行load、store数据时的异步性,造成了不同CPU之间的内存不可见,那么如何做到CPU在load的时候可以拿到最新数据呢? 设置volatile变量写一段简单的java代码,声明一个volatile变量,并赋值
这段代码本身没什么意义,只是想看看加了volatile之后,编译出来的字节码有什么不同,执行 让人很失望,没有找类似关键字synchronize编译之后的字节码指令(monitorenter、monitorexit),volatile编译之后的赋值指令putstatic没有什么不同,唯一不同是变量i的修饰flags多了一个 不过,我觉得可以从这个标识入手,先全局搜下 通过 当然了,在正常执行时,并不会走这段逻辑,都是直接执行字节码对应的机器码指令,这段代码可以在debug的时候使用,不过最终逻辑是一样的。 其中cache变量是java代码中变量i在常量池缓存中的一个实例,因为变量i被volatile修饰,所以 再来看看 内部的赋值动作被包了一层, 奇怪,在OrderAccess::release_store的实现中,第一个参数强制加了一个volatile,很明显,这是c/c++的关键字。 c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的 memory barrier,在”The C++ Programming Language”中,对volatile的描述如下:
volatile是一种类型修饰符,被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化,下面写两段简单的c/c++代码验证一下
代码中的变量i其实是无效的,执行 可以发现,在生成的汇编代码中,对变量a的一些无效负责操作果然都被优化掉了,如果在声明变量a时加上volatile
再次生成汇编代码如下: 和第一次比较,有以下不同: 1、对变量a赋值2的语句,也保留了下来,虽然是无效的动作,所以volatile关键字可以禁止指令优化,其实这里发挥了编译器屏障的作用; 编译器屏障可以避免编译器优化带来的内存乱序访问的问题,也可以手动在代码中插入编译器屏障,比如下面的代码和加volatile关键字之后的效果是一样
编译之后,和上面类似 2、其中 所以,每次对变量a的赋值,都会写入到内存中;每次对变量的读取,都会从内存中重新加载。 感觉有点跑偏了,让我们回到JVM的代码中来。 执行完赋值操作后,紧接着执行 其实这就是经常会念叨的内存屏障,之前只知道念,却不知道是如何实现的。从CPU缓存结构分析中已经知道:一个load操作需要进入LoadBuffer,然后再去内存加载;一个store操作需要进入StoreBuffer,然后再写入缓存,这两个操作都是异步的,会导致不正确的指令重排序,所以在JVM中定义了一系列的内存屏障来指定指令的执行顺序。 JVM中定义的内存屏障如下,JDK1.7的实现 ![]() 1、loadload屏障(load1,loadload, load2) 这两个屏障都通过 ![]() 其中 在LoadBuffer中插入该屏障,清空屏障之前的load操作,然后才能执行屏障之后的操作,可以保证load操作的数据在下个store指令之前准备好 3、storestore屏障(store1,storestore, store2) ![]() 在StoreBuffer中插入该屏障,清空屏障之前的store操作,然后才能执行屏障之后的store操作,保证store1写入的数据在执行store2时对其它CPU可见。 4、storeload屏障(store,storeload, load) ![]() 看到这个有没有很兴奋? 通过 storeload屏障,完全由下面这些指令实现
为了试验这些指令到底有什么用,我们再写点c++代码编译一下
为了变量a和b不被编译器优化掉,这里使用了volatile进行修饰,编译后的汇编指令如下: ![]() 从编译后的代码可以发现,第二次使用foo变量时,没有从内存重新加载,使用了寄存器的值。 把 ![]() 相比之前,这里多了两个指令,一个lock,一个addl。 还有一个:第二次使用foo变量时,从内存中重新加载,保证可以拿到foo变量的最新值,这是由如下指令实现
同样是编译器屏障,通知编译器重新生成加载指令(不可以从缓存寄存器中取)。 读取volatile变量同样在 ![]() 通过 ![]() 最终通过
底层基于C++的volatile实现,因为volatile自带了编译器屏障的功能,总能拿到内存中的最新值。 |
|
来自: liang1234_ > 《jvm》