java源码重编译-1. 什么是编译器重新排序?

在一些关于Java并发编程的书籍中,经常出现JMM内存模型、易失性关键字、重排序、乱序执行等词语,导致一些刚开始学习Java并发编程的男士一脸困惑:这都是什么?

文章目录

明天我们就讲那些书里提到的一个概念,编译器重排序

这里先解释一下,下面所说的编译器是指将中间语言编译成汇编语言(机器代码)的编译器(如GCC、clang、甚至JIT),而不是Javacjava源码重编译,将中间语言编译成中间语言(JVM字段代码) )编译器,其实不同的Java编译器生成的JVM字节码的方法也可能不同,但这不在本文的讨论范围之内。

注:本文实验环境基于Windows 10下wsl2下的debian版本

1.什么是编译器重排序 1.1 定义

编译器重新排序:编译器将分析中间语言(特别是本文中的C/C++)的代码。 当编译器认为你的代码可以优化时,编译器会选择优化代码并生成汇编代码。 事实上,编译器的优化是满足一定条件的。 这是著名的假设规则:

允许任何和所有不改变程序可观察行为的代码转换。

也就是说,编译器可以使用任何方法来编译代码,而不会影响这段代码的结果,这也给了编译器足够的空间来优化代码,从而提高代码的运行效率。

1.2 举个例子

下面是一段简单的C语言代码:

int a, b;
void foo(void)
{
	a = b + 11;
	b = 0;
}

这段代码的逻辑非常简单,而对于编译器来说,问题就没那么简单了。 我们来看看使用aarch64-linux-gnu-gcc使用-O2参数让编译器以O2级别优化编译上述代码(获取RAM汇编),并使用objdump工具查看foo(的反汇编结果) ):

