java虚拟机晚期优化-运行期优化方法
Posted 悦码
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java虚拟机晚期优化-运行期优化方法相关的知识,希望对你有一定的参考价值。
所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有对象分配在堆上也逐渐变得不是那么“绝对”了。
然后有个标注指向了11章。其实我只是想先看看为什么会不那么“绝对”的,结果看到最后才找到为什么,那文章也看完了,就总结下来吧。
解释器和编译器
在主流虚拟机中(比如Hotspot)解释器和编译器是共存的,同时内置了两个编译器Client Compiler(C1编译器)和Server Compiler(C2编译器)
为了在程序启动速度和执行效率之间权衡,jvm将编译划分了三个层次
1.第0层:程序解释执行,没有性能监控
2.第1层:C1编译
字节码编译成本地代码
进行简单可靠的优化
必要时开启性能监控
3.第2层:C2编译
字节码编译成本地代码
优化过程复杂耗时
开启性能监控
进行不可靠的激进优化
编译触发条件
java程序在虚拟机中最初通过解释器执行,遇到“热点代码”则通过JIT即时编译器(Just In Time Compiler)编译执行。
编译过程是发生在执行过程中的,在编译过后栈帧上的方法会被替换,被称为栈上替换(On Stack Replacement,简称OSR)
热点代码有两类:
被多次调用的方法
被多次执行的循环体
热点探测方法主要有两种:
采样热点探测:周期检查各个线程栈顶,经常出现在栈顶的方法就是热点方法。
基于计数器的热点探测:统计方法调用次数,超过阈值就认为是热点方法。
Hotspot中使用的是第2种热点探测方法。
计数器有两种:
1.方法调用计数器
记录的是方法的调用次数,在方法调用时,如果方法没有被编译过则计数器加1,之后判断计数器是否超过阈值,超过就进行编译,否则就解释执行。
2.回边计数器
记录的是循环体的调用次数,在循环调用时,如果代码片段没有被编译过则计数器加1,之后判断计数器是否超过阈值,超过就进行编译,否则就解释执行。
以上两种计数器在超过阈值之后都会通过OSR的方式进行编译。
总结
java虚拟机方法执行过程: 进入方法,判断是否存在这个方法的编译过的版本,如果存在,就执行编译的本地代码。如果不存在编译版本则更新计数器,根据计数器阈值判断是否需要进行编译,然后解释执行。
java循环执行过程: 遇到回边指令,判断是否存在编译后的本地代码,如果存在,执行本地代码。如果不存在则更新回边计数器,根据计数器阈值判断是否需要进行编译,然后解释执行。
那看到晚期优化对方法和循环的执行热度进行判断,如果“过热”就编译成本地代码进行OSR,这样运行效率必定提高。
编译过程
理解了优化的过程,也就是根据热度来编译成本地代码,那编译过程是怎样的呢?分开来看C1和C2编译器:
C1编译器是简单快捷的三段式编译器:
在字节码基础上完成部分基础优化:方法内联、常量传播等,优化完成之后构造成高级中间代码表现形式HIR(High-Level Intermediate Representaion)
在HIR基础完成另外一些优化:空值检查消除、范围检查消除等,之后产生低级中间代码表现形式LIR(Low-Level Intermediate Representaion)
最后在LIR上分配寄存器、完成窥孔优化,产生机器代码。
C2编译器一般用在服务器上,是充分优化过的编译器。它的优化过程比较缓慢,在C2中会执行很多经典优化动作。
优化技术
上边总结了一下晚期编译的整体过程,包括编译的层次、编译触发的条件、热点探测技术和编译过程。那上边只是流程性的东西,当然计数器还是很重要的东西,在server端使用C2的话,可以通过调整计数器来根据服务器资源调整编译触发条件,大家有兴趣可以去研读原著。
接下来总结下最具代表性的优化技术,分别是:
语言无关的经典优化技术之一:公共子表达式消除
语言相关的经典优化技术之一:数组范围检查消除
最重要的优化技术之一:方法内联
最前沿的优化技术之一:逃逸分析
公共子表达式消除
举例:
int d = (c * b) * 12 + a + (a + b * c);
虚拟机发现c * b和b * c结果一样,并且 b和c的值在计算期间不会发生变化
那b * c的结果可能会被E替代,E就是公共子表达式
b * c的运算会被消除,如下
int d = E * 12 + a + (a + E)
这时,编译器还会进行另外一种优化:代数简化,把表达式变为:
int d = E * 13 + a * 2
那这样计算的话,被编译成字节码后行数其实是减少了。
数组范围检查消除
在访问Java中的数组元素时,系统会进行边界检查,如果访问的下标超出边界,会抛出运行时异常:ArrayIndexOutOfBoundsException。
这样如果程序员出错会给出提示,可以避免大部分溢出攻击。
但是对于虚拟机来说,每次读写都包含一次条件判断,对于大量的访问来说,这无疑是一种性能负担。
那在java中如何做到性能和安全的平衡呢?
访问单个数组元素foo[3]的话,首先java在编译期间确定数组foo的长度,并且在编译期间根据下标3来判断是否越界,这样在运行期间就可以消除判断。
那如果在循环中访问数组元素,在编译期间根据数据流就可以确定循环变量的取值范围,如果取值范围没有越界,那整个循环在运行期就可以消除判断了。
方法内联
举例:
在编译之后,foo方法就会被内联到testInline了,如果再次执行testInline,foo方法是不会再被调用了。
java面向对象思想中,方法的调用者的类型在编译期间并不能确定,那在内联的时候,应该用哪个对象的方法内联呢?这导致了不确定性。那是不是就没办法执行方法内联的优化手段了呢?
Java团队已经解决了这个问题,引入了“类型继承关系分析”(Class Hierarchy Analysis, CHA)技术。基于整个应用程序的类型分析技术,用于确定在目前已知类中,某个接口是否有多于一种的实现,某各类是否存在子类,子类是否为抽象类等信息。
在进行方法内联时,如果遇到方法是被对象调用,也就是虚方法的时候,则向CHA查询。
如果只有一个版本,直接进行内联
如果有多个版本,使用内联缓存完成内联。
内联缓存是记录上次方法调用者的信息。
那大家看到无论哪种内联方式,都是很不可靠的,这属于“激进优化”,JVM预留了“逃生门”,也就是如果出现异常,退回到解释状态重新执行。 方法内联为其他优化手段奠定了基础,方法内联看起来简单,实际上
逃逸分析
方法逃逸:对象在方法中定义,但是被方法外部访问
线程逃逸:对象在线程内部定义,但是被其他线程访问。
逃逸分析并不是优化手段而是为其他优化手段提供依据。
由于如果完全准确的判断一个对象是否会逃逸,需要复杂的分析,这是一个耗时相对高的过程,如果分析下来没有几个对象逃逸,那就得不偿失。所以目前的虚拟机只能采用不太精准的算法来完成逃逸分析。
虽然现在逃逸分析技术不是很成熟,但是这是一个发展方向,如果完成了逃逸分析可以基于分析结果执行如下高效优化:
栈上分配:如果确定对象在方法内不会逃逸,那这个对象可以分配在栈上,随着方法调用结束,栈帧出栈,对象也被销毁。这样减轻了分代收集的压力,同时也提升了运行效率。
同步消除:因为同步是耗时的过程,如果确定某个变量不会在线程内逃逸,那肯定被其他线程访问到,也就不会有竞争条件,是线程安全的,则可以取消同步。
标量替换:标量就是无法再分解成更小的数据了,比如int/long等。如果对象不会逃逸,并且对象成员变量拆散都恢复到了原始变量,那对象可以不用创建,直接创建若干个标量来执行逻辑。这样可以做到成员变量直接在栈上读写并且为后续优化提供基础。
以上是关于java虚拟机晚期优化-运行期优化方法的主要内容,如果未能解决你的问题,请参考以下文章