主题
GraalVM 原生编译
一句话:Native Image 在构建期做全程序静态分析,把可达的类、方法和资源编译成一个自带运行时的小型可执行文件;它用启动速度和内存换掉了 JIT 的峰值吞吐与动态能力。
适用 GraalVM for JDK 17 / JDK 21,Spring Boot 3.x。不同 GraalVM 发行版(Oracle GraalVM、GraalVM Community)对 GC 和优化选项的支持不同,以官方文档为准。
为什么需要
JVM 启动要加载类、解释字节码、跑 JIT 编译,冷启动通常以秒计;容器弹缩、Serverless、CLI 和边缘场景对首请求延迟很敏感。长期运行的服务也会为 JIT 代码缓存和元数据付出内存。
Native Image 把「运行时才做」的分析提前到构建期:从 main 出发,找到所有可达的类和方法,生成机器码,打包一个 Substrate VM 运行时。启动时不再加载大量类,也不等 JIT 预热。
代价是闭世界假设:构建期看不到的类、反射名字、资源文件、动态代理,默认都不存在。
核心机制
构建过程
- 可达性分析:从入口点开始,沿调用链和方法引用找可达代码。
- 初始化:部分类在构建期初始化,部分延迟到运行期;静态初始化有副作用时要显式控制。
- 编译:把字节码编译成目标平台机器码,链接成可执行文件。
- 打包:把运行时、堆布局、元数据和资源写进镜像。
构建产物不依赖 JDK,但依赖目标平台的 libc 和 CPU 架构。在 glibc 机器上构建的镜像不能直接跑在 musl/Alpine 上。
反射、资源和动态代理
闭世界下,Class.forName("com.example.Plugin") 只给一个字符串,构建器无法知道 Plugin 可达,默认不会把它编进去。需要注册元数据。
反射配置示例(META-INF/native-image/<group>/<artifact>/reflect-config.json):
json
[
{
"name": "com.example.dto.UserDto",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
}
]资源文件不会自动全部打入,需要 resource-config.json:
json
{
"resources": {
"includes": [
{ "pattern": "\\Qapplication.yml\\E" },
{ "pattern": "\\Qtemplates/.*\\.html\\E" }
]
}
}序列化、动态代理、JNI 分别需要 serialization-config.json、proxy-config.json、jni-config.json。Spring Boot 3 还可以用 RuntimeHintsRegistrar、@RegisterReflectionForBinding 在代码里声明 hints,比手写 JSON 更易维护。
最省事的收集方式是 Native Image Agent:
bash
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/app.jar
# 跑一遍主要业务路径,让 agent 记录反射、资源、代理和序列化Agent 只能覆盖跑到的路径,不能替代对动态分支的审查。
启动与内存的取舍
| 指标 | JVM | Native Image |
|---|---|---|
| 启动 | 秒级,受类加载和 JIT 影响 | 通常几十毫秒级,取决于镜像大小 |
| 预热 | 需要 JIT 预热 | 无 JIT 预热 |
| 峰值吞吐 | 长期运行通常更高 | 通常更低,缺少自适应优化 |
| 内存 | 堆 + 元空间 + JIT 代码缓存 | 常驻更低,但仍受堆和线程栈影响 |
| 构建 | 秒级 | 分钟级,CI 成本高 |
| 动态能力 | 反射、动态类加载完整 | 需要元数据,闭世界 |
Native Image 默认使用 Serial GC;G1 等收集器是否可用取决于 GraalVM 发行版和版本。堆上限、容器内存感知也随版本变化,生产要显式设置 -Xmx 或对应的内存百分比参数,并以官方文档为准。
最小上手
先构建一个不依赖框架的镜像:
java
public class Hello {
public static void main(String[] args) {
System.out.println("native hello");
}
}bash
javac Hello.java
native-image -cp . Hello hello
./hello
# native helloSpring Boot 3 项目用 Maven:
bash
mvn -Pnative native:compile
./target/demo没有 native profile 时,至少确认 native-maven-plugin 已配置,并且构建机和运行机使用相同的 OS/CPU 架构。第一次构建会下载工具链,耗时明显高于普通打包。
生产动作
- 构建参数:
--no-fallback让缺失的反射/资源直接构建失败,而不是生成一个运行期才报错的降级镜像;-H:+ReportExceptionStackTraces保留构建期异常栈。 - 初始化策略:有静态副作用(读配置、连数据库、启动线程)的类用
--initialize-at-run-time=com.example.Boot;不确定时先--trace-class-initialization定位。 - 资源与字符集:模板、证书、
application.yml、国际化资源都要注册;非默认字符集可能需要额外构建参数,具体以 GraalVM 文档为准。 - 容器构建:在目标基础镜像里构建,避免 glibc/musl 不匹配;静态链接用
--static --libc=musl,但要注意 DNS、时区和证书路径的差异。 - 可观测性:Native Image 的堆 dump、JFR、Attach 能力与 JVM 不同;上线前确认日志、指标、追踪和崩溃转储方案仍然可用。
- 安全更新:镜像把 JDK 和依赖编译进产物,升级 JDK 或库后必须重新构建、重新扫描,不能像 JVM 那样只换基础镜像。
边界
- 不适合重动态加载:插件系统、脚本引擎、运行期生成大量类、依赖
Class.forName扫描的服务,注册成本可能超过收益。 - 不适合长期 CPU 密集服务:没有 JIT 的峰值吞吐通常低于 JVM,Native Image 的优势主要在启动和常驻内存。
- Spring 生态有兼容边界:不是所有 starter 都支持 native;升级 Spring Boot 时先跑 native test,不要假设 JVM 能跑镜像就能跑。
- 构建不是一次性的:CI 时间、镜像体积、跨平台构建和调试成本都要算进交付流程。
- Agent 收集不是完备证明:它只记录执行过的路径,冷分支、异常分支和定时任务仍可能缺元数据。
讲清楚
Native Image 在构建期从入口点做可达性分析,把类、方法和资源编译成一个自带运行时的小型可执行文件,所以启动快、常驻内存低。代价是闭世界:反射、资源、动态代理、序列化和 JNI 都必须提前注册,否则运行期才报错。
落地路径是先用 agent 跑主路径收集配置,再补
RuntimeHints和手写 JSON,构建时加--no-fallback让问题尽早暴露。它适合 Serverless、CLI、短生命周期和启动敏感的服务;长期 CPU 密集、插件化、强动态加载的系统,JVM 的 JIT 和完整反射能力更合适。生产还要接受构建慢、镜像需随 JDK 重建、可观测性工具不同这三笔成本。