JIT v.s. AOT in Java

3,440 阅读11分钟

目前主要有两种编译Java程序的方式,即常用的JIT方式(Just in Time)以及最新搭配云原生出现的AOT方式(Ahead of Time)。前者是目前Java运行的默认模式,是通过Hotspot JVM的技术在运行时把字节码翻译成机器码,从而进行高效的指令运行。后者则是由一个新兴的编译器GraalVM来支持,能够直接在编译时把字节码编译成机器码。随着容器化云技术的普及,云原生的概念如火如荼,对于Java最初的初心“一次编译,到处执行”,似乎开始有点不那么恰当,相反由于包体大、启动执行慢等特点,开始被不断的唱衰,为了克服这些弱点,AOT技术也孕育而生。如果说之前的AOT技术还在实验阶段,但当SpringBoot 3.0在11月的时候正式GA的时候,可以说Java AOT技术开始从实验逐步进入到实战了。因为,SpringBoot 3.0中的一大特点就是支持GraalVM的AOT,而Spring以及SpringBoot可以说是Java的基石,当它支持的时候,往往也意味着开始成熟(不过值得注意的是,新技术往往要经历一个较长的时间后才开始流行,比如docker的容器化技术,始于2013年,但被大面积采用是5年后的事了)。根据现在的技术趋势,为了更方便的理解AOT,作为技术储备,本文会介绍JIT与AOT两者的区别以及什么情况下更适合采用AOT技术。

Just in Time Compilation (JIT)

代码编译指的是把像Java、Python之类的高级编程语言的源代码编译成机器码的过程。所谓的机器码,其实就是底层的CPU指令集,因此面对不同的CPU架构需要编译成对应的机器指令。编译器就是那个执行代码编译的程序,它的主要目标就是把源代码编程成一个可执行的程序。在编译的过程中,编译器往往会进行一些优化操作,例如,大多数编译器会进行方法内联、循环展开、部分求值等。这些优化措施在近十年里不管是数量上还是复杂性上都在显著提升。

在介绍JIT与AOT区别前,我们先回顾下传统Java解释器执行的过程。当编译Java程序时,我们会通过编译器(例如javac command line)把源代码转变为一个与平台无关的中间代码IR(Intermediate Representation),即JVM的字节码。通常的CPU是没法直接运行Java字节码的,所以就需要一个编译器把字节码转成与本地相关的机器码,这个是依赖于平台的。也就是,一个程序能真正的被执行,只有编译成与该平台一致的机器指令才可以。

上面的图,展示了JIT的工作过程。为了将中间态的字节码转化成指定平台的可执行代码,JVM会在运行时,通过此时运行平台的架构,将字节码编译成指定的机器指令,它是一个动态编译的过程。目前默认的JIT编译器是HotSpot编译器,它是OpenJDK的编译器,是一个用java语言编写的开源编译器。

JIT编译器通过获取运行时的信息,能够进行大量复杂的动态优化,这些优化可以大大的提升性能。为此,HotSpot JIT编译器为了获得足够多的运行时信息,需要一个充足的“warm up”时间。在这段预热时期内,在数次万次代码被执行的基础上,JIT通过观察完整的类层次图,收集代码分支、类型信息等来进行更好的优化决策,进而获取更好的性能优化。

尽管JIT技术已经做了足够的优化,但Java程序仍然要比一些直接编译成机器码的语言要慢上一些,例如C或者Rust。特别是,在字节码未被翻译成机器码,仍以解释执行的过程时,是远比直接以机器码运行的程序来的慢的多。

Ahead of Time Compilation (AOT)

AOT编译是一种静态编译的方式,是在程序被执行前,就将其编译成机器码的编译方式。这种方式其实是最古老的一种编译方式,例如C语言就是这种静态编译的方式,生成的机器码需要与指定的操作系统以及平台硬件绑定。随着GraalVM的出现,Java的AOT编译方式也成为了一种可能。GraalVM是基于Java编写的,通过利用JVMCI与HotSpot VM集成,它能够将JVM的字节码实现一个高度优化的AOT编译。GraalVM项目是由Oracle发起的,其目标是能为java程序提供更高的性能以及可扩展性,为此它能够以更低的资源消耗(比如更少的CPU与内存)执行更快的程序,使其成为默认JIT编译器的更好替代。

在GraalVM中,利用原生镜像(native-image)工具可以编译出一个完全自我独立与平台的可执行二进制程序,其内部包含应用程序的classes以及依赖包的classes还有运行时lib以及来自JDK的静态链接的本地代码。生成的可执行程序,不需要JVM,可以执行在机器上运行。虽然没有JVM,但还是需要JVM包含的相关必要组件,例如内存管理、GC、线程调度等,这些功能在GraalVM中被放入了一个叫做“Substrate VM”的最小化VM。由于是直接面向机器生成的可执行程序,以及最小化的VM,具有比JVM更快的启动速度以及更小的内存占用。

