CPU 性能优化手段 - 运行时指令重排序
编译器生成指令的次序,可以不同于源代码所暗示的“显然”版本。
重排后的指令,对于优化执行以及成熟的全局寄存器分配算法的使用,都是大有脾益的,它使得程序在计算性能上有了很大的提升。
指令重排的场景
当CPU写缓存时发现缓存区块正被其他CPU占用,为了提高CPU处理性能, 可能将后面的读缓存命令优先执行。
- 比如:
- 并非随便重排,需要遵守
- as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
编译器,runtime 和处理器都必须遵守as-if- serial语义。
也就是说:编译器和处理器不会对存在数据依赖关系的操作做重排
重排序类型
包括如下:
- 编译器生成指令的次序,可以不同于源代码所暗示的“显然”版本。
- 处理器可以乱序或者并行的执行指令。
- 缓存会改变写入提交到主内存的变量的次序。
问题
CPU执行指令重排序优化下有一个问题:
虽然遵守了as-if-serial语义,单仅在单CPU自己执行的情况下能保证结果正确。
多核多线程中,指令逻辑无法分辨因果关联,可能出现乱序执行,导致程序运行结果错误。
有序性:即程序执行的顺序按照代码的先后顺序执行
使用volatile变量的第二个语义是禁止指令重排序优化
普通变量仅保证该方法执行过程所有依赖赋值结果的地方能获取到正确结果,而不保证变量赋值操作的顺序与代码执行顺序一致
因为在一个线程的方法执行过程中无法感知到这一点,这也就是JMM中描述的所谓的
线程内表现为串行的语义(Within-Thread As-If-Serial Sematics)
实例
Map configOptions; char[] configText; //此变量必须定义为volatile volatile boolean initialized = false; //假设以下代码在线程A中执行 //模拟读取配置信息,当读取完成后 //将initialized设置为true来通知其它线程配置可用 configOptions = new HashMap(); configText = readConfigFile(fileName); processConfigOptions(configText, configOptions); initialized = true; // 假设以下代码在线程B中执行 // 等线程A待initialized为true,代表线程A已经把配置信息初始化完成 while(!initialized) { sleep(); } //使用线程A中初始化好的配置信息 doSomethingWithConfig();
如果定义initialized
时没有使用volatile
,就可能会由于指令重排序优化,导致位于线程A中最后一行的代码initialized = true
被提前执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile
关键字则可完美避免。
volatile变量读操作性能消耗与普通变量几乎无差,但写操作则可能稍慢,因为它需要在代码中插入许多内存屏障指令保证处理器不乱序执行。即便如此,大多数场景下volatile的总开销仍然要比锁小,在volatile与锁之中选择的唯一依据仅仅是volatile的语义能否满足使用场景的需求:
volatile修饰的变量,赋值后(前面mov %eax,0x150 (%esi) 这句便是赋值操作) 多执行了一个1ock add1 $ 0x0,(%esp),这相当于一个内存屏障(Memory Barrier/Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU 访问内存时,并不需要内存屏障
但如果有两个或更多CPU 访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了
这句指令中的add1 $0x0, (%esp)(把ESP 寄存器的值加0) 显然是一个空操作(采用这个空操作而不是空操作指令nop 是因为IA32手册规定lock前缀不允许配合nop 指令使用),关键在于lock 前缀,查询IA32 手册,它的作用是使得本CPU 的Cache写入内存,该写入动作也会引起别的CPU 或者别的内核无效化(Inivalidate) 其Cache,这种操作相当于对Cache 中的变量做了一次store和write。所以通过这样一个空操作,可让前面volatile 变量的修改对其他CPU 立即可见。
那为何说它禁止指令重排序呢?
硬件架构上,指令重排序指CPU 采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。
但并非说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。
譬如指令1把地址A中的值加10,指令2把地址A 中的值乘以2,指令3把地址B 中的值减去了,这时指令1和指令2是有依赖的,它们之间的顺序不能重排,(A+10) 2 与A2+10显然不等,但指令3 可以重排到指令i、2之前或者中间,只要保证CPU 执行后面依赖到A、B值的操作时能获取到正确的A 和B 值即可。所以在本CPU 中,重排序看起来依然是有序的。因此lock add1 $0x0,(%esp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果
举个例子
int i = 0; boolean flag = false; i = 1; //语句1 flag = true; //语句2
从代码顺序上看,语句1在2前,JVM在真正执行这段代码的时候会保证**语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)
比如上面的代码中,语句1/2谁先执行对最终的程序结果并无影响,就有可能在执行过程中,语句2先执行而1后虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,**靠什么保证?数据依赖性
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
举例
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。
因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。
但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果
但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。这是就需要内存屏障来保证可见性了
回头看一下JMM对volatile 变量定义的特殊规则
假定T 表示一个线程,V 和W 分别表示两个volatile变量,那么在进行read, load, use,assign,store,write时需要满定如下规则
只有当线程T 对变量V 执行的前一个动作是load ,线程T 方能对变量V 执行use;并且,只有当线程T 对变量V 执行的后一个动作是use,线程T才能对变量V执行load.线程T 对变量V 的use可认为是和线程T对变量V的load,read相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值语,用于保证能看见其他线程对变量V所做的修改后的值)
只有当线程T 对变量V 执行的前一个动作是 assign ,线程T才能对变量V 执行store
并且,只有当线程T对变量V执行的后一个动作是store ,线程T才能对变量V执行assign
线程T对变量V的assign可以认为是和线程T对变量V的store,write相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)
假定动作A 是线程T 对变量V实施的use或assign,假定动作F 是和动作A 相关联的load或store,假定动作P 是和动作F 相应的对变量V 的read 或write
类似的,假定动作B 是线程T 对变量W 实施的use或assign 动作,假定动作G是和动作B 相关联的load或store,假定动作Q 是和动作G 相应的对变量W的read或write
如果A 先于B,那么P先于Q (这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)