0000000000000750 :
 750:   90000080        adrp    x0, 10000 
 754:   90000081        adrp    x1, 10000 
 758:   f947dc00        ldr     x0, [x0, #4024] // 取b内存地址
 75c:   f947e821        ldr     x1, [x1, #4048] // 取a内存地址
 760:   b9400002        ldr     w2, [x0]        // 寄存器w2 = b(内存地址)
 764:   b900001f        str     wzr, [x0]       // b(内存地址) = 0
 768:   11002c40        add     w0, w2, #0xb    // 寄存器w0 = b + 11 
 76c:   b9000020        str     w0, [x1]        // w0寄存器的值存入a(内存)
 770:   d65f03c0        ret
 774:   d503201f        nop

我们发现编译出来的汇编代码和我们之前的C语言代码的顺序并不一样,而是等价于下面的C语言代码:

	int a, b;
	void foo(void)
	{
   		b = 0;
		a = b + 11;
	}

1.3 重新排序的原因

为什么会发生这些事情?

编译器的初衷是为了提高程序在CPU上的运行性能,更好地利用寄存器和现代处理器的流水线技术,减少汇编指令的数量,减少程序执行所需的CPU周期,减少CPU读写寻址时间。 并且在多核、多线程并行的情况下,这些重新排序优化可能会导致共享变量的可见性问题。

事实上,编译器的优化不仅仅局限于代码的重新排序。 编译器会优化一些它认为不需要的变量,同时也会将一些本应从显存获取的数据存入寄存器中。 Hou可以直接从寄存器中获取(这也可能会导致多线程中共享变量的可见性问题)。

事实上,as-if规则在单核CPU时代是完全没问题的,而随着CPU的发展,出现了多核并行CPU。 这时java源码重编译,编译器重新排序可能会导致一些意想不到的问题。 这一点我们从感性认知上是可以理解的,因为在多线程编程中经常会使用一些共享变量来实现不同线程的控制或者数据传输,而如果编译器对我们精心设计的代码序列进行“优化”,就会可能会有我们不希望出现的经营结果。

1.4 不仅仅是重新排序

以后我还是想用“优化”这个词,而不是“重新排序”这个词,因为编译器的代码优化不仅仅局限于重新排序,编译器也会删除一些它认为无用的代码,更重要的是是的,将一些变量放入寄存器!

作为一个反例:

	int run = 1;
	 
	void foo(void)
	{
		while (run) // doSomething…
	          ;
	}

编译aarch64-linux-gnu-gcc–O2后,我得到:

 740:   90000080        adrp    x0, 10000 
 744:   f947e000        ldr     x0, [x0, #4032] // 取run内存值,存入x0
 748:   b9400000        ldr     w0, [x0]         // 取x0值存入w0
 74c:   d503201f        nop
 750:   35000000        cbnz    w0, 750  // 跳到750也就是本行
 754:   d65f03c0        ret

这里需要说明一下,cbnz命令是ARM汇编中的一条指令,cbnzw0,750的意思是,如果寄存器w0中的值不等于0,则跳转到上一行代码,问题就来了,在这里跳转并不会重新取显存中run的值,而是直接从寄存器取值后进行判断,也就是说这段代码理论上还是会运行的,虽然其他线程会修改run的值在视频内存中,虽然这导致了多线程中共享变量的可见性问题。

2、如何严格禁止? 2.1 编译器障碍

正如我之前所说,多线程环境下的编译器优化会导致一些问题。 有什么办法可以阻止编译器优化吗? 答案是肯定的,而且方法不止一种:

将变量声明为 volatile 变量(注:Java中的 volatile 变量更强大),并在代码中插入编译器屏障(Compiler Barrier),以防止编译器对屏障前后的代码进行优化,因此编译器屏障为也称为优化屏障(Optimization Barrier))

为了避免读者产生误解,这里解释一下:在C/C++上将变量声明为易失性,相当于在对该变量的每次操作之前和之后插入一个编译器屏障。 了解了这个前提之后,我们就可以更好的解释后续的一些概念。

编译器屏障的作用是什么? 禁止编译器对屏障前后的代码进行重新排序和优化,同时禁止编译器将变量放入寄存器然后直接使用它们。 而是需要去显存(或者CPU缓存)中取出变量值进行计算操作。 简而言之,严格禁止在编译屏障前后优化编译器的变量操作(重新排序、使用寄存器中的值)

2.2 严禁重新排序

我们来看看插入编译器屏障后上述代码的编译效果:

	#define barrier() __asm__ __volatile__("": : :"memory")
	
	int a, b;
	void foo(void)
	{
		a = b + 11;
		barrier(); // 插入编译器屏障(优化屏障)
		b = 0;
	}

编译后,没有出现重排序现象,汇编代码和C代码的顺序相同。

0000000000000750 :
 750:   90000080        adrp    x0, 10000 
 754:   90000081        adrp    x1, 10000 
 758:   f947dc00        ldr     x0, [x0, #4024] // x0存入b内存地址
 75c:   f947e821        ldr     x1, [x1, #4048] // x1存入a内存地址
 760:   b9400002        ldr     w2, [x0]        // w2存入x0值
 764:   11002c42        add     w2, w2, #0xb    // w2 = b + 11;
 768:   b9000022        str     w2, [x1]        // 内存中a = w2
 76c:   b900001f        str     wzr, [x0]       // 内存中b = 0
 770:   d65f03c0        ret
 774:   d503201f        nop

我先解释一下:

#definebarrier()__asm____易失性__("":::"memory")是内联汇编代码,__asm__代表C语言内联汇编代码,__易失性__告诉编译器不要优化这行代码,

("":::"memory") 这个就比较复杂了,这里你只需要知道这段代码的意思就是告诉编译器“内存已经改变了”,这样GCC编译的时候就会知道,就不能使用在Value中注册,但是要获取显存中的值,并且barrier前后的代码不能重排

可以看出,使用编译器屏障后,代码没有重新排序。 前面提到,编译器会对代码进行优化,把本该从显存取的变量放入寄存器中,这样编译器屏障就可以解决这个现象了吗?

2.3 严格禁止在寄存器中存储/检索值

正如本文前面提到的,编译器会将变量加载到CPU寄存器中,以减少访问显存(缓存)的时间。 然而,在某些情况下,加载寄存器会导致变量在多线程环境中不可见。

那么编译器障碍可以解决这个问题吗? 我们来看看前面插入编译器屏障后的代码:

#define barrier() __asm__ __volatile__("": : :"memory")
int run;
void foo(void)
{
        while(run)
        barrier();
}

反编译得到汇编代码:

0000000000000740 :
 740:   90000081        adrp    x1, 10000 
 744:   f947e021        ldr     x1, [x1, #4032] // 取run内存地址
 748:   b9400020        ldr     w0, [x1]         // w0寄存器取run内存值
 74c:   34000060        cbz     w0, 758  // w0为0跳转到758行
 750:   b9400020        ldr     w0, [x1]          // w0寄存器取run内存值
 754:   35ffffe0        cbnz    w0, 750  //w0不为0跳转750行
 758:   d65f03c0        ret
 75c:   d503201f        nop

添加barrier之后,汇编代码与之前相比发生了变化,主要看第754行,这次比较后,跳转到第750行,即再次取显存中的run值,然后判断是否为0与前面相比,跳转到这一行的行为相当于清除了编译器对寄存器中存储的变量的优化。

2.4 易失性(C/C++)

相应地,你也可以通过将run变量声明为易失性变量来告诉编译器这个变量无法被优化。

int volatile run;
void foo(void)
{
        while(run)
}

编译后得到汇编代码:

0000000000000740 :
 740:   90000081        adrp    x1, 10000 
 744:   f947e021        ldr     x1, [x1, #4032]   // 取run内存地址 
 748:   b9400020        ldr     w0, [x1]          // w0取run内存值
 74c:   35ffffe0        cbnz    w0, 748  // w0不为0就跳转到748行
 750:   d65f03c0        ret
 754:   d503201f        nop

可以看到,跳转到748行后,需要重新从显存中取出run的值进行比较,这和插入编译器barrier是一样的。 事实上,volatile还可以阻止编译器重新排序,读者可以自行尝试。

C/C++中的volatile关键字的作用与Java中不同。 Java中的volatile关键字相当于C/C++的增强版。 至于如何强化,稍后我会重点讲。

C/C++中的volatile关键字,正如我所说,相当于在这个变量的前后插入了一个内存屏障。 虽然这不够准确,但其核心功能是严格禁止编译器对此变量/代码块进行任何优化。 重新排序,禁止使用寄存器而不是获取内存值,并禁止编译器优化它认为无用的代码。

而在Linux内核编程中,程序员是非常谴责使用volatile关键字的。 由于Linux本身提供了各种用于同步控制的API,因此它们可以替代直接使用volatile关键字。 虽然有些Linux和JVM的设计思想仍然屏蔽了这个API的实现细节,就像JVM屏蔽了 volatile 和synchronized关键字的实现细节一样。 而且不得不说的一点是,无论是Linux还是JVM,底层都是通过编译器屏障来避免一些问题的。

三、结论

到这里编译器重排序的问题和解决方案已经说完了。 这时候有人问了,你说了这么多,好像和本文开头提到的JMM内存模型、volatile关键字等关系不大吧?

多线程可见性和顺序问题的原因之一——编译器优化,本文已经解密,JVM为了实现JMM的规则,虽然底层使用了大量的编译器屏障来阻止编译器“优化”一些代码”,但对于Java程序员来说,这是感知不到的,而且JVM的实现还考虑到了跨平台的实现:对于x86、ARM、PowerPC等平台都可以完美实现。

而多线程可见性和顺序问题还有一个原因。 这个原因基本上是CPU设计中的各种“优化”造成的。 事实上,JVM为了满足JMM模型,也煞费苦心地解决了这个问题。 在此,先保密,上一篇文章会阐明原因及其解决方案。

由于本人对ARM编译了解甚少,如果文章中存在一些问题,希望大家批评指正。

窝窝科技 – 编译器重排序 – 本文主体基于ARM(CM3)汇编指令asm__volatile__嵌入式汇编用法来讲解WhyMemoryBarriers? 英文翻译 - 好文 TheLinuxKernel - 为什么不应使用“易失性”类型类

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

悟空资源网 源码编译 java源码重编译-1. 什么是编译器重新排序? https://www.wkzy.net/game/164714.html

常见问题

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务