下面是GraalVM利用native-image工具进行AOT编译的过程,它将Java的字节码作为输入,并输出一个可执行的程序。在编译的过程中,native-image在一个叫“封闭世界假设”的基础上对字节码进行静态分析,它会查看整个程序的所有被用到的代码,被删除那些永远不会被使用的代码。整个过程里有三个关键概念:

  • 指针分析(Points-to analysis)。利用指针分析,GraalVM的native image会寻找出所有可达的java类、方法以及字段并将其放入可执行程序中。指针分析从入口处作为起点,通常是程序的main方法,并不断的迭代所有可达的路径直接到达一个固定的位置。整个分析过程,不单单包括应用程序的代码也包括所有的库以及JDK中的类,即所有需要的一切代码都会被打入到可执行的二进制程序中。
  • 编译时类初始化。为了保证正确性,GraalVM默认类的初始化是放在运行时进行初始化,但如果native-image在编译时能够证明某些类在编译时初始也是安全的话,那么就会直接在编译时进行类初始化,进而减少了运行时的初始化与检查,从而提升了性能。
  • 堆快照化。堆快照化,是一个非常有趣的概念,要深入讲解需要独立一篇文章介绍。通过堆快照化,在编译的过程中,Java对象的内存分配及其所有可达的对象都会被写入到堆镜像中,这样可以使程序能够快速的启动。

在优化方面,GraalVM的AOT编译执行的更为激进,例如对无用代码与依赖的删除、堆快照化、静态类初始化等。其生成的可执行程序,能够直接在没有JVM环境的机器里直接运行,并获得堪比C、Rust、Go这类常用于高性能计算的效果。

JIT vs. AOT

到现在为此,我们介绍了字节码编译的过程以及JIT、AOT这两种工作方式。那么到底是该选用JIT还是AOT呢?遗憾的是,没有确切的答案,具体要看情况。接下来,我们对这两者进行对比分析,相信通过下面的介绍,能够让你有一个对这两种技术选择的参考。

JIT的技术能够让程序实现跨平台,事实上,Java之所以能够在众多语言中脱颖而出并流行至今,也是因为这个,正如其口号“一次编译,到处执行”。JVM能够通过并发的垃圾回收器来减少停顿,利用分支预测等优化手段来提高峰值的吞吐量。

另一方面,AOT技术能够让程序运行的更高效,特别是适合云原生技术。它提供了更快的启动速度,这个可以使得服务能够有更短的启动时间以及更快的水平伸缩的效果。尤其是在以容器化运行的微服务架构下,变的特别有利,它能够具有更小的包体(在编译时删除了无用的代码),更小的image镜像。更小的内存占用,能够运行更多的应用,减少服务的开销,特别是在Serverless的场景下。

下面是JIT与AOT两者的关键对比:

总的来说,GraalVM的AOT编译具有传统JIT方式没有的优势:

  • 相比JVM,只需要很小的一部分资源
  • 程序能够在毫秒级启动
  • 无需预热,即能提供峰值性能
  • 可以被打包成更轻量的容器镜像来有助于更快更高效的部署
  • 减少安全攻击的情况

封闭世界假设

AOT的指针分析技术,需要依赖于能够看到全部的字节码才能正确工作。这个限制被称为“封闭世界假设”。它意味着程序包括它的依赖里所有运行时需要被调用的字节码都要在编译器被“看到”(观察并分析),即既不能加载外部的class(例如Class.getResource),也不能动态创建class。因此,Java中的动态特性就不能够支持,例如JNI(Java Native Interface)、反射技术Java Reflection、动态代理以及classpath resources等。

为了克服这个限制,GraalVM提供了一个Tracing Agent工具,通过这个agent,能够追踪所有使用动态特性的代码,并将这些信息生成对应的文件输出到指定目录,例如 jni-config.json, reflect-config.json, proxy-config.json, and resource-config.json等。这些配置文件以Json的格式独立生成,包含了所有需要动态访问的拦截位置。通过将这些文件传给native-image工具,可以在编译的时候保证不把涉及到这些动态特性的类被删除。

虽然“封闭世界假设”有诸多的限制,但有一点值得一提的是,它在安全上有优先的表现,它能够消除各种代码注入的可能性。例如Log4j vulnerability的问题在2021年震惊了整个互联网,其原因就是利用了Log4j动态类加载的机制。

关于如何支持或者接受“封闭世界假设”(或者说放弃Java的动态特性),在 Project Leyden: Beginnings里有对应的说明,通过利用一个渐进的方式来逐渐实现全面的“封闭世界”。

GraalVM - Java的未来?

云原生技术的背景下,AOT编译的好处给行业带来了巨大的兴趣,Java生态对此抱有巨大的热情。目前,支持GraalVM编译与优化的应用框架主要有4个:

目前看起来,GraalVM的AOT技术似乎是JVM-based语言的未来,例如Java、Scala、Kotlin。但由于AOT技术是工作在“封闭世界假设”的基础上的,无法分析编译那么需要动态特性的库,而过去的代码库往往都会有动态特性。虽然Tracing Agent的技术能够兼容这种情况,很多库的社区也在往“封闭世界假设”这个方向上靠近,但目前仍然支持的还不够充分。可以预见,AOT这项技术如果要被大规模的采用,仍需要一段很长的时间才会走向成熟。

总结

Java目前有了JIT与AOT两种编译方式,但很难说哪种方式更好。GraalVM利用AOT编译获得了更好的性能并减少了启动时间,但这一切都是基于“封闭世界假设”的基础上,并不支持Java的动态特性。为了支持动态特性,开发者仍然离不开标准的JIT编译器。

参考