Java源码的后端编译
欢迎来到我的博客:TWind的博客
我的CSDN::Thanwind-CSDN博客
我的掘金:Thanwinde 的个人主页
0.前言
当已经完成了前端编译了之后,Java源码已经被编译成为了一份完整的字节码,编译阶段已然完成,接下来是运行阶段:
字节码一开始会被解释执行,接着会收集其中的运行数据不断的进行优化,最后产出一份高效率的代码
而这些,被称为后端编译
1.即时编译器
即时编译器(JIT),即Just is time , 能够在程序解释执行时收集信息,捕捉到一些频繁运行的热点代码,然后会将这部分热点代码编译成本地机器码,并辅以尽可能多的优化手段。Java程序的运行速度绝大部分取决于JIT输出代码的质量
[!NOTE]
解释执行:
解释执行是由解释器来完成,和编译执行最大的区别就是,编译执行会将所有的代码编译成目标代码然后执行
解释执行则是把字节码一行行的编译成机器码,一行行的执行
显而易见的解释执行是要比编译执行的效率低很多的
但是解释执行为Java提供了跨平台性:“一次编写,到处运行”,不同机器上的机器码不同,但是字节码相同
同时,优化又方便得多:不用考虑实际上机器的指令集之类,只用专心在JVM上
无论是维护,部署都要比直接分发机器码要来得方便
2.解释器和编译器
在程序刚开始运行时,所有的代码都是由解释器来解释运行的,而当检测到了热点代码时,编译器就会启动,在后台编译优化这一段热点代码,然后将其替换因为编译的目标对象的一个方法,而不是一段代码,这种替换会直接替换掉方法的代码(入口地址),而方法在JVM上的呈现是以栈帧形式存在的,那么就不难理解这种优化被称为“**栈上替换”**了
对于老版本的JVM,拥有两个版本:客户端模式和服务端模式,客户端模式编译出来的代码质量较低,但速度快,毕竟在后台分析编译是会占用程序响应时间的,服务端则更为强大,编译出来的代码质量更高,速度相对慢
编译器的选择取决于模式,比如是客户端还是服务端,是纯解释模式,纯编译模式(只是尽可能)还是混合模式,客户端一般为C1,服务端为C2(最新的Graal也是)
而在Java7 引入的分层编译替换了这个设定:把编译程度分成五层
解释执行。
执行不带profiling的C1代码。
执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。
执行带所有profiling的C1代码。
执行C2代码。
有了分层编译,服务端的编译器和客户端的编译器就能同时使用了,一般来说先会用C1快速生成代码来节省时间给C2编译更高质量的代码然后进行替换
一般来说,程序的执行效率随着层数的上升而提高,到最后基本与纯编译的语言相差无几甚至略快
那是怎么判断热点代码的呢?主要有两种办法
3.热点代码识别
前面提到了,编译的最小单元是方法,那么这里的热点代码识别一定是围绕着方法而展开的
具体来说,有两种:
- 基于采样:虚拟机会周期性检测每个线程的调用栈顶,如果发现某些方法出现的次数很多,就认为其是热点代码,这个方法简单,且可以通过调用栈直接获得调用关系,但缺点是不严谨,容易被其他因素扰乱
- 基于计数器:虚拟机会为每一个方法建立计数器,每次执行都会使其+1,到达阈值之后就会将其认定为一个热点代码,优点在于能够精确的判断一个方法的热度,缺点是复杂
对于HotSpot采用的是第二种方法,但又有些许不同:准备了两种计数器:方法调用计数器和回边计数器
方法计数器顾名思义,就是对每个方法维护一个计数器,达到阈值之后就会认定这是一个热点代码,这时候会看是不是已经有编译过的代码,有的话就会直接替换,没有的话就会提交一个编译请求交给后台编译
值得注意的是,如果没有达到阈值的话,一段时间内调用计数会降低一半,有点像半衰期,这个行为往往是和GC一块进行的,这个行为可以称为“冷却”
而且,最开始的代码也是会先判断有没有编译过的代码而不是直接解释执行的,毕竟存在着提前编译
而回边计数器是专门针对循环而设立的一个计数器,对于字节码来说,向后“跳”的指令就称之为“回边”,实际上,字节码,汇编之中的循环是通过类似C中的goto + if 来实现的,这样也不难理解“回边”的意思了
回边的处理逻辑有些不一样,每次遇到回边都会看有没有编译过的版本,有的话就会直接替换。接着会把回边计数器+1,如果回边计数器加上调用计数器的值大于了回边计数器的阀值,超过了就会提交编译请求并继续解释执行
4.编译过程
这里是整个编译的重点以及最复杂的地方,也是最能体现JVM发展的一个地方
在确定一个方法是热点之后,编译器会将其字节码转化为一种与平台无关的中间形式(HIR),这样的话能够更好的进行优化,这时候会完成比如方法内联,常量传播等优化
接着,HIR会被转化为一个与平台有关的中间形式(LIR),这时候会完成比如空值检测消除,范围检测消除等
随后会分配寄存器,用窥孔优化来产生机器代码
当然这是一个极高度的抽象和概括,这里面的每一章都不是一两万字能够讲清楚的
C1和C2在这个阶段有一些区别,但可以概括成C2会容忍更高复杂度并会尝试更多的优化,同样的相对缓慢
值得一提的是,如果发现一个编译并没有作用甚至会导致反作用的时候,会直接丢弃掉这个编译而采用解释执行,就像一个逃生门一般
5.提前编译
众所周知,编译非常的耗时且拖延性能,更重要的是,需要一定时间来分析程序运行时的各种数据才能开始进行优化
那么我们能不能提前完成这一个部分呢?比如把这些工作放到编译期来进行?
目前有两种形式:
- 提前进行分析并优化
- 缓存可能的优化机器码,直接套用
对于第一种情况,首先得知道,编译期间最消耗性能的就是对各种代码进行分析,对各种数据流,字段进行分析,还得把各个涉及到的方法进行方法内联,才能打通全程序来进行分析,无论如何,都极为耗时耗力且复杂
但如果把这些行为放到编译之前来做,那完全可以放心大胆的去完成
这种方法在Android里的ART广泛应用并成功,但是副作用也是明显的:Android5.0和6.0安装大一点的软件耗时都是安装分钟来计算的,于是后面又加回了解释器和即时编译
第二种方法就很乐观了,简单的说,对于一些比较基础的库,这些类库会被提前编译好,用的时候会直接替换进去,这个技术目前已经广泛使用并收效良好
但是,提前编译并不是一个万能的,它耗费大量时间得到的代码也不一定会比JIT生成的要好:毕竟没有真实的数据支持,完全有可能效率大跳水,甚至爆bug,这样又得回到解释执行重新开始即时编译,相当于之前的努力全部付诸东流
相对的,即时编译的优点也是不容忽视的:运行时收集到的各种数据是非常宝贵且有用的资源,而且众所周知,Java是一个动态的过程,类加载之类的重量级操作都是发生在运行时的,JIT能够完美的收集到这些数据并加以利用
同时,JIT能够采取一些非常激进的优化,反正由于逃生门的存在,就算失败也能退回