1.引言
如果你对Java的volatile有着疑虑请阅读本文,如果你想对volatile想有一个更深的了解,请阅读本文.本文主要讲的是volatile的写happen-before在volatile读之前所涉及相关的原理,以及在Hotspot中相关代码的实现.
首先从一段代码开始讲起,如下
初始化
- int a = 0, int b = 0;
- void foo(void)
- {
- a= 1;
- b= 1;
- }
- void bar(void)
- {
- while (b == 0) continue;
- If(a == 1) {
- System.out.println(“true”);
- };
- }
int a = 0, int b = 0;
void foo(void)
{
a= 1;
b= 1;
}
void bar(void)
{
while (b == 0) continue;
If(a == 1) {
System.out.println(“true”);
};
}
以上的代码,threadA运行foo方法,threadB运行bar方法;在threadB中,能否在while循环跳出之后,即b=1的情况下,得到a一定等于1呢?答案是否定的.因为存在着编译器的乱序和cpu指令的乱序,程序没有按照我们所写的顺序执行.
2.1编译器乱序
编译器是如何乱序的?编译器在不改变单线程语义的前提之下,为了提高程序的运行速度,可以对指令进行乱序.
编译器按照一个原则,进行乱序:不能改变单线程程序的行为.
在不改变单线程运行的结果,以上foo函数就有可能有两种编译结果:
第一种:
a = 1;
b= 1;
第二种:
b = 1;
a = 1;
如上,如果cpu按照第二种编译结果执行,那么就不能正确的输出”true”.虽然乱序了,但是并没有改变单线程threadA执行foo的语义,所以上面的重排是允许的.
2.2.cpu乱序
2.2.1 cpu的结构与cpu乱序:
首先来了解一下x86 cpu的结构:
c1,c2,c3 .. cn是160个用于整数和144个用于浮点的寄存器单元,用于存储本地变量和函数参数.cpu访问寄存器的需要1cycle< 1ns,cpu访问寄存器是最快的.
LoadBuffer,storeBuffer合称排序缓冲(Memoryordering Buffers (MOB)),Load缓冲有64长度,store缓冲有36长度.buffer与L1进行数据传输,cpu无须等待.
L1是本地核心内的缓存,被分成独立的32K的数据缓存和32k指令缓存.访问L1需要3cycles ~1ns.
L2缓存是本地核心内的缓存,被设计为L1缓存与共享的L3缓存之间的缓冲,L2缓存大小为256K.访问L2需要12cycles-3ns.
L3:在同插槽的所有核心共享L3缓存,L3缓存被分为数个2M的段,访问L3需要38cycles~12ns.
DRAM:就是我们通常说的内存,访问内存一般需要65ns.
cpu加载不同缓存级别的数据,从上往下,需要的时间是越来越多的,而等待对cpu来说是极大的浪费;对于不同的插槽的cpu所拥有的一级二级缓存是不共享的,而不同的缓存之间需要保证一致性,主要通过MESI协议,为了保证不同缓存的一致性也是需要付出时间的,因为不同状态之间的转换需要等待消息的回复,这个等待过程往往是阻塞的.
根据MESI协议,变量要从S状态变为M状态,本cpu要执行read andmodify ,需要发出Invalidate消息,然后等待,待收到所有其它cpu的回复之后才执行更新状态,其他操作也是如此,都有会等待的过程。这些过程都是阻塞的动作,无疑会给cpu的性能带来巨大的损耗。
经过不断地改进,在寄存器与cache之间加上loadbuffer、storebuffer,来减小这些阻塞的时间。cpu要读取数据时,先把读请求发到loadbuffer中,无需等待其他cpu的响应,就可以先进行下一步操作。直到其他cpu的响应结果达到之后,再处理这个读请求的结果。cpu写数据时,就把数据写到storebuffer中,此时cpu认为已经把数据写出去了,待到某个适合的时间点(cpu空闲时间),在把storebuffer的数据刷到主存中去。
根据以上的描述,可知在storebuffer中的数据有临时可见性问题。即在storebuffer中的数据对本cpu是可见的(本cpu读取数据时,可以直接从storebuffer中进行读取),其它槽的cpu对storebuffer中的数据不可见。loadbuffer中的请求无法取到其他cpu修改的最新数据,因为最新数据在其他cpu的storebuffer中。同时,在loadbuffer、storebuffer中的"请求"的完成都是异步的,即表现为它们完成的顺序是不确定的,指令的乱序。
2.2.2 cpu乱序
cpu乱序的一种情况:
Processor 0
Processor 1
mov [ _x], 1
mov [ _y], 1
mov r1, [ _y]
mov r2, [_x]
Initially x == y == 0
解释一下:x,y表示内存中的变量,r1,r2表示寄存器, mov [_x],1,表示把1赋值给x变量,从顺序执行的角度来说,结果肯定是r1=1,或r2 =1;然而r1=0并且r2=0是存在的.
cpu实际执行的情况可能如下
Processor 0
Processor 1
mov r1, [ _y] // (1)
mov r2, [_x] // (2)
mov [ _x], 1 // (3)
mov [ _y], 1 // (4)
如上(1),(2),(3),(4)的顺序执行,便会得到r1=0,r2=0.可以这么理解,大部分的处理器,在保证单线程执行语义正确的基础上,会根据一定的规则对指令进行重排.
为什么会出现上面的情况?
因为cpu会将写放入storebuffer中,然后立刻返回不会等待,会将读放入load buffer中,然后立刻返回,这两个队列都是异步的,他们写读的是不同地址,不存在依赖,两者谁先完成是不知道的,如果读指令先完成了,就出现上面的重排的情况.
3. 编译器屏障与内存屏障
由于存在着编译器对程序指令的重排,这个需要编译器屏障来保证程序的正确执行,cpu也会对指令进行重排,这个需要内存屏障来保证.所以屏障需要对编译器和内存同时起作用,来保证程序可以正确执行.
因为我们大部分的软件主要都是在x86_linux上运行的,所以我们主要研究的是x86的相关编译器屏障和内存屏障.
3.1 编译屏障
Open jdk9中x86_linux的栅栏指令编写如下:
static inline void compiler_barrier() {
__asm__ volatile ("" : : : "memory"); /*编译器屏障*/
}
此处 __asm__ volatile("" : : : "memory"); 是内嵌汇编.
解释:
__asm__ :代表汇编代码开始.
__volatile__:禁止编译器对代码进行某些优化.
memory: memory代表是内存;这边用”memory”,来通知编译器内存的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取).因为存在着内存的改变,不可以将前后的代码进行乱序.
asm volatile("" :::"memory"),这句内嵌汇编作为编译器屏障,可以防止编译器对相邻指令进行乱序,但是它无法阻止CPU的乱序;也就是说它仅仅禁止了编译器的乱序优化,不会阻止CPU的乱序执行。
以下利用asm volatile("" ::: "memory")来验证,对于屏障之后的读,都是读取的主存,而不是从寄存器中读取的.当然如果您不懂汇编和c++这部分您可以跳过.
实验一:
- #include <stdio.h>
- int foo = 10;
- int bar = 15;
- int main(void)
- {
- int ss = foo + 1;
- __asm__ __volatile__("":::"memory");
- int foo1 = foo + 2;
- printf("ss=%d\n", ss);
- printf("foo1=%d\n", foo1);
- return0;
- }
#include <stdio.h> int foo = 10; int bar = 15; int main(void) { int ss = foo + 1; __asm__ __volatile__("":::"memory"); int foo1 = foo + 2; printf("ss=%d\n", ss); printf("foo1=%d\n", foo1); return0; }
编译命令:
g++ -S -O2 test1.cpp
编译结果的部分汇编:
- main:
- .LFB30:
- .cfi_startproc
- movl foo(%rip), %eax //将foo变量从内存加载到寄存器eax中
- pushq %rbx
- .cfi_def_cfa_offset 16
- .cfi_offset 3, -16
- leal 1(%rax), %edx //将rax变量加1赋值给edx寄存器也是ss变量. //rax是64位寄存器,它的低32位是eax寄存器.
- movl foo(%rip), %eax//将foo变量从内存加载到寄存器eax中
- movl $.LC0, %esi
- movl $1, %edi
- leal 2(%rax), %ebx//将rax变量加2赋值给edx寄存器也是foo1变量.
- xorl %eax, %eax
- call __printf_chk
- movl %ebx, %edx
- movl $.LC1, %esi
- movl $1, %edi
- xorl %eax, %eax
- call __printf_chk
- xorl %eax, %eax
- popq %rbx
- .cfi_def_cfa_offset 8
- ret
- .cfi_endproc
main:
.LFB30:
.cfi_startproc
movl foo(%rip), %eax //将foo变量从内存加载到寄存器eax中
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
leal 1(%rax), %edx //将rax变量加1赋值给edx寄存器也是ss变量. //rax是64位寄存器,它的低32位是eax寄存器.
movl foo(%rip), %eax//将foo变量从内存加载到寄存器eax中
movl $.LC0, %esi
movl $1, %edi
leal 2(%rax), %ebx//将rax变量加2赋值给edx寄存器也是foo1变量.
xorl %eax, %eax
call __printf_chk
movl %ebx, %edx
movl $.LC1, %esi
movl $1, %edi
xorl %eax, %eax
call __printf_chk
xorl %eax, %eax
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
实验二:
- #include <stdio.h>
- int foo = 10;
- int bar = 15;
- int main(void)
- {
- int ss = foo + 1;
- int foo1 = foo + 2;
- printf("ss=%d\n", ss);
- printf("foo1=%d\n", foo1);
- return0;
- }
#include <stdio.h>
int foo = 10;
int bar = 15;
int main(void)
{
int ss = foo + 1;
int foo1 = foo + 2;
printf("ss=%d\n", ss);
printf("foo1=%d\n", foo1);
return0;
}
编译命令:
g++ -S -O2 test2.cpp
编译结果的部分汇编:
- main:
- FB30:
- .cfi_startproc
- pushq %rbx
- .cfi_def_cfa_offset 16
- .cfi_offset 3, -16
- movl foo(%rip), %ebx //将foo变量从内存加载到寄存器ebx中
- movl $.LC0, %esi
- movl $1, %edi
- xorl %eax, %eax
- leal 1(%rbx), %edx//将rbx寄存器加1赋值给edx寄存器也是ss变量. //rbx是64位寄存器,它的低32位是ebx寄存器.
- call __printf_chk
- leal 2(%rbx), %edx//直接取rbx寄存器,加2赋值给foo1变量
- movl $.LC1, %esi
- movl $1, %edi
- xorl %eax, %eax
- call __printf_chk
- xorl %eax, %eax
- popq %rbx
- .cfi_def_cfa_offset 8
- ret
- .cfi_endproc
main:
.LFB30:
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movl foo(%rip), %ebx //将foo变量从内存加载到寄存器ebx中
movl $.LC0, %esi
movl $1, %edi
xorl %eax, %eax
leal 1(%rbx), %edx//将rbx寄存器加1赋值给edx寄存器也是ss变量. //rbx是64位寄存器,它的低32位是ebx寄存器.
call __printf_chk
leal 2(%rbx), %edx//直接取rbx寄存器,加2赋值给foo1变量
movl $.LC1, %esi
movl $1, %edi
xorl %eax, %eax
call __printf_chk
xorl %eax, %eax
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
结论:第一实验加入了内存屏障中,两次分别从内存中加载foo变量到寄存器中,然后进行操作,第二个实验中取出了内存屏障,第一次将foo变量从内存中加载到了寄存器中,进行第一次操作,然后第二次并没有从内存中加载,而是直接去寄存器中的值去操作,由此可以看出屏障的后面是不可以缓存变量在寄存器中的,而是屏障后面的变量是需要重新从主存中加载的.
3.2 Java的内存屏障
从上文可知:一个load操作需要进入loadbuffer中的,然后在从内存中去加载,一个store操作需要进入storebuffer然后在写入内存.而两个buffer之间是异步,导致出现了不同的乱序(重排),java定义了一系列的内存屏障来指定指令的执行顺序.
Java中的内存中的内存屏障:
LoadLoad 屏障
StoreStore屏障
LoadStore 屏障
StoreLoad屏障
序列
Load1,Loadload,Load2
Store1,StoreStore,Store2
Load1,LoadStore,Store
Store1,StoreLoad,Load
作用
保证Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。
保证Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见
确保Load1的数据在Store2和后续Store指令被刷新之前读取。
确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。
对buffer的影响
在Load buffer插入屏障,清空屏障之前的Load操作,然后才能执行屏障之后的Load操作.
在Store buffer插入屏障,清空屏障之前的Store操作,然后才能执行屏障之后的store操作.
在Load buffer插入屏障,清空屏障之前的Load操作,然后才能执行屏障之后的Store操作.
在Load buffer, Store buffer中都插入屏障,必须清空屏障之前的Load操作并且清空屏障之前的store操作,然后才能执行屏障之后的Load操作,或store操作.
Store