编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
        • 1. 案例引入
          • 1.1 一次冷启动竞赛
          • 1.2 反直觉的曲线
          • 1.3 我们要回答什么
        • 2. AOT 与 JIT 之争
          • 2.1 两种翻译路径
          • 2.2 各自的代价
          • 2.3 为何此时复活
        • 3. GraalVM 全家桶
          • 3.1 三个核心组件
          • 3.2 Graal 编译器
          • 3.3 Truffle 多语言
          • 3.4 Native Image
        • 4. 闭世界假设
          • 4.1 假设的含义
          • 4.2 可达性分析
          • 4.3 假设代价清单
        • 5. 构建过程拆解
          • 5.1 五大构建阶段
          • 5.2 静态分析迭代
          • 5.3 堆快照与初始化
          • 5.4 镜像产物结构
        • 6. SubstrateVM 揭秘
          • 6.1 极简版 JVM
          • 6.2 GC 选型差异
          • 6.3 没有了什么
        • 7. 反射与动态特性
          • 7.1 三类动态行为
          • 7.2 配置文件机制
          • 7.3 Agent 自动采集
        • 8. 性能取舍真相
          • 8.1 启动与内存收益
          • 8.2 峰值吞吐损失
          • 8.3 PGO 与机器学习
        • 9. 选型决策矩阵
          • 9.1 适合的场景
          • 9.2 不适合的场景
          • 9.3 迁移落地清单
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次构建的一生
          • 10.3 设计哲学回扣
          • 10.4 对决速查表
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

GraalVM与AOT编译原理

# 10.GraalVM与AOT编译原理

# 目录介绍

  • 1. 案例引入
    • 1.1 一次冷启动竞赛
    • 1.2 反直觉的曲线
    • 1.3 我们要回答什么
  • 2. AOT 与 JIT 之争
    • 2.1 两种翻译路径
    • 2.2 各自的代价
    • 2.3 为何此时复活
  • 3. GraalVM 全家桶
    • 3.1 三个核心组件
    • 3.2 Graal 编译器
    • 3.3 Truffle 多语言
    • 3.4 Native Image
  • 4. 闭世界假设
    • 4.1 假设的含义
    • 4.2 可达性分析
    • 4.3 假设代价清单
  • 5. 构建过程拆解
    • 5.1 五大构建阶段
    • 5.2 静态分析迭代
    • 5.3 堆快照与初始化
    • 5.4 镜像产物结构
  • 6. SubstrateVM 揭秘
    • 6.1 极简版 JVM
    • 6.2 GC 选型差异
    • 6.3 没有了什么
  • 7. 反射与动态特性
    • 7.1 三类动态行为
    • 7.2 配置文件机制
    • 7.3 Agent 自动采集
  • 8. 性能取舍真相
    • 8.1 启动与内存收益
    • 8.2 峰值吞吐损失
    • 8.3 PGO 与机器学习
  • 9. 选型决策矩阵
    • 9.1 适合的场景
    • 9.2 不适合的场景
    • 9.3 迁移落地清单
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次构建的一生
    • 10.3 设计哲学回扣
    • 10.4 对决速查表

# 1. 案例引入

# 1.1 一次冷启动竞赛

某金融团队做 Serverless 改造,把一个 Spring Boot 微服务搬上 FaaS 平台——按调用次数付费、闲置即冷却。第一周账单出来时所有人惊掉下巴:冷启动费用占总账单的 73%。

复盘数据:

请求峰值:    1200 QPS  → 同时拉起约 80 个实例
冷启动时间:  4.2 秒    → 用户首请求超时
JVM 内存基线:256 MB   → 单实例固定内存开销
预热到峰值:  约 2 分钟  → JIT 编译热代码

问题不在业务代码——业务逻辑只占启动 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%)

团队决定用 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 变长)

冷启动 110 倍提升、内存缩 14 倍——但峰值吞吐反而下降 20%、构建时间膨胀 15 倍。

# 1.2 反直觉的曲线

如果把"启动 → 运行"这条时间轴画成 TPS 曲线,两个产物的曲线走向截然相反:

TPS
 │
 │            ┌──────────  传统 JVM (峰值 8500)
 │           ╱
 │          ╱
 │         ╱   ←—— JIT 预热 1~2 分钟
 │        ╱
 │   ┌───┴────────────  Native Image (稳态 6800)
 │   │
 │   │← 38ms 即达稳态
 ├───┴──────────────────────────────────────→  时间
 0  100ms              60s              120s

