跳到正文

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 预热。

代价是闭世界假设:构建期看不到的类、反射名字、资源文件、动态代理,默认都不存在。

核心机制 ​

构建过程 ​

  1. 可达性分析:从入口点开始,沿调用链和方法引用找可达代码。
  2. 初始化:部分类在构建期初始化,部分延迟到运行期;静态初始化有副作用时要显式控制。
  3. 编译:把字节码编译成目标平台机器码,链接成可执行文件。
  4. 打包:把运行时、堆布局、元数据和资源写进镜像。

构建产物不依赖 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 只能覆盖跑到的路径,不能替代对动态分支的审查。

启动与内存的取舍 ​

指标JVMNative 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 hello

Spring 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 重建、可观测性工具不同这三笔成本。

把一门技术讲透到既懂也会用