JVM--晚期(运行期)优化
文章目录
1、 概述
Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁是,就会把这些代码认定为"热点代码".为了提高热点代码的运行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各个层次的优化,完成这个任务的编译器成为即时编译器.
2、 HotSpot虚拟机内的即时编译器
2.1 解释器与编译器
当程序需要迅速启动和执行的时候,解释器可以迅速发挥作用,省去编译时间立即执行程序.程序运行后,随着时间的推移,编译期逐渐发挥作用,把越来越多的代码编译成本地代码,可以获取更高的运行效率.
解释执行可以节约内存,编译执行可以提升效率.这二者可以根据需要相互转换.
解释器还可以作为编译期激进优化的后门,在编译期进行激进优化出现错误时,可以改为解释执行来"读档"恢复成原状态.
HotSpot虚拟机中内置了两个即时编译器,分别为C1编译器和C2编译器.目前主流的HotSpot虚拟机中默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式.
解释器与编译器搭配使用的方式在虚拟机中成为"混合模式",可以使用参数设置使用"编译模式",“解释模式"或者"混合模式”.
由于即时编译器编译本地代码需要占用程序运行时间,解释器可能还要收集性能监控信息,对解释执行的速度也有影响.为了在程序启动响应速度和运行效率之间达到最佳平衡,HotSpot虚拟机会逐渐启动分层编译的策略.分层编译根据编译器编译,优化的规模与耗时,互粉出不同编译的层次:
- 第0层,程序解释执行,解释器不开启性能监控,可以触发第1层编译.
- 第1层,也称C1编译将字节码编译为本地代码,进行简单,可靠的优化,有必要的话会加入性能监控.
- 第2层,也称C2编译也是将字节码编译为本地代码,但进行的是一些编译耗时较长的优化,甚至根据性能监控信息进行一些不可靠的激进优化.
2.2 编译对象与触发条件
会被即时编译器编译的热点代码有两类:被多次调用的方法和被多次执行的循环体.
判断一段代码是不是热点代码,是不是需要出发即时编译,这样的行为被称为热点探测,目前主要有两种热点探测判定的方式:
- 基于采样的热点探测:虚拟机会周期性地检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法.这样的好处是实现简单,高效.
- 基于计数器的热点探测:虚拟机会为每个方法建立计数器,统计方法执行的次数,如果执行次数超过一定阀值就认为它是热点方法.好处是更加精确和严谨.
HotSpot虚拟机中使用的第二种–基于计数器的热点探测方法.它为每个方法准备了两类计数器,方法调用计数器和回边计数器.
方法调用计数器用于统计方法调用的次数,默认阀值在Client模式下是1500次,在Server模式下是10000次.如果不做任何的设置,方法调用计数器拥挤的是一段时间内方法被调用的次数,而不是历史中出现的调用数.当一段时间内方法调用计数器的值还是不能到达阀值,那么方法调用计数器中的值会减半.
回边计数器的作用是统计一个方法中循环体代码执行的次数.
当一个方法被调用时,会检查该方法是否有被JIT编译过的版本,如果有则优先使用编译后的本地代码来执行.如果不存在,则侧方法的调用计数器加一.然后判断方法调用计数器与回边计数器之和是否超过方法调用技术器的阀值.如果超过阀值,会想即时编译期提交一个该方法的代码编译请求.
2.3 编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译期未完成之前,都仍然按照解释执行的方式继续执行,而编译动作则在后台的编译线程中进行.
Client Compiler(C1)和Server Compiler(C2)两个编译期的编译过程是不一样的.对于C1编译器来说,它是一个简单快速的三段式编译器,主要关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段.
- 第一阶段,由一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR).
- 第二阶段,由一个平台相关的后端从HIR中产生低级中间代码表示(LIR).
- 第三阶段,由平台相关的后端使用线性扫描算法优化,然后产生机器代码.
而C2编译器则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,它会执行所有经典的优化动作.另外,还可能根据解释器或者C1编译器提供的性能监控信息进行一些不稳定的激进优化.
3、 编译优化技术
3.1 公共子表达式消除
公共子表达式消除是一个普遍用于各种编译期的经典优化技术.如果一个表达式E已经计算过了,并且从先前的机选到现在E中所有变量的值都没有发生变化.那么E的这次出现就成为了公共子表达式.对于这种情况,没有必要再去计算表达式的值,直接使用之前计算的结果代替E即可.
3.2 数组边界检查消除
Java中对数组进行访问操作时每一次虚拟机都会进行边界检查以免越界,但每一次都进行边界检查造成了性能负担.这种隐式开销一方面尽量在编译期完成,一方面使用隐式异常处理避免.
3.3 方法内联
方法内联优化就是指讲调用的代码直接复制到调用处,但实际上如果不是虚拟机进行了一些特别的努力大多数Java方法都不能进行内联.Java语言中默认的实例方法是虚方法,也就是说运行时才可以知道是哪个方法,并且可能存在多个方法接收者.
为了解决虚方法的内联问题,引入了一种叫"类型继承关系分析"(CHA)的技术.
Java虚拟机这样处理虚方法内联:如果是非虚方法,直接内联,如果遇到虚方法,会向CHA查询此方法在当前程序下事由有多个目标版本可供选择,如果只有唯一的一个版本,也可以进行内联,不过这种内联就属于激进优化.如果CHA查询有多个版本,则编译期使用内联缓存来完成内联.(就是记录调用方法的版本,如果没有改变就证明可以使用之前记录的方法版本进行内联)
3.4 逃逸分析
逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸.甚至有可能被外部线程访问到,成为线程逃逸.
如果能证明一个对象不会逃逸到方法和线程之外,可以为这个对象进行一些高效的优化:
- 栈上分配:如果一个对象不会逃逸出方法之外,那让这个对象的内存在栈上分配是比较好的选择,大量的对象随着方法的结束而自动销毁,垃圾收集的压力会小很多.
- 同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,那么对这个变量进行的线程同步就可以消除掉.
- 标量替换:如果逃逸分析证明一个对象不会被外部访问,那么可能在实际运行中,并不会创建这个对象,而是直接创建它的若干个被这个方法使用到的成员变量来代替.
4、 Java与C/C++的编译期对比
Java编译器输出的本地代码的劣势:
- 即时编译器运行占用了用户程序的运行时间,所以即时优化会使用户程序延迟,导致即时编译器不敢随便引入大规模的优化技术.
- Java语言是动态的类型安全语言,意味着必须由虚拟机保证不会违反语言语义或访问非结构化内存,也就一位置虚拟机必须频繁地进行动态检查.
- Java中虚方法的使用频率远大于C/C++,意味着即时编译期做一些优化(如之前的方法内联)时,难度远大于C/C++的静态优化编译器.
- Java预言师可以动态扩展的,运行是重新加载类可能改变程序类型的继承关系,使得很多全局的优化难以进行.
- Java中对象的内存分配都是在堆上进行的,只有方法中的局部变量才能在栈上分配,因此回收内存上效率比C/C++低.
这些劣势换取的是开发效率上极大的优势.