阅读 91

深入理解JVM-前端编译与后端编译

前端编译

前端编译指的是把Java代码编译成字节码的过程,一般来说,这个过程很难进行优化,因为它仅仅是一个“翻译过程”,前端编译也负责提供各种语法糖支持,以及类型检查等特性,而本读书笔记重在了解性能优化和底层原理,遂跳过了这一部分。望读者知悉。

后端编译

一般来说,后端编译指的是把class文件编译为平台相关的二进制代码的过程,而在Java中,虽然后AOT(Ahead Of Time)等技术,但是即时编译占主流,我们就不可避免的多谈论它。

即时编译器

对主流虚拟机而言,程序一般都是先以解释运行的方法编译运行的,而在运行一段时间之后,会把热点代码使用即时编译编译以提高性能。

解释器和编译器

解释执行器除了作为“快速启动”来使用,也会在即时编译器优化过当的情况下充当“备选”来保证程序继续执行。一般来说,即时编译器的优化比较激进,可以达到C/C++的O2级别优化。

HotSpot虚拟机内置了两个即时编译器,其实还有第三个,不过目前处于实现阶段,日后可能发布。先说这两个。它们分别是客户端编译器和服务端编译器,又称C1,C2编译器

因为即时编译耗时长,同时又需要通过解释执行收集性能信息,所以通过解释执行器和即时编译器的搭配,引入了分层编译的模型。

分层编译详细细节略去不表,请读者知悉。

一般来说,C1编译器的编译速度快,优化程度不高,C2则与之相对,在分层编译中的起始阶段,可以是C1进行简单编译争取足够的时间,C2再登场进行深度编译。

编译对象与触发条件

前面提到的会被即时编译编译的热点代码,一般是:

  • 1⃣️重复调用的方法
  • 2⃣️循环体

虽然循环体处于一个代码块中,但是对循环体的优化是以循环体所在的方法为单位进行的。也就是说这两种热点代码都会触发方法体的即时编译。

对于方法的编译,没什么好说的,直接编译然后后续调用;但是对于循环的编译,则是要进行栈上替换操作,即在方法执行时就把方法替换成即时编译的代码了。

确定某个方法或某个循环体是不是热点代码的方式为热点探测技术。目前有两种热点探测技术:

  • 1⃣️基于采样。通过周期性检查线程调用栈顶来实现,如果某个方法经常出现在栈顶,则说明它可能是热点方法。
  • 2⃣️基于计数器。为某个方法或代码块设置一个计数器,统计调用次数。

这两种方法各有好坏。在这里我们以HotSpot为例进行分析。HotSpot使用的是基于计数器的热点探测技术。维护两个计数器:方法调用计数器回边计数器

方法调用计数器:统计方法调用次数并在某一个方法的方法调用计数器和回边计数器之和大于方法计数器的阀值时触发即时编译。

image.png

回边计数器:在某个代码块循环次数到达阀值触发即时编译,进行栈上替换。

image.png

在进行即时编译时,解释器会继续执行代码,而不用等待编译完成。

编译过程

一般来说,触发即时编译后,即时编译会在后台编译线程中运行,程序继续使用解释器运行代码。具体的编译过程视虚拟机实现而定,且比较低层和复杂,在此不表。

提前编译器

AOT(Ahead Of Time)提前编译技术,最好的引用在安卓上,比较好的体现就是ART技术。

提前编译的优劣得失

提前编译有两种方式:

  • 1⃣️如同GCC/G++一样把程序直接静态翻译成机器码来运行。
  • 2⃣️提前把即时编译要做的工作完成并保存编译结果,需要使用时直接载入。

第一种方式其实直击即时编译的弱点:编译过程占用程序执行时间。

而第二种方式比较干脆,甚至可以称为即时编译缓存。

第一种像一口气直接编译完,第二种则像是模块化编译,运行至不编译,直接拿编译好的模块使用。但都是提前编译完再处理。

相比之下即时编译器好像是个累赘,事实果真如此吗?来看看即时编译器相对于AOT的优势:

  • 1⃣️性能分析指导优化。
  • 2⃣️激进预测性优化。此方式可以保证大部分有内联价值的虚方法都可以被内联,而内联对于优化来说相当重要。
  • 3⃣️链接时优化。

编译器优化技术

优化技术有很多,我们仅看一些最应该了解的技术:

  • 最重要的优化技术:方法内联
  • 最前沿的优化技术:逃逸分析
  • 语言无关的优化技术:公共子表达式消除
  • 语言相关的优化技术:数组边界检查消除

