重排序
文章目录
- 重排序
- 什么是重排序
- 重排序遵守的规则
- 数据依赖性
- as-if-serial 语义
- 为什么会出现重排序
- 重排序对多线程操作的影响
什么是重排序
重排序指的是在执行程序时,为了提高性能,从源代码到最终执行指令的过程中,编译器和处理器会对指令进行重排的一种手段。
下图为从源代码到最终指令示意图
重排序的分为3种
- 编译器优化的重排序:编译器在不改变单线程程序语义(as-if-serial)的的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现在处理器采用指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用了存储和读写缓冲区,这使得加载和存储操作看上去乱序执行。
重排序遵守的规则
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
编译器和处理器在重排序时,针对单个处理器中执行的指令序列和单个线程中执行的操作会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial 语义
as-if-serial 语义的意思指:不管编译器和处理器为了提高并行度怎么重排序,(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守as-if-serial 语义,编译器和处理器就通过对操作的数据依赖关系进行判断,如果操作之间存在依赖关系,就不会对其进行重排序。
经典例子:
int a=1; // 操作1
int b=2; // 操作2
int b=a*c; // 操作3
操作3依赖于操作1和操作2,而操作1与操作2之间不存在数据依赖关系。因此在最终执行的指令序列中,操作3不能出现在操作1和操作2之前,可以出现以下两种顺序。
操作1-->操作2-->操作3
操作2-->操作1-->操作3
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
在as-if-serial 语义中允许对存在控制依赖关系的操作做排序。
class Example{
int a = 1;
boolean flag = flase;
...
pub void a(){
if(flag){ //操作1
int i = a*a; //操作2
...
}
}
...
}
上述代码中操作1和操作2之间存在控制依赖关系,但是不存在数据依赖。为了提高指令的并行执行度,在程序中出现控制依赖关系的时候,编译器和处理器会采用猜测()来执行。以处理器猜测执行为例,处理器可以提取读取并计算a*a,计算结果存在叫做重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当操作2判断为真的时候,在把结果写入变量i。
常见的处理器重排序规则,N代表不允许两个操作重排,Y代表两个操作可以重排。
Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 | |
SPARC-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
IA86 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
为什么会出现重排序
由于CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,为了让缓存更合理的被利用起来,就出现了编译程序优化指令执行次序。
// todo 此处不解释重排序怎么提升执行效率的。有空去找资料补充。
重排序对多线程操作的影响
从一个线程中观察其他线程,所有操作都是无序的。这句话就是重排序对多线程操作的影响。
// todo 在这里没有补充对上述影响的演示内容,可以去搜索。
为了消除这种影响:
- 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
- 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。 |
StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
执行
主要来自《Java并发编程的艺术》 方腾飞 魏鹏 程晓明版