这条曲线浓缩了整个 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章

本篇路线:

AOT vs JIT 历史 (第2章)
    ↓
GraalVM 全家桶解构 (第3章)
    ↓
闭世界假设 (第4章)  ←—— 灵魂概念
    ↓
构建过程五阶段 (第5章)
SubstrateVM 揭秘 (第6章)
反射与动态特性 (第7章)
    ↓
性能取舍真相 (第8章)
选型决策矩阵 (第9章)
    ↓
综合案例串讲 (第10章)

# 2. AOT 与 JIT 之争

# 2.1 两种翻译路径

把"源代码 → CPU 执行"这条路画出来,AOT 与 JIT 是两条截然不同的路径:

                源代码 (.java)
                     │
                     │ javac
                     ↓
                字节码 (.class)
                     │
        ┌────────────┼────────────┐
        │                         │
   传统 JVM 路线                AOT 路线
        │                         │
        ↓                         ↓
   类加载 + 解释执行          静态分析 + 整体编译
        │                         │
        ↓                         ↓
   热代码触发 JIT 编译        生成原生可执行文件
        │                         │
        ↓                         ↓
   Tier1→Tier4 渐进优化       一次性焊死所有决策
        │                         │
        ↓                         ↓
   运行时机器码              直接运行机器码

关键差异:

维度 JIT (传统 JVM) AOT (Native Image)
编译时机 运行时 构建期
输入信息 字节码 + 运行剖面 字节码 + 配置
输出形式 内存中的机器码 磁盘上的可执行文件
优化空间 基于实际数据的剖面引导 基于静态分析的保守估计
启动开销 解释 → 编译多次切换 零(直接执行)

# 2.2 各自的代价

JIT 的代价——14 篇详细讲过:

+ 长跑业务峰值性能极致(运行时数据指导优化)
+ 跨平台一次编译到处运行
- 启动慢(解释执行 + 类加载)
- 内存占用高(JVM 自身 + Metaspace + CodeCache)
- 部署体积大(JRE 至少 50MB+)

AOT 的代价——本篇主角:

+ 启动飞快(毫秒级)
+ 内存占用极低(无 JVM 自身开销)
+ 部署体积可控(单一二进制,无需 JRE)
- 构建时间长(静态分析占大头)
- 峰值性能受限(无运行时剖面)
- 动态特性受限(反射 / 代理 / SPI 需配置)
- 平台绑定(Linux 镜像不能跑在 macOS)

疑惑:既然 JIT 和 AOT 各有优劣,为什么不"两个都要"?

论证:实际上 OpenJDK 早在 JDK 9 就尝试过 —— jaotc 工具,把热代码 AOT 编译成 .so 文件,运行时仍然用 JVM 加载。但这条路在 JDK 17 被废弃,JDK 21 彻底移除。原因是:

  1. 维护成本高:jaotc 与 JIT 共享一套 IR,演进时两边要同步改
  2. 收益不明显:仍然需要完整 JVM 运行时,启动只省几百毫秒
  3. 生态分叉:开发者难以判断"什么时候 AOT、什么时候 JIT"

结论:纯 AOT (Native Image) 才是真正的范式革命——它不是"在 JVM 上加 AOT",而是"绕过 JVM"。这是与 jaotc 的根本区别。

# 2.3 为何此时复活

疑惑:AOT 是 1950 年代就有的老技术(C/Fortran 早期都是 AOT),为什么直到 2019 年(GraalVM 19.0)才在 Java 圈大火?

论证:三股潮流同时汇聚:

┌─────────────────┐     ┌─────────────────┐     ┌──────────────────┐
│  云原生 / FaaS  │     │   容器化部署    │     │   微服务拆分     │
│  按调用计费      │     │   镜像越小越好  │     │   单服务变小     │
│  冷启动决定钱包  │     │   内存越省越好  │     │   每个 JVM 都贵  │
└────────┬────────┘     └────────┬────────┘     └────────┬─────────┘
         │                       │                       │
         └───────────┬───────────┴───────────────────────┘
                     ↓
              对"启动快 + 内存小"的需求
              第一次成为业务一等公民
                     ↓
              AOT 复活的土壤