方法内联

方法内联是最重要的优化技术,其他所有优化技术都需要基于它才能实现。方法内联说白了就是把要调用的方法的代码复制到当前方法中来,这样就可以不进行调用,直接继续执行了。正是因为它避免了实际的方法调用的发生,所以很大程度的提高了性能。

P.S.因为每次方法调用意味着压栈,分配栈帧,设置局部变量表,操作数栈等一堆,即使是编译成了二进制代码,那还是需要压栈,设置参数,传递参数等一堆balabala的操作。

哪些方法可以内联呢?所有的静态方法,构造器,私有方法,父类方法,final修饰的虚方法这5个方法是非虚的,可以直接被内联。到此就结束了吗?那未免优化起来也太没意思了。虚方法:您看我还有机会吗?

在此之前需要明确,虚方法指的是需要在运行时动态确定方法接收者的方法(就是Java的运行时多态,确定具体使用哪个子类的方法)。那既然这都说了,需要在运行时确定集体使用哪个方法,那就是说编译时我也不知道我到底应该内联哪个子类的方法实现。

但是我们知道,很多时候,整个App只会用某一个子类的实现,或者干脆一点,这个类只有一个子类,那我们具体调用哪个就是唯一确定的了。此时就能直接进行内联。这里的一个典型就是Spring的单例模式。

说了这么多,来看看JVM怎么做的。为了解决虚方法的内联问题,JVM引入了一种称为类型继承关系分析(CHA) 的技术来进行分析。听名字就差不多能猜出来这玩意是干嘛的了。它就是进行继承分析,然后把只有一个实现的,或者可以唯一确定的虚方法调用内联起来。不过这属于激进优化,因为继承关系也可能会动态更改,比如动态代理的存在,此时需要退回到解释器执行。

此外,如果遇到了多版本选择,还会再进行一次内联方法缓存的行为,进一步优化;如果还是不行,对于方法接收者不一致的情况,才会真的使用查虚方法表的方式处理。

逃逸分析

逃逸分析在很多语言上都可以看到,它说白了就是分析某一个变量的作用域,找出它可能作用的范围。比如方法A定义了一个变量,方法B进行了引用,我们就可以说这给变量逃逸到了方法之外。类似的还有线程逃逸。

有三种不同程度的逃逸:从不逃逸,方法逃逸,线程逃逸。针对不同的逃逸情况,可以对变量的空间分配做不同程度的优化:

  • 栈上分配。当一个变量不会逃逸出线程时,可以使用栈上分配,这样随着线程的结束,空间会自动释放;更细一些,如果某个方法的变量不会逃逸出方法,那么可以在这个方法的栈帧上分配,这样在方法结束后即可回收空间。
  • 标量替换。如果一个变量小到无法分解,比如基本数据类型和引用类型,就可以称之为标量。反之,比如对象还能继续分解,就可以称为聚合量。而把一个聚合量拆分成许多个标量进行访问,要求这个聚合量不能逃逸出方法,此时就可以不创建对象,而仅仅创建标量来访问。
  • 同步消除。如果一个共享变量确定不会逃逸到其他线程,就没必要进行同步,便可以删除同步操作。

逃逸分析的算法计算成本非常高,想要使用它,必须确保使用后带来的收益可以抵消成本才行。所以目前它仍处于实验阶段。

公共子表达式消除

如果一个表达式E的值被计算过了,且E的构成变量没有改变,那么下次使用E是可直接进行值替换。E的这次出现称为公共子表达式。

数组边界检查

会在编译时判断循环边界,或者一些其他试图访问数组边界的情况,进行安全检查,可以减少运行时的数组异常判断。

另一种称为隐式异常处理的处理方式,会假设要处理的异常极少出现,然后使用操作系统层次的异常处理来进行捕获,每次假定异常不会发生,直接进行下一步,而在真正发生时会触发一个中断,进而引发中断处理程序处理。这是一种代价很高的处理方式,尽在能确定要捕获的异常极少发生时才会这么做。

来看一个例子,也是很常见的判空检查:

if (foo != null) {
    return foo.value;
} else {
    throw new NullPointException();
}
复制代码

有了隐式异常处理,则可以这么做:

try {
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}
复制代码

当foo真的为空时,会触发一个段错误,进而使用中断处理程序处理异常。

文章分类
后端
文章标签