1. JIT 与 AOT 优化原理
1. 核心概念解释
1.1 JIT (Just-In-Time) 编译:运行时激进优化
JIT 编译器的最大优势是它在程序运行时工作,它拥有大量的运行时信息(Profiling Data) 。
-
它看到了什么? 它能观察到程序在实际运行中的行为:
- 哪些方法是“热点”(Hotspot) ,被频繁调用,值得深度优化。
- 某个虚方法(virtual method)的实际类型是什么? 95% 的情况下都是同一个具体类吗?
- 循环会运行多少次? 是多次循环还是经常提前退出?
- 某个分支条件(if-else)通常是 true 还是 false?
-
它基于这些信息能做什么“激进优化”?
- 方法内联 (Inlining) :将一个频繁调用的小方法体直接嵌入到调用者中,消除方法调用的开销。JIT 可以基于实际类型进行非常激进且准确的内联。
- 虚调用优化 (Virtual Call Devirtualization) :如果发现某个虚方法在运行时几乎总是被一个特定类型调用,JIT 可以将其优化为一个直接调用,甚至进一步内联。这为其他优化打开了大门。
- 循环优化 (Loop Unrolling) :将循环体展开,减少循环控制指令的次数。
- 分支预测 (Branch Prediction) :根据运行时统计,将最可能走的分支代码放在指令缓存更优的位置。
- 逃逸分析 (Escape Analysis) :在栈上分配对象而不是堆上,甚至完全消除不必要的对象分配。
简单比喻:JIT 像一个经验丰富的司机,在行驶过程中(运行时)不断观察路况(Profiling Data),然后选择最优路线、超车、换挡(激进优化),越开越快。
1.1.1 JIT (即时编译) 运行时激进优化原理
- 初始执行:代码首先由解释器执行,速度较慢。
- 分析监控:性能分析器(Profiler) 在后台默默工作,收集代码运行的真实数据(黄金信息)。
- 触发编译:当某段代码(如
handleRequest方法)被频繁调用,被判定为“热点”时,JIT编译器被触发。 - 激进优化:JIT编译器不是简单地将字节码编译成机器码,而是基于收集到的运行时数据进行极其大胆的假设和优化(见图中的优化策略列表)。
- 存入缓存:优化后的、性能极高的机器码被存入代码缓存。
- 直接执行:之后所有对该代码的调用,都将直接执行这份优化后的机器码,从而达到极致性能。
核心特点: 边跑边学,越跑越快。优化基于程序运行时的真实行为,极其精准和高效。
1.2 AOT (Ahead-of-Time) 编译:静态保守优化
Native Image 的 AOT 编译在程序运行之前就必须完成所有工作。
- 它看不到什么? 它没有任何运行时信息。它不知道程序输入是什么,不知道哪些代码会是热点,不知道类的具体继承关系在运行时如何体现。
- 因此它必须保守得多:
- 方法内联受限:它只能内联那些在编译时就能确定的方法(如
private,static,final方法)。对于虚方法,它无法知道具体类型,因此无法进行激进的内联。 - 无法有效优化虚调用:虚调用在 AOT 编译后通常仍然是虚调用,需要通过查虚方法表来分发,这比直接调用慢。
- 必须为所有可能用到的代码做准备:基于“封闭世界假设”,它必须把所有可达的代码都编译进去,即使某些代码可能永远也不会被执行(比如错误处理路径)。这有时会牺牲掉一些为常见路径做极端优化的机会。
- 方法内联受限:它只能内联那些在编译时就能确定的方法(如
简单比喻:AOT 像一个出发前做行程规划的司机。他只能看着地图(代码)做计划(编译),选择一条看起来不错的路线。但一旦上路(运行时),他就无法根据实时交通情况(运行时信息)改变路线来优化了。
1.2.1 AOT (提前编译) 静态保守优化原理
- 静态分析:在程序运行之前,静态分析器只能对着源代码和字节码进行分析。它知道代码结构,但不知道代码运行时会发生什么。
- 保守决策:AOT编译器无法做出大胆假设。它的优化策略非常保守(见图中的策略列表)。例如,对于一个虚方法,它不知道运行时具体是哪个实现类,因此只能编译成标准的、性能较低的虚方法调用指令。
- 包含所有路径:为了保证正确性,编译器必须将所有可能被执行到的代码路径(如
else分支、异常处理流程)都编译进最终的可执行文件中,即使这些路径可能永远也走不到。 - 生成成品:输出一个完全编译好的、自包含的可执行文件。
- 固定性能:运行时,这个文件被直接加载执行。里面的代码无法再根据运行时情况发生变化或优化。它的性能在编译完成的那一刻就已经决定了。
核心特点: 一次编译,到处运行(但性能固定) 。用潜在的峰值性能损失,换来了启动速度和部署的便利性。
2. 为什么“峰值性能可能略低”?
现在我们把上面两个概念结合起来,解释性能差异。
-
场景:一个长期运行的、负载很高的微服务。例如,一个处理大量请求的 API 服务器。
-
JVM (JIT) 的表现:
- 服务启动后,最初几分钟,代码由解释器执行,速度较慢。
- JIT 编译器开始收集分析数据(Profiling)。
- 它识别出处理核心业务的
handleRequest()方法是热点。 - 它进一步发现,
handleRequest()中调用的service.process()这个虚方法,99.9% 的情况下都是DefaultProcessor这个类。 - 激进优化发生:JIT 将
service.process()去虚拟化并直接内联到handleRequest()中。现在,处理一个请求就是执行一段连续高度优化的机器码,效率极高。 - 服务达到 “峰值性能” ,吞吐量极高,延迟极低。
-
Native Image (AOT) 的表现:
- 服务瞬间启动,直接以编译好的机器码全速运行。这是它的巨大优势。
- 但是,在编译时,它无法确定
service.process()的具体类型。为了 correctness(正确性),它只能编译成一个标准的虚方法调用。 - 每次执行到
service.process()时,CPU 都需要进行一次查表(虚方法表)和间接跳转。这个操作比直接调用慢,也更不利于CPU的指令缓存和分支预测。 - 因此,即使运行了很久,它的代码执行路径也无法自我优化到 JIT 版本那样极致的状态。
- 其峰值吞吐量可能比充分预热后的 JIT 版本低 10%-20% (这是一个常见的范围,具体因 workload 而异),延迟可能略高。
3. 综合对比选择
| 特性 | GraalVM Native Image (AOT) | 传统 JVM (JIT) |
|---|---|---|
| 优化时机 | 编译时 | 运行时 |
| 可用信息 | 仅静态代码分析 | 运行时 Profiling 数据(黄金信息) |
| 优化策略 | 保守、安全 | 激进、基于假设(可去虚拟化、激进内联) |
| 启动性能 | 极快(毫秒级) | 慢(秒级) |
| 峰值性能 | 良好,但通常略低于优化后的 JIT | 极高(经过充分预热后) |
| 最佳场景 | 短期任务、命令行工具、Serverless、需要快速扩缩容的微服务 | 长期运行的服务、对极限吞吐量有要求的重型应用 |
结论:
这是一个典型的 “权衡” 。
- 你选择 Native Image,是用一点点峰值性能的潜在损失,换来了启动速度、内存占用和分发便利性的巨大提升。对于云原生时代的大部分应用,尤其是微服务和 Serverless,这个交换是绝对值得的。
- 你选择 JVM JIT,则是用较慢的启动和较高的内存开销,换取了长期运行后无人能及的极限优化和峰值性能。对于需要7x24小时运行且计算密集型的核心后端服务,它仍然是王者。
技术选型没有银弹,理解其中的权衡才能做出最适合自己业务场景的选择。