结论:不是 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 模式     │
└─────────────────────────────────────────────────────┘

三者的关系:

  • 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

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 的性能

实际产物:

  • 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++ 程序具备相同部署形态

与 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 生成

# 4.2 可达性分析

疑惑:闭世界假设怎么落地?编译器怎么知道哪些类要不要进二进制?

论证:通过静态可达性分析——从 main 方法出发做巨大的图遍历:

   入口点 (main)
        │
        │ 直接调用
        ↓
    ┌───────┐  ┌──────┐  ┌─────────┐
    │ Foo() │→│ Bar()│→│ Baz()    │
    └───┬───┘  └──┬───┘  └────┬────┘
        │         │           │
        ↓         ↓           ↓
    引用类型    引用类型    Class.forName(...)
    (静态)     (静态)       ↑
                            │
                     必须配置文件告诉
                     "这里要加载 X 类"

核心算法:

  1. 从所有入口点(main / 静态初始化 / 测试入口)出发
  2. 沿着静态可见的调用边遍历
  3. 遇到反射 / Class.forName 时查配置文件——配置里登记的类视为可达
  4. 不可达的类、方法、字段全部不进二进制——这是镜像能瘦身的根本原因

结论: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 时,幕后跑了一个长链路:

真实构建日志(节选):

[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.

时间分布:分析 + 编译占 80%,其他阶段加起来 20%。构建膨胀的元凶就是这两步——这就回答了第 1 章疑问 ⑥。

# 5.2 静态分析迭代

疑惑:分析阶段为什么这么慢?不就是图遍历吗?

论证:实际上 native-image 的分析是多轮迭代——每轮发现新的可达点后回头重新算:

轮 1:从 main 出发,找到 100 个可达类
       发现某个类的静态构造器引用了新类型
       
轮 2:把新类型加入工作集,再次遍历
       又发现某些类有 @TargetClass 注解(替换原始类)
       
轮 3:处理替换关系,再算一遍
       ...
       
直到一轮不再发现新可达点为止 (固定点收敛)

每轮都要重新做:

  • 类型流分析(每个变量在每个程序点的可能类型集)
  • 转义分析(对象逃逸路径)
  • 折叠常量(编译期可确定的值)

固定点收敛通常 5~10 轮,单轮分析数十秒——加起来就是分钟级别。

结论:慢不是工具问题,是问题本身的复杂度——闭世界假设的强度越高(要求保守覆盖所有可达性),分析迭代次数越多。这是 AOT 的"原罪"。

# 5.3 堆快照与初始化

Native Image 一个独特特性——构建期初始化(build-time initialization):

传统 JVM                       Native Image
──────────                     ──────────────
启动时执行所有 <clinit>        构建期就执行选定类的 <clinit>
                              结果序列化到镜像的"image heap"
                              
启动时机器码 + 全部初始化       启动时机器码 + 反序列化堆快照
   ↓                              ↓
慢                             快(直接 mmap 映射)

举例:一个 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 入口点

启动流程:

OS 加载 ELF
   ↓
执行入口点 _start
   ↓
SubstrateVM 初始化(极简,约 10ms)
   ↓
mmap 映射 image_heap
   ↓
跳转到用户 main 方法

结论:启动 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(受限)

SVM 不提供的功能(与 HotSpot 对比):

✗ 类加载器
✗ Metaspace
✗ JIT 编译器
✗ 解释器
✗ JFR(JDK 22+ 部分支持)
✗ 字节码热替换
✗ 大部分 JVMTI 接口

# 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

# 6.3 没有了什么

疑惑:去掉这么多东西,还能叫"Java"吗?

论证:SubstrateVM 的取舍是精确的——

保留:Java 语言核心规范的全部
      + 内存模型、并发原语、异常体系
      + 大部分 JDK 标准库
      
裁剪:JVM 实现细节中"运行时灵活性"相关的部分
      - 类加载、JIT、字节码增强

也就是说——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

每一类都需要告诉 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 ← 序列化目标

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"] }
    ]
  }
]

proxy-config.json 示例:

[
  { "interfaces": ["java.sql.Connection", "java.lang.AutoCloseable"] },
  { "interfaces": ["com.example.UserRepo"] }
]

疑惑:这些 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

配置覆盖率原则:

