GraalVM与AOT编译原理
# 10.GraalVM与AOT编译原理
# 目录介绍
- 1. 案例引入
- 2. AOT 与 JIT 之争
- 3. GraalVM 全家桶
- 4. 闭世界假设
- 5. 构建过程拆解
- 6. SubstrateVM 揭秘
- 7. 反射与动态特性
- 8. 性能取舍真相
- 9. 选型决策矩阵
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一次冷启动竞赛
某金融团队做 Serverless 改造,把一个 Spring Boot 微服务搬上 FaaS 平台——按调用次数付费、闲置即冷却。第一周账单出来时所有人惊掉下巴:冷启动费用占总账单的 73%。
复盘数据:
请求峰值: 1200 QPS → 同时拉起约 80 个实例
冷启动时间: 4.2 秒 → 用户首请求超时
JVM 内存基线:256 MB → 单实例固定内存开销
预热到峰值: 约 2 分钟 → JIT 编译热代码
2
3
4
问题不在业务代码——业务逻辑只占启动 4.2 秒中的 0.4 秒。剩下 3.8 秒全花在:
0.5s JVM 启动 + 加载 BootClassLoader 核心类
1.4s Spring 扫描 ClassPath、构建 BeanDefinition
0.8s 反射调用初始化全部单例 Bean
0.6s Tomcat 启动 + 监听端口
0.5s JIT 解释执行的"冷"阶段(性能仅峰值 30%)
2
3
4
5
团队决定用 GraalVM Native Image 重新打包同一份代码,结果让人意外:
冷启动时间: 4200 ms → 38 ms (110x 提升)
基线内存: 256 MB → 18 MB (14x 缩减)
镜像大小: 52 MB jar → 78 MB binary (变大了)
峰值 TPS: 8500 → 6800 (下降 20%)
构建时间: 18 秒 → 4 分 30 秒 (15x 变长)
2
3
4
5
冷启动 110 倍提升、内存缩 14 倍——但峰值吞吐反而下降 20%、构建时间膨胀 15 倍。
# 1.2 反直觉的曲线
如果把"启动 → 运行"这条时间轴画成 TPS 曲线,两个产物的曲线走向截然相反:
TPS
│
│ ┌────────── 传统 JVM (峰值 8500)
│ ╱
│ ╱
│ ╱ ←—— JIT 预热 1~2 分钟
│ ╱
│ ┌───┴──────────── Native Image (稳态 6800)
│ │
│ │← 38ms 即达稳态
├───┴──────────────────────────────────────→ 时间
0 100ms 60s 120s
2
3
4
5
6
7
8
9
10
11
12
这条曲线浓缩了整个 GraalVM 设计哲学的核心矛盾:
- 传统 JVM:起步慢但天花板高——靠 JIT + 运行时反馈把热代码越优化越极致
- Native Image:起步飞快但天花板低——AOT 在编译期就把代码焊死,没有运行时学习能力
更深的问题是:这两条曲线哪条"更好"?——答案完全取决于业务场景。Serverless 选 Native Image 一年省百万;长跑微服务硬切 Native Image 一年损失千万。
# 1.3 我们要回答什么
第 18 篇要把 "Java 还能不像 JVM 那样跑" 这件事讲透——读完之后再面对一个 Java 应用,5 分钟内能判断它该不该上 Native Image,并能识别迁移过程的所有坑点。
带着这个目标,要回答 7 个核心问题:
① AOT 不是新东西(C/C++ 早就这样),为什么到 Java 才大火? → 第2.3节
② GraalVM 到底是什么?跟 OpenJDK / OracleJDK 什么关系? → 第3章
③ "闭世界假设"是什么?为什么它是 Native Image 的灵魂? → 第4章
④ 反射 / 动态代理 / SPI / 资源加载在 Native Image 还能用吗? → 第7章
⑤ 为什么 Native Image 启动 100x 但峰值吞吐下降 20%? → 第8章
⑥ 构建时间从 18 秒到 4 分钟,多出来的时间在做什么? → 第5章
⑦ 哪些场景一定要上 Native Image?哪些上了会翻车? → 第9章
2
3
4
5
6
7
本篇路线:
AOT vs JIT 历史 (第2章)
↓
GraalVM 全家桶解构 (第3章)
↓
闭世界假设 (第4章) ←—— 灵魂概念
↓
构建过程五阶段 (第5章)
SubstrateVM 揭秘 (第6章)
反射与动态特性 (第7章)
↓
性能取舍真相 (第8章)
选型决策矩阵 (第9章)
↓
综合案例串讲 (第10章)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2. AOT 与 JIT 之争
# 2.1 两种翻译路径
把"源代码 → CPU 执行"这条路画出来,AOT 与 JIT 是两条截然不同的路径:
源代码 (.java)
│
│ javac
↓
字节码 (.class)
│
┌────────────┼────────────┐
│ │
传统 JVM 路线 AOT 路线
│ │
↓ ↓
类加载 + 解释执行 静态分析 + 整体编译
│ │
↓ ↓
热代码触发 JIT 编译 生成原生可执行文件
│ │
↓ ↓
Tier1→Tier4 渐进优化 一次性焊死所有决策
│ │
↓ ↓
运行时机器码 直接运行机器码
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
关键差异:
| 维度 | JIT (传统 JVM) | AOT (Native Image) |
|---|---|---|
| 编译时机 | 运行时 | 构建期 |
| 输入信息 | 字节码 + 运行剖面 | 字节码 + 配置 |
| 输出形式 | 内存中的机器码 | 磁盘上的可执行文件 |
| 优化空间 | 基于实际数据的剖面引导 | 基于静态分析的保守估计 |
| 启动开销 | 解释 → 编译多次切换 | 零(直接执行) |
# 2.2 各自的代价
JIT 的代价——14 篇详细讲过:
+ 长跑业务峰值性能极致(运行时数据指导优化)
+ 跨平台一次编译到处运行
- 启动慢(解释执行 + 类加载)
- 内存占用高(JVM 自身 + Metaspace + CodeCache)
- 部署体积大(JRE 至少 50MB+)
2
3
4
5
AOT 的代价——本篇主角:
+ 启动飞快(毫秒级)
+ 内存占用极低(无 JVM 自身开销)
+ 部署体积可控(单一二进制,无需 JRE)
- 构建时间长(静态分析占大头)
- 峰值性能受限(无运行时剖面)
- 动态特性受限(反射 / 代理 / SPI 需配置)
- 平台绑定(Linux 镜像不能跑在 macOS)
2
3
4
5
6
7
疑惑:既然 JIT 和 AOT 各有优劣,为什么不"两个都要"?
论证:实际上 OpenJDK 早在 JDK 9 就尝试过 —— jaotc 工具,把热代码 AOT 编译成 .so 文件,运行时仍然用 JVM 加载。但这条路在 JDK 17 被废弃,JDK 21 彻底移除。原因是:
- 维护成本高:jaotc 与 JIT 共享一套 IR,演进时两边要同步改
- 收益不明显:仍然需要完整 JVM 运行时,启动只省几百毫秒
- 生态分叉:开发者难以判断"什么时候 AOT、什么时候 JIT"
结论:纯 AOT (Native Image) 才是真正的范式革命——它不是"在 JVM 上加 AOT",而是"绕过 JVM"。这是与 jaotc 的根本区别。
# 2.3 为何此时复活
疑惑:AOT 是 1950 年代就有的老技术(C/Fortran 早期都是 AOT),为什么直到 2019 年(GraalVM 19.0)才在 Java 圈大火?
论证:三股潮流同时汇聚:
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 云原生 / FaaS │ │ 容器化部署 │ │ 微服务拆分 │
│ 按调用计费 │ │ 镜像越小越好 │ │ 单服务变小 │
│ 冷启动决定钱包 │ │ 内存越省越好 │ │ 每个 JVM 都贵 │
└────────┬────────┘ └────────┬────────┘ └────────┬─────────┘
│ │ │
└───────────┬───────────┴───────────────────────┘
↓
对"启动快 + 内存小"的需求
第一次成为业务一等公民
↓
AOT 复活的土壤
2
3
4
5
6
7
8
9
10
11
12
结论:不是 AOT 突然变好了,是业务场景变了。同一项技术,在 2008 年是负担(JIT 长跑业务的天敌),在 2024 年是救命稻草(Serverless 冷启动杀手)。技术评价永远绑定于场景,没有绝对的好坏。这条规律在第 17 篇 GC 演进、第 11 篇 IO 模型演进里都见过。
# 3. GraalVM 全家桶
# 3.1 三个核心组件
GraalVM 不是一个单独的工具,而是一个技术栈:
┌─────────────────────────────────────────────────────┐
│ GraalVM │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Graal │ │ Truffle │ │ Native │ │
│ │ Compiler │ │ Framework │ │ Image │ │
│ │ │ │ │ │ │ │
│ │ 替代 C2 │ │ 多语言运行 │ │ AOT 打包 │ │
│ │ JIT 编译器 │ │ (JS/Py/R..) │ │ │ │
│ └──────────────┘ └──────────────┘ └───────────┘ │
│ ↓ ↓ │
│ JIT 模式 AOT 模式 │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
三者的关系:
- Graal Compiler 是基础——一个用 Java 写的高级 JIT 编译器,可以替代 HotSpot 的 C2
- Truffle 是上层框架——基于 Graal,实现"用 Java 写解释器、自动获得高性能",让 GraalVM 同时跑 JavaScript / Python / Ruby / R
- Native Image 是另一种产物形态——用 Graal 在构建期把整个应用编译成可执行文件
关键澄清:本文说的"GraalVM",多数语境特指 Native Image 这条路径——这才是与传统 JVM 范式对立的部分。
# 3.2 Graal 编译器
疑惑:Graal 既然只是替代 C2,跟"AOT"有什么关系?
论证:Graal 同一份编译器代码可以工作在两种模式:
模式 1:JIT (作为 HotSpot 的 C2 替代)
字节码 + 运行剖面 → Graal → 机器码 (运行时)
开关:-XX:+UseJVMCICompiler -XX:+UseGraalJIT
模式 2:AOT (作为 Native Image 的核心引擎)
字节码 + 静态分析结果 → Graal → 机器码 (构建期)
工具:native-image
2
3
4
5
6
7
Graal 自身的关键特性:
- 完全用 Java 写——HotSpot 的 C2 是 C++ 实现,Graal 用 Java 实现
- 基于 IR (Sea of Nodes)——比 C2 的 IR 更现代、更易演进
- 支持 PGO(Profile-Guided Optimization)——AOT 模式下也可用历史剖面优化
结论:Graal 是"编译器即库"的范式——可以嵌入 JVM 当 JIT,也可以独立运行做 AOT。这种解耦是 Native Image 能存在的技术前提。
# 3.3 Truffle 多语言
Truffle 与本篇主线关系不大,但理解它有助于理解 GraalVM 的整体定位:
// Truffle 让你"用 Java 写一个 Python 解释器"
// 只要按 Truffle API 写解释器
// GraalVM 自动用部分求值 (Partial Evaluation) 把解释器
// 优化到接近原生 Python 的性能
2
3
4
实际产物:
- GraalJS——比 V8 引擎慢约 2x,但能直接调 Java 类
- GraalPy——CPython 兼容
- TruffleRuby——比 MRI 快 5~30x
本篇之后不再展开 Truffle——重心回到 Native Image。
# 3.4 Native Image
Native Image 的核心承诺:
输入:一份普通的 Java 应用 (jar 或 module)
输出:一个原生可执行文件
Linux: ELF
macOS: Mach-O
Windows: PE
特性:
✓ 无需 JRE 即可运行
✓ 启动时间毫秒级
✓ 内存占用极低
✓ 与 C/C++ 程序具备相同部署形态
2
3
4
5
6
7
8
9
10
11
与 Go / Rust 二进制的区别:
- Native Image 仍然是有 GC 的语言(不像 Rust 的 RAII)
- Native Image 仍然有运行时(SubstrateVM,见 §6)
- Native Image 仍然支持多线程 + 内存模型
简单说——它是"长得像 Go 二进制、内核仍是 Java"的混血产物。
# 4. 闭世界假设
# 4.1 假设的含义
闭世界假设(Closed World Assumption,CWA)是 Native Image 最重要的设计前提,没有之一。
闭世界假设:构建期能枚举到的类、方法、字段,就是运行期所有可能用到的全部。运行期不会出现新的类。
对比传统 JVM 的开世界:
传统 JVM (开世界) Native Image (闭世界)
───────────────── ──────────────────
运行时可加载新类 构建期固定所有类
反射可访问任意成员 反射目标必须预声明
ClassLoader 可热部署 没有 ClassLoader 概念
SPI 可发现新服务 SPI 必须构建期注册
代理可动态生成 代理必须 buildtime 生成
2
3
4
5
6
7
# 4.2 可达性分析
疑惑:闭世界假设怎么落地?编译器怎么知道哪些类要不要进二进制?
论证:通过静态可达性分析——从 main 方法出发做巨大的图遍历:
入口点 (main)
│
│ 直接调用
↓
┌───────┐ ┌──────┐ ┌─────────┐
│ Foo() │→│ Bar()│→│ Baz() │
└───┬───┘ └──┬───┘ └────┬────┘
│ │ │
↓ ↓ ↓
引用类型 引用类型 Class.forName(...)
(静态) (静态) ↑
│
必须配置文件告诉
"这里要加载 X 类"
2
3
4
5
6
7
8
9
10
11
12
13
14
核心算法:
- 从所有入口点(main / 静态初始化 / 测试入口)出发
- 沿着静态可见的调用边遍历
- 遇到反射 / Class.forName 时查配置文件——配置里登记的类视为可达
- 不可达的类、方法、字段全部不进二进制——这是镜像能瘦身的根本原因
结论:Native Image 的瘦身不是"压缩",是"裁剪"。如果 jar 是 50MB 但应用只用了 20% 的代码,那么 Native Image 镜像里只剩这 20% 的机器码——这是 AOT 相比 JVM 的另一项免费收益。
# 4.3 假设代价清单
闭世界不是免费的——它强制要求你:
| 动态行为 | 闭世界下的对策 | 成本 |
|---|---|---|
Class.forName("X") | reflect-config.json 注册 X | 配置维护 |
Method.invoke(...) | reflect-config.json 注册 method | 配置维护 |
Proxy.newProxyInstance(...) | proxy-config.json 注册接口列表 | 配置维护 |
getResource("/conf.yaml") | resource-config.json 包含资源 | 配置维护 |
| ServiceLoader | META-INF/services 自动扫描,但 impl 类需 reflect 配置 | 半自动 |
| 字节码生成(CGLIB / ASM) | 基本无解,必须改用 buildtime 代理 | 重写代码 |
| 类热加载 / OSGi / 热部署 | 完全无法支持 | 放弃使用 |
关键认知:闭世界假设把"动态性"换成了"启动速度 + 内存效率"——这是一笔明确的、不可逆的取舍。如果业务严重依赖动态性(如 OSGi 插件框架、字节码热替换),Native Image 就不是选项。
# 5. 构建过程拆解
# 5.1 五大构建阶段
执行 native-image -jar app.jar 时,幕后跑了一个长链路:
flowchart LR
A[① Initializing<br/>类路径扫描] --> B[② Performing analysis<br/>静态可达性分析]
B --> C[③ Building universe<br/>类型与方法集合]
C --> D[④ Parsing<br/>字节码 → IR]
D --> E[⑤ Compiling<br/>IR → 机器码]
E --> F[⑥ Layouting<br/>布局到 ELF/Mach-O]
F --> G[原生可执行文件]
2
3
4
5
6
7
真实构建日志(节选):
[1/8] Initializing... (3.2s @ 0.20GB)
[2/8] Performing analysis... (78.4s @ 4.50GB)
12,856 reachable types (88.9% of 14,452 total)
23,447 reachable fields (60.1% of 39,038 total)
71,288 reachable methods (62.2% of 114,612 total)
[3/8] Building universe... (4.5s @ 4.10GB)
[4/8] Parsing methods... (8.9s @ 5.20GB)
[5/8] Inlining methods... (5.6s @ 5.30GB)
[6/8] Compiling methods... (95.7s @ 6.10GB)
[7/8] Layouting methods... (4.8s @ 6.20GB)
[8/8] Creating image... (3.6s @ 5.80GB)
Top 10 origins of code area:
24.7MB java.base
12.3MB svm.jar (Native Image)
8.5MB spring-core
...
Finished generating 'app' in 3m 45s.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
时间分布:分析 + 编译占 80%,其他阶段加起来 20%。构建膨胀的元凶就是这两步——这就回答了第 1 章疑问 ⑥。
# 5.2 静态分析迭代
疑惑:分析阶段为什么这么慢?不就是图遍历吗?
论证:实际上 native-image 的分析是多轮迭代——每轮发现新的可达点后回头重新算:
轮 1:从 main 出发,找到 100 个可达类
发现某个类的静态构造器引用了新类型
轮 2:把新类型加入工作集,再次遍历
又发现某些类有 @TargetClass 注解(替换原始类)
轮 3:处理替换关系,再算一遍
...
直到一轮不再发现新可达点为止 (固定点收敛)
2
3
4
5
6
7
8
9
10
每轮都要重新做:
- 类型流分析(每个变量在每个程序点的可能类型集)
- 转义分析(对象逃逸路径)
- 折叠常量(编译期可确定的值)
固定点收敛通常 5~10 轮,单轮分析数十秒——加起来就是分钟级别。
结论:慢不是工具问题,是问题本身的复杂度——闭世界假设的强度越高(要求保守覆盖所有可达性),分析迭代次数越多。这是 AOT 的"原罪"。
# 5.3 堆快照与初始化
Native Image 一个独特特性——构建期初始化(build-time initialization):
传统 JVM Native Image
────────── ──────────────
启动时执行所有 <clinit> 构建期就执行选定类的 <clinit>
结果序列化到镜像的"image heap"
启动时机器码 + 全部初始化 启动时机器码 + 反序列化堆快照
↓ ↓
慢 快(直接 mmap 映射)
2
3
4
5
6
7
8
举例:一个 Logger.getLogger("foo") 的初始化在普通 JVM 里要花几毫秒解析 logback 配置;在 Native Image 里这个 logger 对象在构建期就创建好了,启动时只是把堆快照映射进内存。
这是 Native Image 启动 38ms 的另一个秘密——很多"启动工作"被前移到了构建期。
陷阱:构建期初始化时,当前主机的环境变量、系统时间、文件路径会被永久固化到镜像里。如果你在 <clinit> 里写了 System.getenv("HOME"),得到的会是构建机的 HOME,而不是运行机的——这是新手常踩的坑。
# 5.4 镜像产物结构
最终输出的二进制文件长这样:
ELF / Mach-O / PE 可执行文件
├── .text ← 编译后的机器码 (60~70%)
├── .rodata ← 字符串常量、类元数据
├── .data ← 可写全局变量
├── .image_heap ← 构建期堆快照(mmap 加载)
├── .symbols ← 调试符号(可选)
└── 启动入口 ← 调用 SubstrateVM 入口点
2
3
4
5
6
7
启动流程:
OS 加载 ELF
↓
执行入口点 _start
↓
SubstrateVM 初始化(极简,约 10ms)
↓
mmap 映射 image_heap
↓
跳转到用户 main 方法
2
3
4
5
6
7
8
9
结论:启动 38ms 的构成:~10ms SubstrateVM、~5ms mmap、~20ms 用户业务初始化、~3ms HTTP 端口监听。根本没有给"类加载"留空间。
# 6. SubstrateVM 揭秘
# 6.1 极简版 JVM
Native Image 不是没有运行时——它有一个极简版 JVM 叫 SubstrateVM (SVM),体积约 10~12MB,被静态链接进每个镜像。
SVM 提供的功能:
✓ GC(Serial / G1 子集)
✓ 异常处理 (栈展开)
✓ 内存模型 (volatile / synchronized)
✓ 线程调度 (基于 OS 线程)
✓ 信号处理
✓ JNI(受限)
2
3
4
5
6
SVM 不提供的功能(与 HotSpot 对比):
✗ 类加载器
✗ Metaspace
✗ JIT 编译器
✗ 解释器
✗ JFR(JDK 22+ 部分支持)
✗ 字节码热替换
✗ 大部分 JVMTI 接口
2
3
4
5
6
7
# 6.2 GC 选型差异
SVM 的 GC 实现是简化版,与 HotSpot 不能直接对应:
| GC 算法 | HotSpot | SubstrateVM |
|---|---|---|
| Serial | ✓ | ✓(默认) |
| Parallel | ✓ | ✗ |
| G1 | ✓(17 默认) | ✓(GraalVM 22.2+ 商业版可用) |
| ZGC | ✓ | ✗ |
| Shenandoah | ✓ | ✗ |
| Epsilon | ✓ | ✓ |
关键区别:SubstrateVM 的 Serial GC 是全 STW 单线程——简单可靠但暂停时间较长。这对短生命周期 Serverless 函数完全没问题(一次调用可能 GC 都不触发就结束了),但对长跑大堆服务不友好。
配置参数:
# 选择 GC(构建期决定)
native-image --gc=serial # 默认
native-image --gc=G1 # 仅 GraalVM EE 商业版
# 运行期参数(与 HotSpot 类似但子集)
./app -Xmx2g -Xms2g -XX:+PrintGC
2
3
4
5
6
# 6.3 没有了什么
疑惑:去掉这么多东西,还能叫"Java"吗?
论证:SubstrateVM 的取舍是精确的——
保留:Java 语言核心规范的全部
+ 内存模型、并发原语、异常体系
+ 大部分 JDK 标准库
裁剪:JVM 实现细节中"运行时灵活性"相关的部分
- 类加载、JIT、字节码增强
2
3
4
5
6
也就是说——Java 语言层面写的代码不变,变的是底下的执行机制。99% 的业务代码在 Native Image 上能跑得跟 JVM 一样,剩下 1% 是用了反射、代理、字节码生成的"框架代码"——而这正是为什么Spring / Hibernate 等框架要专门做 GraalVM 适配。
结论:Native Image 是"Java 语言的子集运行时"——不是"另一种语言",而是"裁掉动态性的同一种语言"。理解这一点,才能正确预期它的能力边界。
# 7. 反射与动态特性
# 7.1 三类动态行为
闭世界假设最大的痛点是动态行为。归纳成三类:
① 反射(Reflection)
Class.forName / Method.invoke / Field.set
② 动态代理(Dynamic Proxy)
Proxy.newProxyInstance
CGLIB.create
③ 资源加载(Resource)
ClassLoader.getResource / getResourceAsStream
ServiceLoader.load
2
3
4
5
6
7
8
9
10
每一类都需要告诉 native-image 你要用什么——通过配置文件。
# 7.2 配置文件机制
GraalVM 通过 META-INF/native-image/ 下的 JSON 配置文件描述动态需求:
META-INF/native-image/group/artifact/
├── reflect-config.json ← 反射目标
├── proxy-config.json ← 代理接口列表
├── resource-config.json ← 资源文件
├── jni-config.json ← JNI 调用
└── serialization-config.json ← 序列化目标
2
3
4
5
6
reflect-config.json 示例:
[
{
"name": "com.example.User",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true,
"queryAllPublicMethods": true
},
{
"name": "com.example.OrderService",
"methods": [
{ "name": "process", "parameterTypes": ["com.example.Order"] }
]
}
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
proxy-config.json 示例:
[
{ "interfaces": ["java.sql.Connection", "java.lang.AutoCloseable"] },
{ "interfaces": ["com.example.UserRepo"] }
]
2
3
4
疑惑:这些 JSON 谁来写?手写岂不是要写到天荒地老?
# 7.3 Agent 自动采集
论证:GraalVM 提供 Tracing Agent 自动采集——以普通 JVM 运行你的应用、走一遍业务流,Agent 记录所有反射 / 代理 / 资源加载,自动生成配置文件:
# 第一步:以追踪模式跑一遍应用
java -agentlib:native-image-agent=config-output-dir=META-INF/native-image \
-jar app.jar
# 业务正常跑,覆盖完测试用例后退出
# Agent 自动生成 reflect-config.json 等文件
# 第二步:用生成的配置构建镜像
native-image -jar app.jar
2
3
4
5
6
7
8
9
配置覆盖率原则:
单测覆盖 = 必要下限(接口契约层)
集成测试覆盖 = 推荐目标(覆盖框架反射)
生产流量回放 = 最高保障(覆盖罕见路径)
2
3
陷阱:Agent 只能记录"已经跑过的代码路径"——如果某个分支测试没覆盖到,运行时就会爆 ClassNotFoundException / NoSuchMethodException——而且是毫无征兆地爆,因为闭世界已经把那些类裁剪了。
框架现状:Spring Boot 3.0+ / Quarkus / Micronaut 都内置了 META-INF/native-image/ 配置——这就是"GraalVM-friendly 框架"的真实含义。老框架(如 Hibernate 5)需要大量手工补配。
# 8. 性能取舍真相
# 8.1 启动与内存收益
启动时间收益的来源(38ms vs 4.2s):
4200ms 拆解(传统 JVM) 38ms 拆解(Native Image)
───────────────────── ──────────────────────
500ms JVM 自身启动 10ms SubstrateVM
1400ms ClassPath 扫描 0ms (构建期完成)
800ms Spring Bean 初始化 5ms (堆快照映射)
600ms Tomcat 启动 20ms 业务初始化
500ms JIT 解释期"冷" 0ms (机器码已就绪)
500ms 其他 3ms 端口监听
2
3
4
5
6
7
8
根因:Native Image 把"类加载、初始化、JIT 编译"这三件事全前移到了构建期。运行期只剩"内存映射 + 业务逻辑"。
内存收益(256MB → 18MB)来源:
传统 JVM 运行时基线:
├── JVM 自身代码段 ~70 MB
├── Metaspace ~80 MB (Spring 上千个类)
├── CodeCache ~50 MB
├── GC 元数据 + JIT 缓冲 ~30 MB
├── 线程栈 (200 × 1MB) ~200 MB
└── 业务堆 ~256 MB(设定上限)
合计 RSS: 约 500~600 MB
Native Image:
├── SubstrateVM ~10 MB
├── 业务机器码 ~30 MB(裁剪后)
├── image_heap ~5 MB
├── 线程栈 (10 × 256KB) ~3 MB
└── 业务堆 ~10 MB(小工作集)
合计 RSS: 约 50~80 MB
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
结论:Native Image 不是"用更聪明的方法",而是"用更少的东西"——本来就没有 Metaspace、没有 JIT,自然不占内存。
# 8.2 峰值吞吐损失
疑惑:为什么 Native Image 峰值吞吐反而下降 20%?AOT 编译过的代码不是更快吗?
论证:JIT 有 AOT 不可能拥有的三件优化:
- 基于剖面的内联:JIT 看到
obj.method()99% 时间 obj 是Foo类型,会激进内联Foo.method——AOT 不知道实际调用分布,只能保守不内联(或者内联所有可能类型,代码膨胀) - 分支频率优化:JIT 知道某个 if 分支 99.9% 走 false,会把热路径放在前面,提升分支预测命中——AOT 只能按代码顺序排
- 去虚化 (devirtualization):JIT 看到某接口运行时只有一个实现,直接调具体方法——AOT 必须保留虚调用
JIT 在 60 秒预热后能做的优化:
+ 基于运行时数据的激进内联
+ 类层级分析 (CHA) 去虚化
+ 推测优化 + 失败回退
AOT 在构建期能做的优化:
- 静态内联(保守)
- 静态去虚化(仅 final 类生效)
- 没有推测,全是稳态
2
3
4
5
6
7
8
9
真实数据对比(Spring Boot REST 服务,相同业务):
启动 稳态TPS P99 内存
HotSpot + C2 4.2s 8500 18ms 256MB
Native Image 38ms 6800 24ms 18MB
Native Image + PGO 45ms 7900 20ms 22MB
2
3
4
PGO(下一节)能把吞吐损失从 20% 缩小到 7%——但仍达不到 JIT。
# 8.3 PGO 与机器学习
PGO(Profile-Guided Optimization)—— GraalVM EE / Oracle GraalVM 提供:
# 第一步:构建带 instrumentation 的镜像
native-image --pgo-instrument -jar app.jar
# 第二步:用真实流量跑 instrumented 镜像
./app # 业务跑一段时间,生成 default.iprof
# 第三步:用剖面构建优化版镜像
native-image --pgo=default.iprof -jar app.jar
2
3
4
5
6
7
8
机制:第一步构建的镜像内嵌了计数器,运行时记录每条分支频率、每个调用点的实际类型——这些数据喂回第三步,让 AOT 编译期也能做"基于剖面的优化"。
收益:吞吐回升 10%~20%,但仍无法达到 JIT 长跑峰值。根本原因——剖面数据是离线收集的,无法适应流量模式变化。
前沿方向:Oracle 在做 ML-PGO——用机器学习预测剖面,避免手工 instrument。仍在实验阶段。
# 9. 选型决策矩阵
# 9.1 适合的场景
✅ Serverless / FaaS 函数
- AWS Lambda / 阿里云函数计算 / Knative
- 收益:冷启动从秒级 → 毫秒级,省钱
- 案例:本篇开篇
✅ CLI 工具 / 短生命周期任务
- kubectl 风格的命令行工具
- 数据处理脚本(一次性运行)
- 收益:启动快、内存省、单二进制易分发
✅ 边缘计算 / 嵌入式
- IoT 网关、车载、边缘节点
- 收益:内存极低、不需要 JRE 部署
✅ Spring Boot 3 / Quarkus / Micronaut 现代框架
- 框架已做 GraalVM 适配
- 收益:享受云原生红利、容器密度提升 5~10 倍
# 9.2 不适合的场景
❌ 长跑高吞吐微服务
- 每天处理亿级请求的核心交易系统
- 损失:峰值吞吐 -10%~20%,单机成本反升
- 替代方案:保留 JVM,调优 G1 / ZGC(见第 17 篇)
❌ 重度依赖动态特性的应用
- OSGi 插件框架(Eclipse / 工业控制)
- 字节码热替换(jrebel / 热部署)
- 替代方案:放弃 Native Image,无解
❌ 生态尚未适配的老框架
- Hibernate 5、Struts、老版本 Mybatis
- 损失:手工写大量 reflect-config.json
- 替代方案:等框架升级或迁移
❌ 大量 JNI / Native 互操作
- JNI 在 SVM 下能用但限制多
- 替代方案:用 Project Panama (FFM API)
# 9.3 迁移落地清单
如果决定上 Native Image,按这个清单走:
[ ] 1. JDK 升级到 17+(GraalVM 21.0+ 强依赖)
[ ] 2. 框架升级(Spring Boot 3.x / Quarkus / Micronaut 最新版)
[ ] 3. 单测 + 集成测试覆盖率 ≥ 70%(Agent 采集质量保障)
[ ] 4. 启用 Tracing Agent 跑全部测试用例:
java -agentlib:native-image-agent=config-output-dir=...
[ ] 5. 检查项目里所有 ClassLoader / Class.forName / Proxy 调用
[ ] 6. 评估第三方依赖的 GraalVM 兼容性(spring.io/native)
[ ] 7. 在 CI 加 native-image 构建步骤(注意:构建机至少 8GB 内存)
[ ] 8. 灰度发布:单实例 → 10% → 50% → 全量
[ ] 9. 加监控:启动时间、内存 RSS、TPS、ClassNotFoundException 计数
[ ] 10. 回退方案:保留传统 jar 部署能力 6 个月
2
3
4
5
6
7
8
9
10
11
反向 checklist(满足任一项请放弃):
[ ] 业务严重依赖运行时字节码增强(JRebel / Mock / AOP 框架)
[ ] 团队没有 1 人有 GraalVM 经验
[ ] 框架版本无法升级到 GraalVM 适配版
[ ] 业务 SLA 对峰值吞吐敏感(<5% 偏差不可接受)
2
3
4
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章那场冷启动竞赛,逐条揭晓:
① AOT 为什么到 Java 才大火:不是 AOT 突然变好,是云原生 + 容器化 + 微服务三股潮流让"启动快 + 内存小"第一次成为业务一等公民。技术评价永远绑定场景。真相:参考 §2.3——同样的技术,2008 年是负担、2024 年是救星。
② GraalVM 与 OpenJDK / OracleJDK 的关系:GraalVM 不是 JDK 替代品,它是个技术栈——包含 Graal 编译器(可替代 C2)、Truffle 多语言、Native Image 三大组件。开源版(CE)功能受限,商业版(EE / Oracle GraalVM)有 G1 GC、PGO、ML-PGO 等高级特性。
③ 闭世界假设的灵魂地位:CWA 是 Native Image 一切能力(启动快 / 内存省 / 体积小)和一切限制(动态特性受限 / 构建慢)的唯一根源。理解 CWA 就理解了 80% 的 Native Image。
④ 反射 / 代理 / SPI / 资源加载的处境:全都需要配置(reflect-config.json / proxy-config.json / resource-config.json)——但有 Tracing Agent 自动采集兜底,只要测试覆盖率 ≥ 70% 基本能自动配齐。字节码生成(CGLIB / 运行时 ASM)则基本无解——必须改用 buildtime 代理。
⑤ 启动 100x 但峰值吞吐 -20% 的根因:启动收益来自"工作前移到构建期"——类加载、初始化、JIT 全部省掉;峰值吞吐损失来自"AOT 没有运行时剖面"——无法做激进内联、去虚化、推测优化。这是结构性的取舍,PGO 能弥补一半但弥补不全。
⑥ 构建时间膨胀 15 倍的去向:80% 花在静态分析迭代(5~10 轮固定点收敛)+ 整体编译(每个方法都要走完整 IR 优化管线)。对比 javac 只是"字节码生成"——Native Image 是"把 javac + JIT + linker 一次性全做完"。
⑦ 何时该用 / 不该用:参考 §9 决策矩阵——Serverless / CLI / 边缘计算选 Native Image,长跑高吞吐微服务选 JVM。这是清晰的二分。
# 10.2 一次构建的一生
把"一份 jar 变成原生二进制并跑起来"的完整时间线串成一棵树——回扣本册 17 篇:
T-30min 开发提交代码
[13篇] javac → 字节码 .class
T-10min CI 触发构建
[17篇] 容器规格 8C16G(构建机内存要求高)
T 0 native-image 启动
↓
T+3s [§5.1] Initializing:扫描 class path
[02篇] 模拟 BootClassLoader 行为,但是构建期
T+5s [§5.2] Performing analysis 第一轮
从 main 出发遍历调用图
[04篇] 容器引用解析(HashMap / ArrayList 都进可达集)
T+25s 第二轮分析
发现 reflect-config.json 中的类
[07篇] 反射目标加入可达图
T+78s 固定点收敛 (第 6 轮)
12856 个类可达,其余裁剪
[04/06篇] 大量泛型类被擦除合并
T+90s [§5.3] 构建期初始化
执行选定类的 <clinit>
[01篇] 创建对象到 image_heap(构建期堆)
[05篇] String 常量池预填充
T+105s [§5.4] Compiling 阶段
[14篇] Graal IR → 机器码(与 JIT 同一引擎)
每个方法走完整 inline / EA / 标量替换
[14篇] 所有可能的优化在构建期一次完成
T+200s Layouting + Creating image
.text / .rodata / .image_heap 段写入 ELF
[15篇] 调试符号写入(运行期可用 perf 看)
T+230s 构建完成,输出 80MB 的 app 二进制
═══════════ 镜像产物部署 ═══════════
T 0 OS exec ./app
[01篇] 内核 mmap 镜像各段到虚拟内存
T+3ms [§6] SubstrateVM 入口启动
初始化 GC、信号处理、线程池
T+8ms [§5.3] image_heap mmap 到位
构建期创建的对象"瞬间"可见
T+15ms 用户 main 方法执行
[09/10篇] Spring 容器"快速恢复"——
因为 BeanDefinition 已经构建期固化
T+30ms [11篇] HTTP 端口监听就绪
T+38ms 第一个请求到达
直接走机器码,无解释执行阶段
[16篇] 不会发生 GC overhead,因为堆很小
事故时 某条罕见路径触发 Class.forName("X")
但 X 没在 reflect-config.json 里
→ ClassNotFoundException
→ SubstrateVM 抛错并打印
[16篇] 没有 OOM 但有"配置 OOM"
↓
补 reflect-config,重新构建,重新部署
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
这条时间线串起本册 80% 的关键概念——Native Image 的每一项收益都对应着 JVM 的某一项开销被前移或裁剪。理解了这一点,AOT 就不再是黑魔法。
# 10.3 设计哲学回扣
跳出技术细节,提炼三条贯穿本篇的设计哲学:
取舍即设计:Native Image 不是"更好的 JVM",它是"用动态性换启动速度"的另一种取舍。没有更好,只有更适合——同样的代码,Serverless 场景选 Native Image 一年省百万、长跑微服务选 Native Image 一年损失千万。这条规律在第 03 篇 GC 算法选型、第 11 篇 IO 模型演进、第 17 篇 G1 vs ZGC 都见过。结论:所有架构决策本质都是取舍,承认取舍才能做对决策。
工作前移是最大优化:Native Image 100x 启动提升的根因不是"运行得更快"——是"很多事情根本不在运行期做"。类加载、初始化、JIT 编译全部前移到构建期。这与第 05 篇 String 常量池(编译期固化)、第 13 篇 invokedynamic(首次解析后缓存)、第 14 篇 JIT 编译(首次后机器码缓存)是同一根思想——"做一次缓存到底" 永远比"每次都做"快。结论:启动优化的终极方向是把工作搬到非启动期。
静态可达性是闭世界的灵魂:Native Image 一切能力(裁剪 / 启动快 / 内存省)和一切限制(反射要配置 / 字节码生成不可用)都源自"编译期能枚举所有可能"这一假设。这与第 02 篇双亲委派模型的反面正好——双亲委派是"开放可加载",Native Image 是"封闭已固化"。两种范式各自映射不同的业务诉求。结论:理解一项技术的边界,比记住它的特性更重要。
# 10.4 对决速查表
最后一张表,建议截图保存——JVM vs Native Image 全维度对决:
| 维度 | 传统 JVM | Native Image | 谁赢 |
|---|---|---|---|
| 启动时间 | 秒级 | 毫秒级 | 🏆 Native |
| 基线内存 | 数百 MB | 数十 MB | 🏆 Native |
| 镜像体积 | jar+JRE >100MB | 单二进制 50~100MB | 🏆 Native |
| 峰值吞吐 | 100% (基准) | 80%~93%(PGO) | 🏆 JVM |
| 启动后峰值时间 | 1~2 分钟预热 | 立刻达稳态 | 🏆 Native |
| 构建时间 | 秒级 | 分钟级 | 🏆 JVM |
| 构建机内存 | <2GB | 4~8GB | 🏆 JVM |
| 反射支持 | 完整 | 需配置 | 🏆 JVM |
| 字节码生成 | 完整 | 不支持 | 🏆 JVM |
| 跨平台 | 一次编译跨平台 | 平台绑定 | 🏆 JVM |
| 调试体验 | 成熟 (JDWP) | 弱(gdb) | 🏆 JVM |
| 部署形态 | 需要 JRE | 单二进制 | 🏆 Native |
| 容器密度 | 中 | 高(5~10x) | 🏆 Native |
选型心法三条:
1. 启动 / 内存敏感场景 → Native Image
(Serverless / CLI / 边缘 / 现代云原生框架)
2. 吞吐 / 灵活性敏感场景 → 传统 JVM
(长跑微服务 / 大数据 / 老框架 / 重动态性)
3. 都不敏感 → 默认传统 JVM
(生态成熟、运维门槛低、问题可查)
2
3
4
5
6
7
8
至此卷一 JVM 与运行时核心画上句号——从 01 篇内存模型一路走到 18 篇 AOT 革命,我们把"虚拟机如何把字节码跑起来"这件事拆透了。但 JVM 之上跑的是Java 集合框架——HashMap / ArrayList / ConcurrentHashMap 这些容器才是业务代码每天打交道的对象。下一篇我们顺着"GraalVM 静态分析时为什么 ArrayList 会被疯狂裁剪?"这条线,进入 卷二第一篇——第 19 篇:ArrayList 与 LinkedList 源码深析——把动态扩容、fail-fast 迭代器、modCount、为什么 LinkedList 几乎被弃用一次讲透。