Java后端编译优化

虚拟机最开始是通过解释器进行解释执行的,解释器是最基础的,但是没有优化,效率和速度都有待提高。但是只依靠解释器不进行优化也是可以的。即时编译器和提前编译器都是采取的优化措施,是可选择的。

即时编译器:当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称 为即时编译器

提前编译器
1.在程序运行之前把程序代码编译成机器码的静态翻译工作
2.把原本即时编译器在 运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器 其他Java进程使用)时直接把它加载进来使用。目前主流虚拟机均支持此种方法。

即时编译是在程序运行时发生的,优化需要时间和运算资源,而提前编译是在程序运行之前就编译好,可以减少系统运行时优化消耗。

解释器与编译器

主流的虚拟机内部都同时包含解释器与编译器,二者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序 启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少 解释器的中间损耗,获得更高的执行效率。

编译器是将代码转换为本机机器码保存下来,需要占用内存。所以内存资源不多的时候用解释器执行,反之可以用编译器来提高程序执行速度和效率。同时,编译器的优化不能保证完全成功,一些激进优化措施是有失败的风险,此时就不能再用编译器,要退回到解释器状态继续执行。

在这里插入图片描述
虚拟机有三种编译模式:
混合模式:解释器与编译器搭配使用
解释模式:解释器工作,编译器完全不工作
编译模式:优先采用编译器,但编译器无法进行时采用解释器

HotSpo内置的编译器有:“客户端编译器(C1编译器)”和“服务端编译器(C2编译器)”。
分层编译:

·第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
·第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启 性能监控功能。 ·第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
·第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
·第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

解释器执行的启动速度是比编译器快的,所以程序先再第0层用解释器执行。而编译器的启动速度也有区别,C1编译器启动要比C2编译器快,这是因为C2会进行更多的优化,所以C2编译出的代码要比C1的代码执行效率好很多。从启动速度上看 解释器>C1编译器>C2编译器,从程序执行效率上看 4>1>2>3>0, 随着C1性能监控搜集的信息越来越多,性能开销也越大。第二层和第三层基本是为第4层服务的,减少第四层编译执行的时间。

程序最后会在1或者4编译,C2编译出的效果最好,所以最后要进入4,但是如果一段程序本身就简单,可以优化的地方不多,那么就不值得花费时间去搜集程序执行信息,直接去第1层用C1编译就可以,这样编译的效果也很好。

在这里插入图片描述
通常情况下,热点代码会被第三层C1编译,然后交给第4层的C2编译。
在字节码较少的情况下,此时性能监控收集的数据很少,就交给第1层的C1进行编译。
在C1比较繁忙时,会在第0层解释执行收集程序执行状态数据,然后直接交给第4层的C2编译。
在C2繁忙的时候,先交给第2层的C1,再交给低3层的C1,最后交给第4层的C2。

热点代码

1.被多次调用的方法 触发标准编译请求

2.被多次执行的循环体 触发栈上编译请求

热点代码编译的都是整个方法体。即使是方法中的循环体被认定是热点代码,热点只是整个方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条指令开始执行)有不同。编译时会传入入口点字节码序号,这种编译方式因为 编译发生在方法执行的过程中因此称为”栈上替换(OSR)“,即方法还在方法栈上但是具体执行的指令已经被替换了,注意方法栈本身并没有改变也没有移动,只是执行到热点代码时去内存中读取编译后的方法。因为循环体是从某条指令开始执行,一轮完成之后会再次跳转到开始位置,所以只传入一个入口点序号就可以(入口点也是出口)。

热点探测:判断一个方法是不是热点代码,需不需要出发即时编译。主流有两种方法

基于采样的热点探测:虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。好处是实现简单,只需要采样当前的栈顶即可。缺点是精度不行,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

基于计数器的热点探测:虚拟机为每个方法建立计数器,统计执行次数,超过一定阈值则认为是热点代码。好处是结果精确,缺点是比较麻烦,为每个方法建立计数器。HotSpot采用此种方法,给每个方法准备两类计数器:方法调用计数器和回边计数器。

方法调用计数器:统计方法被调用的次数。方法调用时判断是否存在编译过的版本,如果存在则执行编译后的本地代码。如果不存在,方法计数器+1,和阈值比较判断是否触发即时编译。一旦超过,申请即时编译。

