Java编译技术分析

​ Java程序在运行时首先需要进行前端编译,传统的方法是将Java字节码进行解释执行。为了提高性能,JIT编译通过分层编译热点代码,结合诸多优化方法,在很多场景中性能得到显著的提高。在云原生的场景中,JIT方法面临冷启动的局限,因此AOT受到越来越多的关注。Java AOT是近几年较新的一个Java编译方法,GraalVM Native Image是当前一个主流的实现方法,它将Java字节码编译为平台相关的二进制代码,将Java的动态编译转变为静态编译,以适应云原生等场景。在文章最后将JIT与AOT进行了简单的对比。

1. 传统Java编译和运行

1.1 Java编译

​ 编译一般是指将高级程序设计语言转换为计算机硬件能识别的机器语言,以便计算机进行处理和实现人类的易读性,而解释是指将源代码逐条转换为目标代码并逐条运行的过程。针对Java语言而言,Java程序运行的过程中同时包括编译与运行,第一个阶段是在编译阶段,将Java代码编译成Java字节码,这个过程通常叫做前端编译,比如使用Oracle的javac编译器进行编译;第二个阶段是在运行时,通过JVM将Java字节码逐条运行。Java编译主要在第二个阶段有不同的类型,比如JIT和AOT,这两种方法会在后续进行介绍。第一个阶段的转换过程大体与其他编程语言类似,下面简单进行介绍。

​ 前端编译主要是将源代码转换为中间代码的过程,大体分为以下几个过程。首先通过词法分析分析出句子中各个单词的词性或者词类,将程序划分为词法单元(即Token)。接下来通过语法分析从上面输出的Token流中识别出各类短语,并构造语法分析树。然后进行语义分析,手机标识符的属性信息,同时进行语义检查,最后生成中间代码。各种编程语言的前端编译大体类似。

1.2 Java运行

​ 经过传统的Java编译后,得到了Java字节码,即Class文件,Java字节码由操作码和操作数组成,Java通过Java字节码实现了平台无关性,一次编写,到处运行。当使用java命令运行Class文件时,相当于启动了一个JVM进程,JVM中的执行引擎(中的解释器)将平台无关的字节码转换为机器码。JVM采用基于栈的结构,同样分为堆和栈。比如我们现在运行到了 main 方法,就会给它分配一个栈帧。当退出方法体时,会弹出相应的栈帧。

2 Java即时编译(JIT)

2.1 JIT运行过程

​ 传统的Java运行过程是JVM解释器逐条代码翻译运行Java字节码,所以在性能上Java通常不如C++这类编译型语言。为了优化Java的性能,根据“二八定律”(少部分代码占据了程序的大部分运行时间),JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。

​ 使用JIT后,Java代码的执行过程分为两个部分。第一步同样是进行前端编译,转换成Java字节码。第二部分中,在程序解释运行的过程中,部分代码在一定时间内调用或循环次数超过一定的阈值后,该段代码被认为是热点代码,JIT会编译热点代码并存入codeCache中。当下次要执行该段代码时,直接从codeCache中读取执行,以此来提升运行的性能。简单说,JIT就是将代码经过预热之后,将热点代码进行编译,整体的执行过程大致如 图 1 JIT编译过程 所示。

JIT编译过程
图一 JIT编译过程

2.2 分层编译

​ JIT大体分为两个部分或两种模式:C1编译模式与C2编译模式,分别对应了两种不同的编译器:Client Compiler和Server Compiler。Client Compiler(或C1编译器)注重启动速度和局部的优化,Server Compiler更关注全局的优化,性能更好但是编译时间也更久。

​ 具体来说,C1编译器会对字节码进行以下的优化:进行局部简单可靠的优化(比如方法内联、常量传播等),将字节码构造成高级中间表示(HIR,HIR与平台无关,通常采用图结构),将HIR转换为低级中间表示(LIR)、C2编译器会进行一些全局性的、更激进的优化(比如循环变换等)。从JDK9开始,C2编译模式除了Server Compiler,还可以选择Graal编译器,该编译器会进行分支预测、虚函数内联等优化,相对Server Compiler优化更加激进,峰值性能更好。

