DepthJVM-运行期优化

来源:互联网 发布:js图片轮播和点击切换 编辑:程序博客网 时间:2024/06/05 09:32
1.解释器和编译器
当程序需要迅速启动和执行时,解释器首先发挥作用,省去编译时间,立即执行,在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码后,获取更高的运行效率。当程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行提高效率。解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候能提升运行速度的优化手段,当激进优化假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行
HotSpot内置了两个即时编译器:Client Compiler、Server Compiler,简称C1和C2,默认采用其中一个编译器与解释器直接配合的方式工作。参数-client选择C1编译器,-server选择C2编译器,即Client模式和Server模式
解释器与编译器搭配使用的方式称为“混合模式”,参数-Xint强制虚拟机运行于“解释模式”,编译器完全不介入工作;参数-Xcomp强制虚拟机运行于“编译模式”,这时优先采用编译方式执行,但解释器仍然要在编译无法进行的情况下接入执行过程
为了在程序启动速度和运行效率之间达到最佳平衡,HotSpot虚拟机采用分层编译策略(JDK1.7的Server模式默认开启),Client Compiler和Server Compiler同时工作,代码可能被多次编译,Client Compiler获取更高的编译速度,Server Compiler获取更好的编译质量,解释之星也无需再承担收集性能监控信息的任务
第0层:程序解释执行,解释器不开启性能监控功能(Profiling),触发第1层编译
第1层:C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控
第2层(以上):C2编译,字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
2.编译对象与触发条件
即时编译器编译的“热点代码”有两类:被多次调用的方法、被多次执行的循环体。前者以整个方法作为编译对象,是标准的JIT编译方式;后者尽管是循环体触发编译,但仍然以整个方法作为编译对象,这种编译方式发生在方法执行过程中,因此称之为栈上替换(OSR编译,即方法栈帧还在栈上,方法就被替换了)
判断一段代码是不是热点代码,是否触发即时编译的行为成为热点探测
基于采样的热点探测:虚拟机周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶即为热点方法,这种方法实现简单、高效,且容易获取调用关系,但很难精确确认方法热度,容器受线程阻塞和其他外界因素影响
基于计数器的热点探测:HotSpot默认,虚拟机为每个方法(甚至是代码块)建立一个计数器,如果执行次数超过一定的阈值则认为是热点方法,这种方法统计结果更加精确和严谨,但不能直接获取调用关系,且要建立并维护计数器
HotSpot为每个方法准备了两个计数器:方法调用计数器、回边计数器,超出阈值会触发JIT编译
方法调用计数器:Client1500,Server10000,-XX:CompileThrshold设置,方法被调用时检测是否有被JIT编译过的版本,有则优先使用编译后的本地代码执行,没有将方法调用计数器加1,判断方法调用计数器和回边计数器之和是否超过方法调用计数器阈值,超过则向即时编译器提交该方法的编译请求
如果没有任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释执行方法执行代码
如果没有任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一段时间内的调用次数,当超过一定时间限度时方法调用次数仍然不足以进行编译,调用计数器减少一半,称为调用计数器热度衰减,这段时间称为半衰周期。热度衰减的动作是垃圾收集时顺便进行的,-XX:-UseCounterDecay关闭热度衰减,当调用计数器统计绝对次数,只要系统运行时间足够长,绝大多数代码都会被编译为本地代码,-XX:CounterHalfLifeTime设置半衰周期时间,单位是秒
回边计数器:统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,建立回边计数器统计的目的就是为了触发OSR编译。-XX:BackEdgeThrshold设置阈值,但现在虚拟机未使用该参数,因此需要用参数-XX:OnStackReplacePercentage(Client默认933,Server默认140)间接调整阈值,计算公式:Client=-XX:CompileThrshold*-XX:OnStackReplacePercentage/100,Server=-XX:CompileThrshold*(OnStackReplacePercentage-InterpreterProfilePercentage)/100,InterpreterProfilePercentage为解释器监控比率,默认33
解释器遇到回边指令时,检测是否由被编译过的代码,有则优先执行,没有把回边计数器值加1,判断方法调用计数器和回边计数器之和是否超过回边计数器阈值,超过则提交一个OSR编译请求,并且降低回边计数器值。回边计数器没有热度衰减,统计的是方法循环执行的绝对次数
3.编译过程
编译未完成时,仍旧以解释方式执行,编译动作在后台的编译线程中进行,-XX:-BackgroundCompilation禁止后台编译,禁止后线程提交编译请求后一直等待,直到编译完成后再开始执行编译器输出的本地代码
Client Compiler是一个三段式编译器,关注局部性优化,放弃了许多耗时较长的全局优化手段
第一阶段:一个平台独立的前段将字节码构成一种高级中间代码表示(HIR),HIR使用静态但分配的形式来表示代码值,这使一些在HIR构造过程中和之后进行的优化动作更容易实现。字节码被构造成HIR之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等
第二阶段:一个平台相关的后端从HIR中产生低级中间代码表示(LIR),在此之前会在HIR上完成一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的表示形式
第三阶段:在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生极其代码
过程:字节码--(方法内联、常量传播)-->HIR--(空值检查消除、范围检查消除)-->优化后的HIR-->LIR--(寄存器分配、窥孔优化、机器码生成)-->本地代码
Server Compiler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除,另外还可以根据解释器或Client Compiler提供的性能监控信息进行一些不稳定的激进优化,如守护内联、分支频率预测等
Server Compiler的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合。以即时编译标准来看,Server Compiler是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销·
4.编译优化技术
针对即时编译器,Javac不会进行编译优化,因此即时编译器产生的本地代码比Javac产生的字节码更加优秀
公共子表达式消除:如果一个表达式E已经计算过了,且从先前的计算到现在E中所有变量值都没变,那么E的这次出现就成为了公共子表达式,不再次计算,直接用前面计算过的表达式结果代替E。优化仅限于程序基本块内称为局部公共子表达式消除,优化涵盖了多个基本块称为全局公共子表达式消除
数组范围检查消除:Java中访问数组时虚拟机会自动进行上下界范围检查,判断i>=0&&i<=arr.length,每次数组元素的读写都有一次隐含的条件判定,如果有大量数组访问代码会称为一种性能负担。这种情况下,数组边界检查在运行期是否一次不漏是可以优化的,如arr[3]只要在编译期根据数据流分析确定arr.length,判断3是否越界,执行的时候就无须判断,更常见的情况是循环访问数组元素,使用该优化方法可以节省很多次条件判断操作
方法内联:作用在于除了消除方法调用的成本之外,更重要在于为其他优化手段建立基础。私有方法、父类方法、实例构造器方法、静态方法、final方法等非虚方法在编译器解析,可以直接内联;虚方法在编译器无法确定版本,因此无法直接内联,对此Java虚拟机引入了“类型继承关系分析”(CHA)技术,用于确定整个应用中已加载的类中,某个接口是否由多于一个实现,某个类是否存在子类、子类是否为抽象类等信息。编译器进行内联时,如果是虚方法会向CHA查询此方法是否有多个版本选择,如果只有一个可以直接内联,这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。如果程序的后续执行过程中虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化代码可以一直使用,如果加载到就需要抛弃已经编译的代码,退回到解释执行或者重新编译;如果有多个版本,编译器进行最后一次努力,使用内联缓存完成方法内联,内联缓存是一个建立在方法入口之前的缓存,原理:方法未调用之前缓存为空,第一次调用后缓存记录下方法接收者的版本信息,每次进行方法调用时都比较接收者版本,一样则这个内联可以一直使用,不一样则取消内联,查找虚方法表进行方法分派
逃逸分析:不直接优化代码,而是为其他优化手段提供依据的分析技术。一个对象在方法中被定义后,可能被外部方法所引用,称为方法逃逸(如方法参数传递),甚至可能被外部线程访问到,称为线程逃逸(如类变量、线程共享实例变量)。如果确定对象不会逃逸,可以进行一些高效优化,如栈上分配(不会发生方法逃逸,可以考虑在栈上分配内存,使对象随栈帧出栈而销毁,减轻GC压力)、同步消除(不会发生线程逃逸,可以消除同步措施)、标量替换(无法分解的数据称为标量,可以分解的称为聚合量,对象是典型的聚合量。将对象拆散,使其成员变量恢复原始类型来访问叫做标量替换。对象不会逃逸且可以拆散,执行时可以考虑不创建这个对象,而是直接创建它的若干个被这个方法使用到的成员变量来代替,这样对象的成员变量就在栈上分配内存,且可以进行进一步优化)
示例:
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y=b.get();
//..do..
z=b.get();
sum=y+z;
}
方法内联:foo(){ y=b.value; //..do.. z=b.value; sum=y+z; }
冗余访问消除:foo(){ y=b.value; //..do.. z=y; sum=y+z },假设do操作不会改变b.value的值
复写传播:foo(){ y=b.value; //..do.. y=y; sum=y+y } 没有必要使用多余变量z
无用代码消除:foo(){ y=b.value; //..do.. sum=y+y }
5.Java与C/C++编译器对比
也就是即时编译器与静态编译器对比,Java对于C/C++劣势如下,都是为了换取开发效率而付出的代价:
1.即时编译器占用用户时间,有较大时间压力
2.Java是动态的类型安全语言,虚拟机要保证程序安全,这就意味着虚拟机必须频繁进行动态检查,要消耗不少时间
3.Java中使用虚方法的频率远大于C/C++,这意味着运行时对方法接收者进行多态选择的频率远大于C/C++,也意味着即时编译器要进行一些优化的难度远大于静态优化编译器
4.Java是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局优化难以进行,因为编译器无法看见程序的全貌,许多全局优化不得不以激进优化的方式来完成,这意味着编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化
5.Java对象内存分配在堆上,C/C++即可以在堆上,也可以在栈上,这减轻了GC压力。C/C++主要由用户代码进行内存回收分配,这就不存在无用对象筛选的过程,因此效率上高于垃圾回收机制
另外,C/C++的别名分析难度要高于Java;C/C++编译器所有优化都在编译器完成,以运行期性能监控为基础对于优化措施它都无法进行,如调用频率预测、分支频率预测、裁剪未被选择的分支等
0 0