统计绝对次数:统计方法所有时间段内执行次数,计数器值不会减少。只要程序执行足够多,一定会触发即时编译。

统计相对次数:统计一段时间执行次数,如果超出时间还未能出发即时编译,统计次数减少一半。这个称为方法调用计数器热度的衰减,这段时间半衰周期。默认统计相对次数。

注意上面这两个方法只有在方法调用器种可以选择,回边调用器只能记录绝对次数。

回边计数器:统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边“。当计数达到阈值后会触发栈上的替换操作(OSR),每个循环体都应该有一个回边计数器,所以一个方法内可能存在多个回边计数器。回边调用只记录绝对次数,触发的阈值可以间接设置。

在这里插入图片描述
在这里插入图片描述

热点代码经过编译后是放在虚拟机的内存中,这样以后调用时可以直接从内存中读取。但是如果代码缓存空间用完了,虚拟机遇到热点代码也不会进行即时编译,而是启用解释器执行。代码缓存占满后并不会抛出OOM异常,只是等待下一次GC清理出空间。当程序较大想要保存更多的编译后本地代码,应该扩大代码缓存空间。

提前编译和即时编译

提前编译的好处是省资源,避免了在运行时进行优化,节约了运算资源。而即时编译相对提前编译,有以下优点:
1.性能分析制导优化
2.激进预测性优化
3.链接时优化

编译优化技术

方法内联是把目标方法的代码原封不动地“复 制”到发起调用的方法之中,避免发生真实的方法调用。这是编译器最重要的优化手段,被称为优化之母。除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,没有内联,多数其他优化都无法有效进。

方法内联需要确定目标方法的具体版本,但是除了在编译期间进行解析的非虚方法和final修饰的方法,其他方法都是虚方法。虚方法可能会进行重载或者重写,为了确定方法的版本Java虚拟机采用类型继承关系分析技术(CHA),这个可以确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。

守护内联:如果内联的是非虚方法,那么直接进行内联就行,这样一定时安全的,要内联的方法版本不会发生改变。但是如果时虚方法,CHA会查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序 的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联。
如果后期类的关系没有变化,就一直按照内联优化的程序运行,如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

内联缓存:如果向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,编译器将采用内联缓存的方式来减少方法调用开销。在未发生方法调用之前,内联缓存为空。第一次调用方法时,记录下方法接受者的版本信息,随后的每次调用都先比较接收者版本信息,如果一致则认为调用的是同一种方法,就通过缓存来调用,称为单态内联缓存。如果接收者信息不一样,就会退化为超多态内联缓存。单态内联缓存只是比普通内联调用多一次比较,速度还是很快,但是如果是超多态内联缓存,其速度相当于真正查找虚方法表来进行方法分派。

逃逸分析 :分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部 方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
逃逸分析与类型继承关系分析一 样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

如果一个对象不会逃到方法和线程之外,即其他方法和线程无法通过任何手段访问到这个对象,或者逃逸程度低 (只是方法逃逸)则可以为这个对象实现不同程度的优化:

·栈上分配:如果确定一个对 象不会逃逸出线程之外,让这个对象在栈上分配内存,对象所占用的内存 空间就可以随栈帧出栈而销毁。因为不会逃逸出线程,所以其他线程是无法访问到的,那么在堆上创建会浪费GC资源,直接在此线程的栈上创建,随着栈帧出栈而销毁,减少GC压力。栈上分配可以支持方法逃逸,但不能支持线程逃逸

标量替换:若一个数据已经无法再分解成更小的数据(int char long)来表示就称为标量,如果一个数据可以继续分解(Java对象),那它就被称为聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创 建它的若干个被这个方法使用的成员变量来代替。标量替换不允许对象逃逸出方法。

同步消除:如果对象不会逃逸出线程,就不必做线程间的同步,对这个变量实施的同步措施也就可以安全地消除掉。

公共子表达式消除如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。没有必要花时间再对它重新进行计算,直接用之前的计算结果代表E即可。

数组边界检查消除 在Java语言中访问数组元素系统将会自动进行上下界 的范围检查,对于拥有大量数组访问的程序代码,这必定是一种性能负担。如果编译器只 要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,length)之内,那么在循环中就可 以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。直白的说就是用过编译器更聪明的判断是否需要进行边界检查,消除不必要的检查节省时间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值