​ C1编译器和C2编译器和解释器可以相互进行组合,即分层编译。Java7开始引入了分层编译的概念,对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的C1;长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的C2。分层编译将JVM的执行状态分为了五个层次(如 图二 常见编译路径 中横向阶段):

​ 0层:解释执行

​ 1层:执行不带profiling的C1代码

​ 2层:执行仅带方法调用次数和循环回边执行次数profiling的C1代码

​ 3层:执行带所有profiling的C1代码

​ 4层:执行C2代码

​ 其中profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数

根据实际中不同层次进行组合的情况,常用的有五种路径或组合方式(如 图二 常见编译路径 中纵向路径):

​ 路径①:编译的一般情况,热点方法从解释执行到被3层的C1编译,最后被4层的C2编译。

​ 路径②:如果方法比较小(比如Java服务中常见的getter/setter方法),3层的profiling没有收集到有价值的数据,JVM就会断定该方法对于C1代码和C2代码的执行效率相同,在这种情况下,JVM会在3层编译之后,放弃进入C2编译,直接选择用1层的C1编译运行。

​ 路径③:如果C1编译器忙碌,就在解释执行过程中对程序进行profiling ,根据信息直接由第4层的C2编译。

图二 常见编译路径

​ 路径④:如果C2编译器忙碌,因为C1阶段运行速度快,这时方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层C2的执行时间。

​ 路径⑤:如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行

​ 总的来说,C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译。

2.3 编译优化

​ JIT会对正在运行的服务进行一系列的优化,包括字节码解析过程中的分析,根据编译过程中代码的一些中间形式来做局部优化,还会根据程序依赖图进行全局优化,最后才会生成机器码。下面简要介绍一些常用的优化方法。

2.3.1 方法内联

​ 方法内联,是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。

​ Java服务中存在大量getter/setter方法,如果没有方法内联,在调用getter/setter时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。内联了对 getter/setter的方法调用后,能将对getter、setter的访问优化成单一内存访问。

​ 内联是JIT提升性能的主要手段,但是虚函数使得内联是很难的,因为在内联阶段并不知道他们会调用哪个方法。C2编译器会优化单个实现方法的虚函数调用,但是无法优化多个实现方法的虚函数调用。

2.3.2 逃逸分析

​ 逃逸分析是一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针。JIT会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

​ 锁消除即如果JIT能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义,可以进行锁消除。

​ 栈上分配是如果逃逸分析能够证明某些新建的对象不逃逸,那么JVM完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。在Hotspot虚拟机中,并没有进行实际的栈上分配,而是使用了标量替换这一技术,编译器会在方法内将未逃逸的聚合量分解成多个标量,以此来减少堆上分配。

3 Java提前编译(AOT)

​ JIT会对正在运行的服务进行一系列的优化,包括字节码解析过程中的分析,根据编译过程中代码的一些中间形式来做局部优化,还会根据程序依赖图进行全局优化,最后才会生成机器码。下面简要介绍一些常用的优化方法。

3.1 背景

​ JIT编译经过不断的发展,在某些情况下性能甚至可以编译型语言相比,但是冷启动开销较大(即需要经过虚拟机初始化后才能达到可用状态,再经过程序预热才能达到最佳性能)的问题是JIT难以解决的一个问题,这个问题在某些情形下显得尤为重要。比如云原生场景下,Serverless 服务本身执行时间短,Serverless 应用强调微服务架构,服务的粒度小,耗时短。与短暂的应用执行时间相比,冷启动的开销耗时所占比重增大,甚至可能比程序执行时间还要长,因此冷启动对应用的影响也到了不可忽视的程度。

3.2 运行过程与分析

