GraalVM 原生镜像
本教程共 48 篇 · 第 44 篇 · 更新于 2026-08-13 · 约 5 分钟阅读
本节目标:理解原生镜像和 AOT 编译的原理,掌握 Spring Boot 构建原生镜像的方法,知道它的限制和适用场景。
JVM 应用的两大痛点
Java 应用跑在 JVM 上,启动要先加载类、解释执行、逐步 JIT 编译。一个 Spring Boot 应用冷启动 3 到 5 秒很正常,内存占用几百 MB 起步。
大多数场景无所谓。但云原生时代变了:函数计算按调用计费,容器要秒级扩容,内存就是成本。启动慢、吃内存成了痛点。
原生镜像:换一条路
GraalVM 的 Native Image 技术换个思路:构建时就把 Java 代码编译成机器码,产物是一个独立的可执行文件,运行时不需要 JVM。
对比 JVM 部署:
| 维度 | JVM 部署 | 原生镜像 |
|---|---|---|
| 编译时机 | 运行时 JIT | 构建时 AOT |
| 启动时间 | 秒级 | 毫秒级(快 10 倍以上) |
| 内存占用 | 高 | 低(可省一半以上) |
| 可执行文件 | 需要 JVM | 独立文件,自带一切 |
| 构建时间 | 快 | 慢(几分钟) |
启动快、内存小,特别适合容器和无服务器场景。代价是构建复杂、限制多。
AOT 编译:从入口开始静态分析
原生镜像的编译流程叫 AOT(Ahead-of-Time,提前编译)。编译器从 main 方法开始,静态分析所有可达的代码,全部编译成机器码。
静态分析带来几个直接后果:
- 分析时不可达的代码(比如没被调用的方法、用反射才触达的类)会被直接丢弃,不在可执行文件里。
- 反射、动态代理、资源加载、序列化,编译器看不见,必须显式告诉它。
- 类路径在构建时固定,运行时不能加依赖。
- 没有懒加载,启动时所有类都装进内存(好在没有 JVM 的元数据开销)。
一句话:分析期看不到的东西,运行期就没有。
Spring 的动态性 vs 原生镜像
Spring Boot 天生「动态」:自动配置根据 classpath 和属性决定装什么 Bean,@ConditionalOnXxx 一堆条件注解,Bean 实例化走反射。这些动态行为,静态分析全看不见。
Spring 的解法是 Spring AOT 处理:构建时先把应用启动到「Bean 定义就绪」的阶段,把动态决策提前做完,生成三类产物:
- Java 源码:把 Bean 定义转成直接的代码(Maven 下在
target/spring-aot/main/sources)。 - 字节码:动态代理类提前生成。
- JSON 提示文件:放在
META-INF/native-image/下,告诉 GraalVM 反射、资源、序列化、JNI 的清单:reflect-config.json(反射)resource-config.json(资源)serialization-config.json(序列化)proxy-config.json(动态代理)jni-config.json(JNI)
GraalVM 编译时读取这些提示文件,把动态部分也编进可执行文件。这就是「闭世界假设」:应用的结构在构建时定死,运行时不再变化。
构建原生镜像
准备环境
两种方式二选一:
- 安装 GraalVM JDK(自带 Native Image 工具),用
GRAALVM_HOME指向它。 - 用普通 JDK 21+,装 Native Build Tools 插件(自动下载 GraalVM 组件)。21 LTS 可用,官方示例为 25。
NoteSpring Boot 4.x 中,原生镜像是一等公民。用 Spring Initializr 创建项目时勾选 GraalVM Native Support,
pom.xml里会自动带上native的 Maven Profile 和org.graalvm.buildtools:native-maven-plugin,不用手写。
Maven 构建
项目根目录执行:
mvn -Pnative native:compile
构建产物在 target/ 下,文件名是 demo(没有 .jar 后缀):
./target/demo
直接运行,没有 java 命令:
./target/demo --server.port=8080
第一次构建很慢:要跑 AOT 处理、静态分析、编译,几分钟很正常。构建机内存建议 8GB 以上。
构建原生镜像容器
应用要进 Docker,直接构建原生可执行文件放进最小镜像:
mvn -Pnative spring-boot:build-image
这条命令用 Buildpacks 生成一个包含原生可执行文件的容器镜像,基础镜像极小(不带 JVM),镜像体积从几百 MB 降到几十 MB。也可以从 Dockerfile.native 手动构建,思路一致:构建阶段出可执行文件,运行阶段用 paketobuildpacks/run 之类的轻量基础镜像。
运行时行为差异
原生镜像跑起来,行为和 JVM 版有几处不同:
- Profile 受限:
@Profile和 profile 专属配置的支持有限,构建时就要确定。 - 条件属性受限:
@ConditionalOnProperty这类「运行时才定」的 Bean 条件不支持。 - 反射需声明:自己写的反射代码要加
@RegisterReflectionForBinding等注解或补 hint 文件。 - 文件路径、类路径操作:部分 JDK API 行为不同。
- 懒加载失效:
spring.main.lazy-initialization=true在原生镜像下无意义。
所以原生镜像不是「无脑切换」。官方原则:先让应用在 JVM 下全绿,再打原生镜像,遇到问题逐项补 hint。
什么时候用原生镜像
适合:
- 函数计算 / Serverless,按调用次数计费,启动越快越省。
- 容器频繁扩缩容,秒级就绪是关键指标。
- 内存敏感的小服务,能省一半内存。
- CLI 工具类应用,秒开体验好。
不适合:
- 大型单体,构建时间太长,反射场景复杂。
- 重度动态特性:大量反射、字节码增强、运行时生成类。
- 需要运行时热部署、动态加载插件的场景。
Tip判断项目适不适合,先跑一次
mvn -Pnative native:compile。构建失败信息会直接告诉你有哪个反射没声明。小服务通常改几处就能过。
折中方案:AOT 缓存与 CDS
不想换 GraalVM,又想加快启动?JVM 也有 AOT 类特性:
- CDS(类数据共享):Java 24 及以下,训练一次生成
.jsa归档,启动时直接加载。 - AOT 缓存:Java 25+ 的新特性,
-XX:AOTCacheOutput生成、-XX:AOTCache加载。
# 训练(解压形态的应用)
java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar demo.jar
# 带缓存启动
java -XX:AOTCache=app.aot -jar demo.jar
效果是启动时间明显缩短,代价远小于原生镜像,是「性价比」路线。第 45 章会结合部署一起讲。
小结
- 原生镜像 = 构建时 AOT 编译成机器码,启动快、内存小、无需 JVM。
- Spring AOT 处理生成源码、代理字节码和 JSON hint 文件,解决动态问题。
- 构建命令:
mvn -Pnative native:compile;容器镜像:mvn -Pnative spring-boot:build-image。 - 反射、Profile、条件属性是主要限制,适合小服务和无服务器场景。