来源:详述Java内存屏障,透彻理解volatile
【JVM系列】第一章 运行时数据区
【JVM面试题】第二章 从JDK7 到 JDK8, JVM为啥用元空间替换永久代?
【JVM面试题】第三章 JVM分代年龄为何是15次?能设置为16吗?
【JVM系列】详述Java内存屏障,透彻理解volatile
一般来说内存屏障分为两层:编译器屏障和CPU屏障,前者只在编译期生效,目的是防止编译器生成乱序的内存访问指令;后者通过插入或修改特定的CPU指令,在运行时防止内存访问指令乱序执行。
编译器屏障如下:
asm volatile(“”: : :“memory”)
内联汇编时只是插入了一个空指令"“,关键在在内联汇编中的修改寄存器列表中指定了"memory”,它告诉编译器:这条指令(其实是空的)可能会读取任何内存地址,也可能会改写任何内存地址。那么编译器会变得保守起来,它会防止这条fence命令上方的内存访问操作移到下方,同时防止下方的操作移到上面,也就是防止了乱序,是我们想要的结果。这条命令还有另外一个副作用:它会让编译器把所有缓存在寄存器中的内存变量刷新到内存中,然后重新从内存中读取这些值。
总结一下就是,如上命令有两个作用,防止指令重排序以及保证可见性。
如果使用纯字节码解释器来运行Java,那么HotSpot VM中orderAccess_linux_zero.inline.hpp文件中有如下实现:
static inline void compiler_barrier() {
__asm__ volatile ("" : : : "memory");
}
inline void OrderAccess::loadload() {
compiler_barrier(); }
inline void OrderAccess::storestore() {
compiler_barrier(); }
inline void OrderAccess::loadstore() {
compiler_barrier(); }
这种方式依赖于编译器达到目的时,如果编译器支持,就不用在不同的平台和CPU上再专门编写对应的实现,简化了跨平台操作。
x86属于一个强内存模型,这意味着在大多数情况下CPU会保证内存访问指令有序执行。为了防止这种CPU乱序,我们需要添加CPU内存屏障。X86专门的内存屏障指令是"mfence",另外还可以使用lock指令前缀起到相同的效果,后者开销更小。也就是说,内存屏障可以分为两类:
下面介绍一下lock指令前缀。lock指令前缀功能如下:
在X86指令体系中,具有lock指令前缀,其内允许使用lock指令前缀修饰的汇编指令有:
ADD,ADC,AND,BTC,BTR,BTS,CMPXCHG,CMPXCH8B,DEC,INC,NEG,NOT,OR,SBB,SUB,XOR,XADD,XCHG等
需要注意的是,“XCHG”和“XADD”汇编指令本身是原子指令,但也允许使用lock指令前缀进行修饰。
lock前缀的2个作用要记住。第一个是内存屏障,任何显式或隐式带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。如xchg [mem], reg具有隐式的lock前缀。第二个是原子性,单指令并不是一个不可分割的操作,比如mov,本身只有其操作数满足某些条件的时候才是原子的,但是如果允许有lock前缀,那就是原子的。
JMM为了更好让Java开发者独立于CPU的方式理解这些概念,对内存读(Load)和写(Store)操作进行两两组合:LoadLoad、LoadStore、StoreLoad以及StoreStore,只有StoreLoad组合可能乱序,而且Store和Load的内存地址必须是不一样的。
现在只讨论x86架构下的CPU屏障,参考的是Intel手册。4个屏障只是Java为了跨平台而设计出来的,实际上根据CPU的不同,对应 CPU 平台上的 JVM 可能可以优化掉一些 屏障,例如LoadLoad、LoadStore和StoreStore是x86上默认就有的行为,在这个平台上写代码时会简化一些开发过程。X86-64下仅支持一种指令重排:StoreLoad ,即读操作可能会重排到写操作前面,同时不同线程的写操作并没有保证全局可见,例子见《Intel® 64 and IA-32 Architectures Software Developer’s Manual》手册8.6.1、8.2.3.7节。这个问题用lock或mfence解决,不能靠组合sfence和lfence解决。
JDK 1.8版本中的HotSpot VM在x86上实现的loadload()、storestore()以及loadstore()函数如下:
inline void OrderAccess::loadload(){
acquire();
}
inline void OrderAccess::storestore(){
release();
}
inline void OrderAccess::loadstore(){
acquire();
}
inline void OrderAccess::storeload(){
fence();
}
inline void OrderAccess::acquire() {
volatile intptr_t local_dummy;
#ifdef AMD64
__asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");
#else
__asm__ volatile ("movl 0(%%esp),%0" : "=r" (local_dummy) : : "memory");
#endif // AMD64
}
inline void OrderAccess::release() {
// Avoid hitting the same cache-line from different threads.
volatile jint local_dummy = 0;
}
acquire语义防止它后面的读写操作重排序到acquire前面,所以LoadLoad和LoadStore组合后可满足要求;release防止它前面的读写操作重排序到release后面,所以可由StoreStore和LoadStore组合后满足要求。这样acquire和release就可以实现一个"栅栏",禁止内部读写操作跑到外边,但是外边的读写操作仍然可以跑到“栅栏”内。
在x86上,acquire和release没有涉及到StoreLoad,所以本来默认支持,在函数实现时,完全可以不做任何操作。具体在实现时,acquire()函数读取了一个C++的volatile变量,而release()函数写入了一个C++的volatile变量。这可能是支持微软从Visual Studio 2005开始就对C++ volatile关键字添加了同步语义,也就是对volatile变量的读操作具有acquire语义,对volatile变量的写操作具有release语义。
另外还可以顺便说一下,借助acquire与release语义可以实现互斥锁(mutex),实际上,mutex正是acquire与release这两个原语的由来,acquire的本意是acquire a lock,release的本意是release a lock,因此,互斥锁能保证被锁住的区域内得到的数据不会是过期的数据,而且所有写入操作在release之前一定会写入内存。所以后续我们在实现锁的过程中会有如下代码出现:
pthread_mutex_lock(&mutex);
// 操作
pthread_mutex_unlock(&mutex);
OrderAccess::storeload()函数调用的fence()的实现如下:
inline void OrderAccess::fence() {
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
}
可以看到是使用lock前缀来解决内存屏障问题。
下面看一下Java的volatile变量的实现。
字节码层面会在access_flags中会标记某个属性为volatitle,到HotSpot VM后,对volatitle内存区进行读写时,都加屏障,如读取volatile变量时加如下屏障:
volatile变量读操作
LoadLoad
LoadStore
在写volatilie变量时加如下屏障:
LoadStore
StoreStore
volatile变量写操作
StoreLoad
如上的volatile变量读之后的操作不允许重排序到前面,而写之前的操作也不允许重排序到写后面,所以volatile有acquire和release的语义。
对x86-64位来说,只需要对StoreLoad进行处理,所以从解释执行的putfield或putstatic指令来看(可参考文章:第26篇-虚拟机对象操作指令之putstatic),会在最后写入volatilie变量后加如下指令:
lock addl $0x0,(%rsp)
在多线程编程中,由于使用互斥量,信号量和事件都在设计的时候都阻止了它们调用点中的内存乱序(已经隐式包含各种memery barrier),内存乱序的问题同样不需要考虑了。只有当使用无锁(lock-free)技术时–内存在线程间共享而没有任何的互斥量,内存乱序的效果才会显露无疑,这样我们才需要考虑在合适的地方加入合适的memery barrier。
编译器屏障和CPU屏障的主要作用都是确保指令执行的顺序,防止指令重排,从而保证程序的正确性和可靠性。
编译器屏障主要在编译时期发挥作用,防止编译器生成乱序的内存访问指令。通过插入编译器屏障,编译器不会对屏障前后的代码进行重排和优化,从而保证了程序的正确性和可靠性。具体来说,编译器屏障可以分为读屏障和写屏障,分别用于保证读取和写入共享变量时的可见性。
CPU屏障则在运行时防止内存访问指令乱序执行。这是通过插入或修改特定的CPU指令来实现的。