​ Java AOT与C++的编译过程比较类似,它首先仍需要将Java程序进行前端编译,转换为Java字节码。然后使用静态编译器将字节码编译为平台相关的二进制可执行代码,最后执行。

​ 相较于JIT,Java AOT(Ahead Of Time)是一个近年来较新的解决方案,GraalVM Native Image是Oracle官方首推的AOT解决方案,它摈弃了JVM,将Java像C++一样编译成机器代码来执行。GraalVM是Oracle在2019年推出的新一代UVM(通用虚拟机),它在HotSpotVM的基础上进行了大量的优化和改进,主要提供了两大特性:多语言支持(可以在GraalVM中无缝运行多种语言)与高性能(提供了一个高性能的JIT引擎和SubstrateVM)。下面简单介绍一下这个特定的Java AOT方案。

​ Native Image 是一种将 Java 代码提前编译为独立可执行文件(称为Native executable)的技术,即Native Image是基于GraalVM的AOT。Native Image的输入是整个应用的所有组件,包括应用本身的代码、各种依赖的库、JDK库、以及Substrate VM(Substrate VM是一个包含内存管理、线程调度等的运行时系统),然后会进行三个步骤(如 图三 Native executable构建过程 所示):

图三 Native executable构建过程

3.3 动态特性

​ Substrate VM除了实现内存管理、线程映射等底层能力之外,还需要以静态的方式实现Java的动态特性,以保持JDK接口层面的兼容性和功能的等价性。例如反射是Java中使用非常广泛的动态特性,Substrate VM通过预执行、编译时和运行时三个阶段的配合对其实现了有条件的静态化支持。

​ 静态分析无法得到反射的目标,所以静态分析得到的可达代码中缺少了反射的目标类、函数和域。Substrate VM需要用户在编译时额外提供关于反射的信息——被称为元数据配置,以帮助Substrate VM编译出正确的程序。元数据配置可以由用户手动编辑,但是考虑到在实际项目中手工编辑是不现实的,所以Substrate VM提供了native-image-agent,可以在挂载在应用程序上,将运行过程中遇到的所有反射都记录下来自动生成静态编译需要的配置文件。将通过agent得到配置的过程称为预执行,预执行时不但记录了反射信息,还记录了序列化、动态类加载和动态代理等动态特性的数据。

​ 解析出来这个配置文件以后,就可以知道反射什么东西了,将反射的东西注册上去,也就是将可达性的范围进行了扩张,也就扩大了编译的范围。有了配置提供的反射数据,编译时一方面将反射目标注册为可达,扩大了代码可达范围;另一方面将反射调用替换为直接调用,使得在运行时可以在原本用反射调用的位置实现了直接调用。

3 不同编译方法的对比和应用

​ 传统的单纯解释方法已经逐渐淘汰,现在主流的方法是基于JIT的编译方法,因此下面主要讲JIT与AOT进行对比。

​ JIT吞吐量高,有运行时性能加成,程序运行更快,并可以做到动态生成代码等,但是相对启动速度较慢,并需要一定时间和调用频率才能触发 JIT 的分层机制。AOT内存占用低,启动速度快,可以无需 runtime 运行,直接将 runtime 静态链接至最终的程序中,但是无运行时性能加成,不能根据程序运行情况做进一步的优化,而且对动态特性的支持是有限的,部分Java的机制不再适用,且与平台相关。

​ 总的来说,JIT与AOT是面向于不同场景下的编译方法。在传统服务器部署的场景中,应用执行时间足够长,冷启动问题就被淡化了,而且还可以提前将服务预热准备好,以最好的状态迎接用户的服务请求,因此可以充分发挥JIT的性能。而Serverless 服务本身执行时间短。Serverless 应用强调微服务架构,服务的粒度小,耗时短。与短暂的应用执行时间相比,冷启动的开销耗时所占比重增大,甚至可能比程序执行时间还要长,因此冷启动对应用的影响也到了不可忽视的程度,此时使用AOT更合适。