从Hotsport源码和操作系统级别深⼊理解volatile关键字与内存屏障(Lock前
缀)
⽂章⽬录
⼀、volatile的内存语义
1.2 volatile的特性
可见性:对⼀个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写⼊。
原⼦性:对任意单个volatile变量的读/写具有原⼦性,但类似于volatile++这种复合操作不具有原⼦性(基于这点,我们通过会认为volatile 不具备原⼦性)。volatile仅仅保证对单个volatile变量的读/写具有原⼦性,⽽锁的互斥执⾏的特性可以确保对整个临界区代码的执⾏具有原⼦性。
64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原⼦性。
有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁⽌指令重排序来保障有序性。
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。为了提供⼀种⽐锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
1.2 volatile写-读的内存语义
当写⼀个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读⼀个volatile变量时,JMM会把该线程对应的本地内存置为⽆效,线程接下来将从主内存中读取共享变量。
1.3 volatile可见性实现原理
1.3.1 JMM内存交互层⾯实现
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须⽴即同步回主内存,使⽤时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
1.3.2 汇编层⾯volatile的实现 (java代码中的使⽤和验证)
public class VisibilityTest {
// ⽅式⼀:使⽤volatile
private volatile boolean flag =true;
.......
上述代码是我们在java中是使⽤volatile关键字的⽤法,那么在java中加了这个关键字之后,底层JVM或者操作系统到底是如何处理的呢?为何就能够保证可见性呢?也就是说这个java中的volatile关键字为何能够使得缓存中的值失效,并强迫线程把修改后的值⽴即刷回主内存的?⼤家有想过这个问题吗?
其实我们可以查看JIT及时编译后的汇编代码。我们知道java中是存在两种解释器的,⼀种是字节码解释器,⼀种是模板解释器。对于热点代码会使⽤规模解释器直接编译成汇编语⾔,从⽽达到更快的执⾏效率。
我们并将其放在 $JAVA_HOME/jre/bin/server ⽬录下。
然后给测试程序添加JVM参数:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
结果:
可以看到,java代码中加了volatile关键字的变量编译成汇编代码之后,前⾯是有⼀个lock; addl $0,0(%%rsp) 的,这也就是我们平时所说的lock前缀。
我们知道,Lock前缀是有⼀种指令,它能够使得缓存将修改后的变量⽴即刷回主内存,同时使得其他缓存中的该值失效。这就是volatile是如何产⽣作⽤的过程。后⾯我们可以从其他层⾯更深⼊的去了解这个关键字。
1.3.3 硬件层⾯实现
通过lock前缀指令,会锁定变量缓存⾏区域并写回主内存,这个操作称为“缓存锁定”,缓存⼀致性机制会阻⽌同时修改被两个以上处理器缓存的内存区域数据。⼀个处理器的缓存回写到内存会导致其他处理器的缓存⽆效。
⼆、volatile在hotspot的实现
2.1 字节码解释器实现
JVM中的字节码解释器(bytecodeInterpreter),⽤C++实现了JVM指令,其优点是实现相对简单且容易理解,缺点是执⾏慢。
我们的java代码使⽤字节码解释器执⾏的时候,会⾸先被解析为C++代码,然后在被解析为汇编、机器码等,最终才能被机器识别。
⽽在java代码中加了volatile变量的关键字会被虚拟机中bytecodeInterpreter.cpp这个类中的如何⽅法处理:
可以看到,这⾥在判断各种类型的数据(如object,Long, Char, Short…)是否是volatile修饰的。
如果是,最终会调⽤OrderAccess::storeload() ⽅法,也就是会插⼊⼀个storeload内存屏障。
内存屏障的作⽤和Lock前缀是⼀样的,都可以使得修改后的数据⽴即刷回主内存并使得其他缓存中的数据失效(同时也会保证有序性,防⽌指令重排序!)。在Linux X86架构中,使⽤Lock前缀来代替内存屏障,因为Lock前缀性能更⾼。
关于OrderAccess::storeload() ⽅法的具体实现,请继续往下看,我们会到linux架构代码中去查看盖世仙。
2.2 模板解释器实现
模板解释器(templateInterpreter),其对每个指令都写了⼀段对应的汇编代码,启动时将每个指令与对应汇编代码⼊⼝绑定,可以说是效率做到了极致。
对于⼀些热点代码,⽐如while(true) {…}这种要被执⾏⾮常多次的代码,为了提⾼性能、减少字节码解析次数,会使⽤模板解释器执⾏,会将该段代码直接编译成⼀段汇编指令!
templateTable_x86_64.cpp
void TemplateTable::volatile_barrier(Assembler::Membar_mask_bits
order_constraint){
// Helper function to insert a is-volatile test and memory barrier
if(os::is_MP()){// Not needed on single CPU
__ membar(order_constraint);
}
}
// 负责执⾏putfield或putstatic指令
void TemplateTable::putfield_or_static(int byte_no,bool is_static, RewriteControl rc){
// ...
// Check for volatile store
__ testl(rdx, rdx);
__ jcc(Assembler::zero, notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |
Assembler::StoreStore));
__ jmp(Done);
__ bind(notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
__ bind(Done);
}
这⾥会判断处理器是否是多核处理器,如果是则会调⽤membar⽅法!
assembler_x86.hpp
// Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint){
// We only have to handle StoreLoad
// x86平台只需要处理StoreLoad
if(order_constraint & StoreLoad){
int offset =-VM_Version::L1_line_size();
if(offset <-128){
offset =-128;
}
// 下⾯这两句插⼊了⼀条lock前缀指令: lock addl $0, $0(%rsp)
lock();// lock前缀指令
addl(Address(rsp, offset),0);// addl $0, $0(%rsp)
}
}
从membar⽅法中我们也可以看出来,最终volatile的实现是插⼊了⼀条Lock前缀指令实现的三、volatile在linux系统x86中的实现
汇编table指令什么意思orderAccess_linux_x86.inline.hpp
inline void OrderAccess::storeload(){fence();}
inline void OrderAccess::fence(){
if(os::is_MP()){
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile("lock; addl $0,0(%%rsp)":::"cc","memory");
#else
__asm__ volatile("lock; addl $0,0(%%esp)":::"cc","memory");
#endif
}
可以看到,JVM中判断⼀个变量时volatile修饰的,最终会调⽤OrderAccess::storeload()⽅法,即会调⽤操作系统的库函数。
这⾥我们可以看到这个函数在linux系统X86中的具体实现,可以看到storeload⽅法会盗⽤fence⽅法,⽽fence⽅法中判断如果处理器是多核的,则会使⽤lock; addl $0,0(%%rsp),也就是Lock前缀来保证可见性和有序性
lock前缀指令的作⽤
1、确保后续指令执⾏的原⼦性。在Pentium及之前的处理器中,带有lock前缀的指令在执⾏期间会锁
住总线,使得其它处理器暂时⽆法通过总线访问内存,很显然,这个开销很⼤。在新的处理器中,Intel使⽤缓存锁定来保证指令执⾏的原⼦性,缓存锁定将⼤⼤降低lock前缀指令的执⾏开销。
2、LOCK前缀指令具有类似于内存屏障的功能,禁⽌该指令与前⾯和后⾯的读写指令重排序。
3、LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写⼊内存)之后才开始执⾏,并且根据缓存⼀致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
四、从硬件层⾯分析Lock前缀指令
《64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf》中有如下描述:
The 32-bit IA-32 processors support locked atomic operations on locations in system memory. These operations are typically used to manage shared data structures (such as semaphores, segment descriptors, system segments, or page tables) in which two or more processors may try simultaneously to modify the same field or flag. The processor uses three interdependent mechanisms for carrying out locked atomic operations:
· Guaranteed atomic operations
· Bus locking, using the LOCK# signal and the LOCK instruction prefix
· Cache coherency protocols that ensure that atomic operations can be carried out on cached data structures (cache lock); this mechanism is present in the Pentium 4, Intel Xeon, and P6 family processors
32位的IA-32处理器⽀持对系统内存中的位置进⾏锁定的原⼦操作。这些操作通常⽤于管理共享的数据结构(如信号量、段描述符、系统段或页表),在这些结构中,两个或多个处理器可能同时试图修改相同的字段或标志。处理器使⽤三种相互依赖的机制来执⾏锁定的原⼦操作:
有保证的原⼦操作
总线锁定,使⽤LOCK#信号和LOCK指令前缀
缓存⼀致性协议,确保原⼦操作可以在缓存的数据结构上执⾏(缓存锁);这种机制出现在Pentium 4、Intel Xeon和P6系列处理器中五、总结与疑问
⾄此,我们已经对java中volatile关键字进⾏了深⼊的分析,从JVM源码、汇编层⾯以及操作系统中的具体实现。
我们可以总结到,要实现缓存修改数据后⽴即刷回主内存、并使得其他缓存中的该数据失效的这种能⼒,是只有操作操作系统才具备
的 JVM的实现C++语⾔不具备、Java语⾔更不具备,最终都是要调⽤操作系统级别的库函数,才能够具备这种能⼒
那问题就到了操作系统这⾥,操作系统⼜是如何实现这种功能的呢?我们前⾯总结说到有添加内存屏障和使⽤Lock前缀指令的,这⼜是为什么呢?
因为对于同⼀功能,不同的操作系统可能会有不同的实现,也许在⼀些架构中,会使⽤内存屏障来保证可见性和有序性,但是在⼀些架构中,⽐如Linux系统X86中,就是⽤了与内存屏障相同功能的Lock前缀来实现!因为其认为Lock前缀的性能要优于直接使⽤内存屏障。
⾄此,我们已经搞清楚了volatile的整个实现过程与原理。现在再要深⼊的话,我们可能就会提出这样的问题:
内存屏障具备的能⼒我们了解了,但是内存屏障真⾝⼜是什么呢?在操作系统中⼜是如何实现的呢?
后⾯我们持续学习,希望可以解决这个疑问。也欢迎知道答案的⼈告诉⼀下。。。@@!
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论