bigywater.tech
Spring · 84

GraalVM Native Image 与 AOT:启动与内存的代价

原生镜像到底换来什么、付出什么?启动快多少、内存省多少?为什么原生镜像里按名字反射会失败?profile 在原生镜像里为什么不能运行时再改?这篇用同一个应用对比 JVM、JVM 加 AOT、原生镜像。

1023 → 28 ms
JVM 跑 jar 与原生镜像的端口可连接时间中位数
ClassNotFoundException
没有反射提示的类在原生镜像里按名字加载会失败
62004 vs 54315
预热之后 JVM 与原生镜像的吞吐中位数(请求/秒)

构建的代价

实测 · 本机执行输出
  普通构建 mvn package:11 秒;app.jar 18.9 MB
  原生构建 mvn -Pnative native:compile:102 秒(含下载依赖之外的全部步骤);/usr/bin/time -l 报告的最大常驻内存(含子进程中最大的一个)约 2737 MB
  原生可执行文件 target/app:74.3 MB(一个文件,不需要 JDK);Spring AOT 生成的东西(target/spring-aot/main):classes, resources, sources
    生成的 Java 源文件 52 个,例如:['AopAutoConfiguration__BeanDefinitions.java', 'ApplicationAvailabilityAutoConfiguration__BeanDefinitions.java', 'AutoConfigurationPackages__BeanDefinitions.java', 'BoundConfigurationProperties__BeanDefinitions.java']

构建 102 秒、峰值内存约 2737 MB;产物是 74.3 MB 的单文件;Spring AOT 先生成 52 个 Java 源文件。

启动与内存

实测 · 本机执行输出
  JVM:java -jar app.jar:端口可连接 中位 1023 ms(范围 971~1213);第 1 次请求 38.0 ms;常驻内存 启动后 185 MB,2 次请求后 196 MB
  JVM + Spring AOT:java -Dspring.aot.enabled=true -cp target/classes:target/spring-aot/main/classes:依赖:端口可连接 中位 839 ms(范围 725~1910);第 1 次请求 43.3 ms;常驻内存 启动后 171 MB,2 次请求后 173 MB
  原生镜像:./target/app:端口可连接 中位 28 ms(范围 28~1349);第 1 次请求 7.3 ms;常驻内存 启动后 63 MB,2 次请求后 73 MB

启动 1023 ms → 28 ms;请求后常驻内存 196 MB → 73 MB;只用 Spring AOT 的 JVM 模式也略快。

封闭世界

实测 · 本机执行输出
  JVM:java -jar app.jar:
    GET /runtime → java.vm.name=OpenJDK 64-Bit Server VM;org.graalvm.nativeimage.imagecode=null
    GET /reflect?cls=demo.Hidden → loaded demo.Hidden → Hidden 实例
    GET /reflect?cls=demo.Hinted → loaded demo.Hinted → Hinted 实例
    启动时加 --spring.profiles.active=dev:GET /beans → devMarker 存在=true;activeProfiles=[dev]
  原生镜像:./target/app:
    GET /runtime → java.vm.name=Substrate VM;org.graalvm.nativeimage.imagecode=runtime
    GET /reflect?cls=demo.Hidden → FAIL ClassNotFoundException: demo.Hidden
    GET /reflect?cls=demo.Hinted → loaded demo.Hinted → Hinted 实例
    启动时加 --spring.profiles.active=dev:GET /beans → devMarker 存在=false;activeProfiles=[dev]

没有提示的反射类被裁掉;Bean 定义与 profile 在构建期定死。

吞吐

实测 · 本机执行输出
  JVM:java -jar app.jar:62376 / 55517 / 62004 请求/秒;中位 62004;p99 2.2 / 3.4 / 2.2 ms;压测后常驻内存 302 MB
  原生镜像:./target/app:54315 / 55327 / 54197 请求/秒;中位 54315;p99 2.8 / 2.9 / 3.2 ms;压测后常驻内存 78 MB

稳定之后 JVM 的吞吐更高,原生镜像省的是启动时间与内存。

分步演示

原生镜像
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动。JVM 模式用 OpenJDK 21.0.12;原生镜像用 GraalVM Community 25.0.2(native-image),通过 spring-boot-starter-parent 的 native profile 与 native-maven-plugin 构建,-J-Xmx6g 限制 native-image 的堆;应用 spring-boot-starter-webmvc 加几个演示用接口;启动、内存各 5 次取中位数,吞吐 3 轮;最大常驻内存取自 /usr/bin/time -l;测试时机器上有别的程序在运行,所以只比较同一次运行里的数字
    • 没有验证:Spring Data、Security、模板引擎等更多依赖下的原生镜像可用性与构建时间;Linux 容器里的静态链接与 distroless 镜像;PGO(--pgo)与 G1 垃圾回收器对吞吐的补偿;构建缓存与 CI 成本;原生镜像的调试与 JFR;反射提示用 @RegisterReflectionForBinding、元数据仓库自动补充的情形;对第 74 篇里 CDS 的对比(没有放在同一个应用上);102 秒里有多少是下载插件
    京ICP备2026067502号-1京公网安备11010502063487号