本节摘要:Graal 编译器既是可替换 C2 的高速 JIT,也是 Native Image 的构建引擎:在构建期完成类加载与静态初始化,把可达代码提前编译成原生可执行文件。本节讲清 AOT 的收益与限制(反射登记、峰值让步、构建成本),给出适合与不适合上原生镜像的场景清单,并顺带指认 Valhalla 与 Panama 两条演进线的方向。
上一节的分层编译是一场"运行期持续投资":用解释执行换启动速度,用计数与编译换峰值性能。这套模型服务端长跑无往不利,但在另一类场景里水土不服——函数计算实例存活几秒、命令行工具只跑一次、调度密集的容器频繁扩缩容,运行期编译的投资还没回本进程就退场了。AOT(Ahead-of-Time,提前编译)把投资挪到构建期:部署前就把字节码编译成机器码,运行期一分编译钱都不花。本节讲清这笔账的两面。
Graal 首先是一个用 Java 写的 JIT 编译器:与 C2 功能对位,但实现语言是 Java(C2 是 C++),扩展友好,经过多年迭代后部分负载的峰值性能已能与 C2 打平甚至反超。启用方式也简单——在 HotSpot 上加 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler,让 Graal 替换 C2 干活,其余一切照旧:类加载、GC、分层编译的框架都保留。
它的第二重身份才是本节主角:作为 Native Image 的构建引擎,把"字节码进 JVM 执行"整个模型替换为"构建期产出独立可执行文件"。构建阶段,Graal 以 main 方法为根做静态可达性分析,只有被引用到的类、方法、字段进入编译范围;静态初始化在构建期执行完毕,结果固化进镜像;运行期没有解释器、没有 JIT、没有传统类加载——启动即是编译后的机器码直接运行。
动手感受一下差距(GraalVM 环境):
public class QuickStart { public static void main(String[] args) { long start = System.nanoTime(); System.out.println("boot done in " + (System.nanoTime() - start) / 1_000_000 + " ms (measured inside main)"); } }
$ javac QuickStart.java $ time java QuickStart boot done in 11 ms (measured inside main) java QuickStart 0.12s user 0.05s system 62% cpu 0.272 total $ native-image QuickStart --no-fallback -o quickstart $ time ./quickstart boot done in 0 ms (measured inside main) ./quickstart 0.00s user 0.00s system 4% cpu 0.017 total
从拉起进程到 main 执行完,传统 JVM 两百多毫秒,原生镜像约十七毫秒——差出一个数量级,且常驻内存小得多(没有 JIT 编译缓存与整套运行时设施)。代价在构建侧:可达性分析加编译要跑几十秒到几分钟,镜像构建是重型操作。
运行期没有 JIT,上一节讲的那套"动态适应"能力就全部让渡了。逐项列清楚:
第一,动态特性需要显式登记。可达性分析是静态的,运行期才发生的反射查找、动态代理生成、JNI 调用、资源加载,都必须在构建配置里登记(反射要写明哪个类、代理要写明接口列表)。Spring、MyBatis 这类重度依赖运行期魔法的框架,为原生镜像提供了专门的构建期处理(提前生成代理与依赖注入代码),但第三方库的支持程度参差,迁移工作量大头就在这。
第二,峰值性能通常让步。没有运行期画像,就没有基于真实分支与类型的激进优化;逃逸分析等优化仍然有,但"越跑越快"的曲线消失了。对吞吐敏感的长跑服务,充分预热的 HotSpot 仍是多数基准中的赢家。
第三, Profile-guided 优化缺位带来的开发体验变化。编译期无法预测的路径(罕见异常分支、动态类加载)行为与传统 JVM 有差异,报错时机与堆栈形态都会变,排查工具也要换一套(第六章的 jstat、jstack 多数失效,得用 GDB 类原生工具或 GraalVM 自带诊断)。
一张表把选型判断收拢:
| 维度 | JIT(传统 HotSpot 路线) | AOT(Native Image 路线) |
|---|---|---|
| 启动到可服务 | 数百毫秒到数秒(含预热) | 个位数到十几毫秒 |
| 峰值吞吐 | 高(画像驱动的激进优化) | 中等(无运行期画像) |
| 常驻内存 | 较高(编译缓存、运行时设施) | 显著更低 |
| 反射与动态代理 | 运行期自由使用 | 构建期登记,成本前置 |
| 排错工具链 | jstat、jstack、JFR 全套 | 原生工具与专用诊断 |
| 适合负载 | 长跑服务、吞吐与延迟敏感 | Serverless、CLI、高频扩缩容 |
适合上 Native Image 的负载特征很清晰:进程短命(秒级到分钟级)、启动频繁(自动扩缩容、按请求拉起)、对常驻内存敏感(高密度部署)、代码路径相对静态(少反射、少脚本引擎)。反过来,长跑的大堆服务、重度依赖动态类加载的框架型平台(规则引擎、脚本化编排)、依赖大量未适配原生镜像的第三方库的应用,留在 JIT 路线上是更省心的选择。
Java 官方也在沿这个方向补齐标准件:Panama 项目的外部函数与内存 API 让"调用本地库、管理堆外内存"不再依赖 JNI 的运行期魔法(第二章直接内存一节提过它对 Cleaner 模式的替代);Valhalla 项目的值类型目标是消除对象头与指针间接,让小数据对象拥有接近原生结构的内存布局——这两条线落地后,AOT 与 JIT 的差距会进一步收窄。跟踪它们的节奏,比记住当前版本的具体参数更保值。
⚠️ 常见坑:把 Native Image 当成"免费加速"。它加速的是启动,不是执行;对长跑服务,换 AOT 常常是峰值性能的净损失。选型先看进程寿命:跑得越久,JIT 的画像投资越值钱,AOT 越不划算。
⚠️ 常见坑:迁移到原生镜像时只测主流程。反射登记遗漏的典型症状是"跑到某个边缘分支才炸"——序列化库按名字反射构造实体、日志框架按配置反射找 appender,都是主流程测不出、生产半夜炸的常客。迁移验收要覆盖配置加载、序列化、日志初始化这些反射重灾区。
💡 关键直觉:JIT 与 AOT 的本质区别是"什么时候知道代码长什么样"。运行期知道,就能按真实画像优化,但要付启动与内存的钱;构建期知道,启动与内存就便宜,代价是放弃运行期的一切意外可能。你的应用能不能接受"没有意外",就是选型的分水岭。
第四章完工:字节码怎么读、热点怎么晋升、不开热点引擎的路怎么走,动力系统的三张图纸集齐。下一章是全书重头戏——垃圾回收:那些被创建出来的对象,最终由谁、按什么标准、以什么成本清运。