JIT编译优化之方法内联

·  阅读 264

如何实现方法调用

要如何实现方法调用呢?最直接的方法就是可以把调用的函数指令,直接插入在调用函数的地方,然后在编译器编译代码的时候,直接就把函数调用变成对应的指令替换掉,但是如果函数 A 调用了函数 B,然后函数 B 再调用函数 A,我们就得面临在 A 里面插入 B 的指令,然后在 B 里面插入 A 的指令,这样就会产生无穷无尽地替换。这样就像将两面镜子面对面放着,可以看到无穷无尽的镜子一样。如何解决这个问题呢?内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构。栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶。这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈。

int static add(int a, int b){ 
	return a+b;
}
int main(){ 
	int x = 5; 
	int y = 10; 
	int u = add(x, y);
}
复制代码

编译后

int static add(int a, int b){ 
	0: 55 push rbp 
	1: 48 89 e5 mov rbp,rsp 
	4: 89 7d fc mov DWORD PTR [rbp-0x4],edi 
	7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi 
   return a+b; 
 	a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4] 
 	d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 
 	10: 01 d0 add eax,edx} 
 	12: 5d pop rbp 
 	13: c3 ret 0000000000000014 <main>:
int main(){ 
	14: 55 push rbp 
	15: 48 89 e5 mov rbp,rsp 
	18: 48 83 ec 10 sub rsp,0x10 
   int x = 5; 
	1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5 
   int y = 10; 
	23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa 
   int u = add(x, y); 
	2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8] 
	2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 
	30: 89 d6 mov esi,edx 
	32: 89 c7 mov edi,eax 
	34: e8 c7 ff ff ff 
   call 0 
	39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax 
	3c: b8 00 00 00 00 mov eax,0x0
} 
	41: c9 leave 
	42: c3 ret
复制代码

首先明白两个概念:

  • EBP/RBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。(64位机器变为RBP)
  • ESP/RSP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。(64位机器变为RSP)

call指令相当于 push rip 和 jmp 的结合,rip 是指令地址寄存器,也就是将当前执行函数PC寄存器中的指令地址入栈,并跳转到相应函数处执行,而 ret 指令相当于 pop rip 和 jmp指令的结合, rbp 和 rsp 用于维护当前帧栈,rbp 指向栈帧的栈底地址,rsp 指向栈顶地址,push pop 和 mov rbp,rsp 主要是为了从卖你函数的栈帧调整为add函数的栈帧。 具体调用过程如下所示

方法调用指令执行过程

方法内联

上文中提到方法调用最简单的方法就是把被调用函数的指令,直接插入在调用函数的地方,虽然这个方法在一些调用关系比较复杂的场景中存在问题,但是对于调用关系比较简单,如上文中add方法,在add方法没有调用其他方法,那么直接将add方法的指令插入main方法中是可行的,这就是很多编译器进行优化的重要场景之一。JVM中大名鼎鼎的JIT便提供这种优化能力。 上文中的例子如果在编译过程中加上优化编译的参数

$ gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o
复制代码

那么在main函数调用add方法时不会被编译为call指令,而是直接调用add指令,即将add方法的指令插入了main方法中。 方法内联会给我们带来哪些收益呢?

  • CPU需要执行的指令变少了,显而易见,方法内联的方式不需要调用call和ret等指令;
  • 不需要根据地质进行跳转了,所有指令顺序执行;
  • 函数的入栈和处栈不需要了。

总的来说就是方法执行的效率提高了,但是方法内联也是有利有弊的,那么方法内联会带来哪些问题呢?

  • 如果一个内联方法被调用的地方比较多,那么它的指令需要在很多地方被插入,那么整个应用程序占用的内存空间就变大了;
  • 如果一个内敛方法存在继承关系,那么可能需要引入类型判断的额外开销达不到性能优化的目的。

权衡方法内联的利弊,可以总结一下最佳实践

  • 方法体应该尽可能小,我们熟悉的阿里巴巴开发规约中也对方法体的进行了约束,除了可读性、可维护性方面的考虑,也涉及到底层的编译优化;
  • 尽量使用final、private、static来修饰方法,避免继承带来的额外开销;
  • 按需进行JVM参数优化,常见优化参数如下所示
-XX:+PrintCompilation //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来
-XX:CompileThreshold //参数设置识别为热点方法的阈值
-XX:MaxFreqInlineSize=N //如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联
-XX:MaxInlineSize=N //如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联
复制代码

参考资料

java程序猿-你的代码居然慢在JIT方法内联上

极客时间-《深入浅出计算机组成原理之函数调用:为什么会发生stack overflow?》

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改