AOT
前言
语言开发一直离不开一个话题:性能。
一直以来,如何提高程序的性能都是一个难点。
随着技术的不断发展,Java 给我们带来了一个新的解决方案:AOT。
JIT & AOT
在 AOT 之前,Java 都是在使用另外一种编译技术:JIT(Just-in-Time:实时编译)。
在我们使用的 Java 虚拟机中(HotSpot),集成了两种 JIT 编译器:Client Compiler 和 Server Compiler。
拓展
- Client Compiler
Client Compiler 注重启动速度和局部的优化。
工作原理:
- 局部简单优化(eg:字节码优化)。
- 将字节码构造成 HIR(High-level Intermediate Representation:高级中间表示)。
- 最后将 HIR 转换成 LIR(Low-level Intermediate Representation:低级中间表示),最终生成机器码。
- Server Compiler
Server Compiler 则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。
在 Hotspot 虚拟机中使用的 Server Compiler 有两种:C2 和 Graal,默认为 C2。
C2 编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为 Ideal Graph。 Ideal Graph 表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。
但无论是否进行全局优化,Ideal Graph 都会被转化为一种更接近机器层面的 MachNode Graph,最后编译的机器码就是从 MachNode Graph 中得的,生成机器码前还会有一些包括寄存器分配、窥孔优化等操作。
Graal Compiler 是从 JDK 9 之后,在 Hotspot 虚拟机中集成的编译器。 相比较 C2,Graal 会在 JVM 解析执行的时候手机程序运行的各种信息,然后编译器根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。 Graal 编译器可以通过 Java 虚拟机参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用。当启用时,它将替换掉 HotSpot 中的 C2 编译器,并响应原本由 C2 负责的编译请求。
但是近年来随着 Serverless、云原生等概念和技术的火爆,Java JVM 和 JIT 的性能问题越来越多地被诟病,在 Golang、Rust、node.js 等新一代语言的包夹下,业界也不断出现“云原生时代,Java 已死”的言论。所以 Java 需要破局,而破局的手段之一就是:AOT。
认识 AOT
AOT(Ahead-Of-Time)是一种编译技术,它可以在程序运行之前将源代码或字节码编译成机器代码,从而提高程序的启动速度和整体性能。
在 Java 中,AOT 机制能够有效地优化 Java 应用的启动时间,尤其是在需要快速响应的场景中,如微服务、容器化应用等。
Write once run anywhere
学 Java 的时候我们就知道了 Java 的一个承诺:Write once run anywhere。
但是,随着 AOT 的到来,破坏了这个承诺。
提前编译让我们规避了“第一次运行慢”的不好体验,但是这个提前编译缺破坏了这个承诺,因为它的存在,开发商需要为每一个不同的硬件、操作系统去编译对应的发型包,而这也会降低 Java 的动态性,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定,否则就只能舍弃掉己经提前编译好的版本,退回到原来的即时编译执行状态。
就一句话概括:同一份产物不能跨平台运行。
AOT 发展史
本身 Java 发展的好好的,对于企业和个人而言更是一种完美语言,程序员可以在本地开发完成后,打包部署到任何地方,但是随着 Docker 和 Serverless 的到来,慢慢风向开始发生转变。
Docker 容器化部署本身就突破了跨平台,也就是说 Java 的跨平台已经属于累赘了。
而 Serverless 的爆火,更是给了 Java 一棒子。
如果想让 Java 跟上云原生的脚步,貌似需要更多的努力,而 AOT 就是非常重要的一步了。
2016 年,OpenJDK 的 JEP 295 提案首次在 Java 中引入了 AOT 支持,在这一草案中,JDK 团队提供了 jaotc 工具,使用此工具可以将指定 class 文件中的方法逐个编译到 native 代码片段,通过 Java 虚拟机在加载某个类后替换方法的的入口到 AOT 代码来实现启动加速的效果。
jaotc 类似于给 JVM 打了一个“补丁”,让用户有权利将部分代码编译成机器码的时期提前,并预装载到 JVM 中,供运行时代码调用。不过这个补丁存在很多问题:
首先是在设计上没有考虑到 Java 的多 Classloader 场景,当多个 Classloader 加载的同名类都使用了 AOT 后,他们的 static field 是共享的,而根据 java 语言的设计,这部分数据应该是隔开的。由于这个问题无法快速修复,jaotc 最终给出的方案只是暴力地禁止用户自定义 classloader 使用 AOT。 此外,由于社区人手不足,缺乏调优和维护,jaotc 的实际运行效果不尽人意,有时甚至会对应用的启动和运行速度带来反向优化,实装没多久之后就退化为实验特性,最终在 JDK 16 中被删除,结束了短暂的一生。
后来阿里 AJDK 团队自研的 AppCDS(Class-Data-Share)技术继承了 jatoc 的思路,进行了大幅的优化和完善,目前也不失为一种 Java AoT 的选择,其本质思路和 jaotc 基本一致,这里就不再赘述了。
而目前业界除了这种在 JVM 中进行 AOT 的方案,还有另外一种实现 Java AOT 的思路,那就是直接摒弃 JVM,和 C/C++ 一样通过编译器直接将代码编译成机器代码,然后运行。这无疑是一种直接颠覆 Java 语言设计的思路,不过还是被各路大佬们实现了,那就是 GraalVM Native Image。
它通过 C 语言实现了一个超微缩的运行时组件 —— Substrate VM,基本实现了 JVM 的各种特性,但足够轻量、可以被轻松内嵌,这就让 Java 语言和工程摆脱 JVM 的限制,能够真正意义上实现和 C/C++ 一样的 AOT 编译。这一方案在经过长时间的优化和积累后,已经拥有非常不错的效果,基本上成为 Oracle 官方首推的 Java AOT 解决方案。
GraalVM
GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。
拓展
以 2024-11-29 日这天为准,我们可以在官网下载两种版本的 GraalVM,Java 21 和 Java 23,如果有需要其他版本的,可以到 Oracle 官网进行下载。
GraalVM 本身是一个非常庞大的项目,并且从它可以支持的语言不难发现,这货想要统一编程,有兴趣的朋友可以好好研究一下 GraalVM。
我们接下来要学习的就是他里面的一个核心组件:Native Image。
Native Image
生成一个 Native Image 是需要应用代码、各种依赖库、JDK、以及 Substrate VM。
有了这些东西,就可以对整个应用代码进行静态分析了,这个分析过程类似 GC 的“可达性分析”,分析过程中会将可达的代码、变量、对象等生成快照,最终打包变成一个可执行的 Native Image。
一个完整的 Native Image 包含两个部分,一部分称为 Text Section,即用户代码编译成的机器代码;另一部分称为 Data Section,存储了应用启动后堆区内存中各种对象的快照。
在整个静态分析的过程中,会以 Pionts-to Analysis -> Run Initializations -> Heap Snapshotting 的方式迭代进行,一直到不可达程度。
通过这种方式,最后可以得到两个树对象,一个 Call Tree(包含所有可达的方法),另一个 Object Tree(包含所有可达的对象)。Call Tree 中所包含的方法会被AOT编译为机器码,成为 Native Image 的Text Section,而 Object Tree 中所包含的对象及变量则会被保存下来,写入 Native Image 的 Data Setion。
拓展
Native Image 中是如何处理 JVM 的动态特性?
针对于 JVM 的动态特性,例如反射、代理、JNI 等,Native Image 设置了一个名为 Closed World Assumption 去作为静态分析的基本前提。
Pionts-to Analysis 需要查看所有字节码 如果查看不到所有字节码,无法进行 AOT 优化,不能删除未使用的类、方法、字段,并且需要在运行时有类加载器。
Java 的动态部分需要在构建时进行配置 例如反射、JNI、代理等这些需要在配置文件中声明。
在整个程序运行过程中不会动态加载任何新的 class。
实践
这里实践到时候放在项目中吧,本次博客就不多说了,因为需要弄 GraalVM 的环境。