单测覆盖   = 必要下限(接口契约层)
集成测试覆盖 = 推荐目标(覆盖框架反射)
生产流量回放 = 最高保障(覆盖罕见路径)

陷阱: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   端口监听

根因: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

结论:Native Image 不是"用更聪明的方法",而是"用更少的东西"——本来就没有 Metaspace、没有 JIT,自然不占内存。

# 8.2 峰值吞吐损失

疑惑:为什么 Native Image 峰值吞吐反而下降 20%?AOT 编译过的代码不是更快吗?

论证:JIT 有 AOT 不可能拥有的三件优化:

  1. 基于剖面的内联:JIT 看到 obj.method() 99% 时间 obj 是 Foo 类型,会激进内联 Foo.method——AOT 不知道实际调用分布,只能保守不内联(或者内联所有可能类型,代码膨胀)
  2. 分支频率优化:JIT 知道某个 if 分支 99.9% 走 false,会把热路径放在前面,提升分支预测命中——AOT 只能按代码顺序排
  3. 去虚化 (devirtualization):JIT 看到某接口运行时只有一个实现,直接调具体方法——AOT 必须保留虚调用
JIT 在 60 秒预热后能做的优化:
  + 基于运行时数据的激进内联
  + 类层级分析 (CHA) 去虚化
  + 推测优化 + 失败回退
  
AOT 在构建期能做的优化:
  - 静态内联(保守)
  - 静态去虚化(仅 final 类生效)
  - 没有推测,全是稳态

真实数据对比(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

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

机制:第一步构建的镜像内嵌了计数器,运行时记录每条分支频率、每个调用点的实际类型——这些数据喂回第三步,让 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 个月

反向 checklist(满足任一项请放弃):

[ ] 业务严重依赖运行时字节码增强(JRebel / Mock / AOP 框架)
[ ] 团队没有 1 人有 GraalVM 经验
[ ] 框架版本无法升级到 GraalVM 适配版
[ ] 业务 SLA 对峰值吞吐敏感(<5% 偏差不可接受)

# 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,重新构建,重新部署

这条时间线串起本册 80% 的关键概念——Native Image 的每一项收益都对应着 JVM 的某一项开销被前移或裁剪。理解了这一点,AOT 就不再是黑魔法。

# 10.3 设计哲学回扣

跳出技术细节,提炼三条贯穿本篇的设计哲学:

  1. 取舍即设计:Native Image 不是"更好的 JVM",它是"用动态性换启动速度"的另一种取舍。没有更好,只有更适合——同样的代码,Serverless 场景选 Native Image 一年省百万、长跑微服务选 Native Image 一年损失千万。这条规律在第 03 篇 GC 算法选型、第 11 篇 IO 模型演进、第 17 篇 G1 vs ZGC 都见过。结论:所有架构决策本质都是取舍,承认取舍才能做对决策。

  2. 工作前移是最大优化:Native Image 100x 启动提升的根因不是"运行得更快"——是"很多事情根本不在运行期做"。类加载、初始化、JIT 编译全部前移到构建期。这与第 05 篇 String 常量池(编译期固化)、第 13 篇 invokedynamic(首次解析后缓存)、第 14 篇 JIT 编译(首次后机器码缓存)是同一根思想——"做一次缓存到底" 永远比"每次都做"快。结论:启动优化的终极方向是把工作搬到非启动期。

  3. 静态可达性是闭世界的灵魂: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
   (生态成熟、运维门槛低、问题可查)

至此卷一 JVM 与运行时核心画上句号——从 01 篇内存模型一路走到 18 篇 AOT 革命,我们把"虚拟机如何把字节码跑起来"这件事拆透了。但 JVM 之上跑的是Java 集合框架——HashMap / ArrayList / ConcurrentHashMap 这些容器才是业务代码每天打交道的对象。下一篇我们顺着"GraalVM 静态分析时为什么 ArrayList 会被疯狂裁剪?"这条线,进入 卷二第一篇——第 19 篇:ArrayList 与 LinkedList 源码深析——把动态扩容、fail-fast 迭代器、modCount、为什么 LinkedList 几乎被弃用一次讲透。

上次更新: 2026/06/28, 17:55:19
JVM参数调优全景图
HashMap底层哈希设计

← JVM参数调优全景图 HashMap底层哈希设计→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式