一般来说内存屏障分为两层:编译器屏障和CPU屏障,前者只在编译期生效,目的是防止编译器生成乱序的内存访问指令;后者通过插入或修改特定的CPU指令,在运行时防止内存访问指令乱序执行。
下面简单说一下这两种屏障。
1、编译器屏障
编译器屏障如下:
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上再专门编写对应的实现,简化了跨平台操作。
2、x86 CPU屏障
x86属于一个强内存模型,这意味着在大多数情况下CPU会保证内存访问指令有序执行。为了防止这种CPU乱序,我们需要添加CPU内存屏障。X86专门的内存屏障指令是"mfence",另外还可以使用lock指令前缀起到相同的效果,后者开销更小。也就是说,内存屏障可以分为两类:
- 本身是内存屏障,比如"lfence","sfence"和"mfence"汇编指令
- 本身不是内存屏障,但是被lock指令前缀修饰,其组合成为一个内存屏障。在X86指令体系中,其中一类内存屏障常使用"lock指令前缀加上一个空操作"方式实现,比如lock addl $0x0,(%esp)
下面介绍一下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前缀,那就是原子的。
3、HotSpot VM中的内存屏障
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)
在Java的synchronized过程中,也要保证可见性及乱序行为。 写单线程代码的程序员不需要关心内存乱序的问题。在多线程编程中,由于使用互斥量,信号量和事件都在设计的时候都阻止了它们调用点中的内存乱序(已经隐式包含各种memery barrier),内存乱序的问题同样不需要考虑了。只有当使用无锁(lock-free)技术时--内存在线程间共享而没有任何的互斥量,内存乱序的效果才会显露无疑,这样我们才需要考虑在合适的地方加入合适的memery barrier。
B站上已经更新出了一系列的课程,关于一个手写Hotspot VM的课程,超级硬核,从0开始写HotSpot VM,将HotSpot VM所有核心的实现全部走一遍,有兴趣可关注B站Up主。