告别 JVM 启动慢?Native Image 实战与避坑指南

Java 应用的启动速度慢、内存占用高,一直是微服务和 Serverless 场景中的痛点。GraalVM Native Image 通过 AOT(Ahead-Of-Time)编译,将 Java 字节码直接编译为平台相关的原生机器码,生成一个不需要 JVM 就能独立运行的可执行文件。启动时间从秒级降到毫秒级,内存占用也大幅缩减。

但 Native Image 不是银弹——反射、动态代理、资源文件、JNI 调用等 Java 的"动态特性"在 AOT 编译期都是盲区。很多项目第一次尝试 Native Image 编译都会遇到各种报错。本文以一个 Java CLI 工具和微服务为例,走通完整的编译流程,同时整理常见坑点和解决方案。

Native Image 的核心原理

理解原理是避开陷阱的前提。

JVM 运行 Java 程序是 JIT(Just-In-Time)模式:启动时加载类、解释执行,热点方法再编译为机器码。这个过程需要 JVM 本身和类库,启动时间和内存开销都比较高。

Native Image 走的是另一条路:

  1. 静态分析:从入口点(main 方法)出发,通过 Class Hierarchy Analysis(CHA)追踪所有可能执行到的类、方法和字段
  2. 死代码消除:没有被引用到的代码不会包含在最终镜像中
  3. AOT 编译:将分析结果编译为平台相关的原生可执行文件(Linux ELF、macOS Mach-O、Windows PE)
  4. 封闭运行:生成的二进制自带精简版运行时(Substrate VM),不需要 JVM

关键结论:Native Image 在编译期就决定了最终包含哪些代码,运行期无法动态加载编译期未知的类。 这就是所有问题的根源。

安装 GraalVM

方式一:sdkman(推荐,macOS/Linux)

sdk install java 25-graalce
sdk use java 25-graalce

方式二:手动下载

GraalVM 官网 下载对应平台的 GraalVM 25 发行版,解压后配置环境变量:

# Windows (PowerShell)
$env:JAVA_HOME = "C:\path\to\graalvm-jdk-25"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"

# macOS / Linux
export JAVA_HOME=/path/to/graalvm-jdk-25
export PATH=$JAVA_HOME/bin:$PATH

安装 Native Image 工具

GraalVM 25 中 native-image 已默认包含在发行版中,无需额外安装。

验证安装:

native-image --version
# GraalVM 25.0.x (Java 25)

注意:macOS 平台上 GraalVM 25 仅支持 AArch64(Apple Silicon),不再提供 x64 版本。Linux 和 Windows 仍支持 x86_64 和 AArch64。

GraalVM 25 的新变化

相比早期版本,GraalVM 25 在 Native Image 方面有几点重要变化:

  • SBOM 默认嵌入:生成的原生镜像默认嵌入 Software Bill of Materials,可通过 --enable-sbom=false 关闭
  • --future-defaults:新增选项,用于提前测试未来版本的默认行为变化
  • -H:Preserve:新增实验性选项,可强制保留指定包/模块/类,绕过静态分析限制
  • native-image-inspect 废弃:类级别元数据提取改用 --enable-sbom=class-level,export
  • --install-exit-handlers 已废弃:现在是默认行为,无需手动指定
  • -H:+RuntimeDebugInfo:新增,支持 GDB 调试原生二进制
  • -H:+JDWP:新增实验性 JDWP 调试支持
  • --enable-monitoring:新增运行时监控(heapdump、JFR、JMX)
  • FFM API 配置:agent 新增 foreign-config.json 生成支持
  • -H:-RunReachabilityHandlersConcurrently 已移除:现在只支持并发模式

实战一:将 CLI 工具编译为原生可执行文件

先从一个简单的场景开始——纯 Java 编写的命令行工具,不涉及框架。

项目结构

native-cli/
├── src/main/java/com/example/CliTool.java
└── pom.xml

源代码

package com.example;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class CliTool {
    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            System.out.println("用法: clidemo <url> [--json]");
            System.out.println("  获取指定 URL 的内容并输出");
            return;
        }

        String url = args[0];
        boolean jsonMode = false;
        for (int i = 1; i < args.length; i++) {
            if ("--json".equals(args[i])) {
                jsonMode = true;
            }
        }

        System.err.println("正在请求: " + url);
        long startTime = System.currentTimeMillis();

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .GET()
                .build();

        HttpResponse<String> response = client.send(request,
                HttpResponse.BodyHandlers.ofString());

        long elapsed = System.currentTimeMillis() - startTime;

        System.out.println("状态码: " + response.statusCode());
        System.out.println("耗时: " + elapsed + " ms");
        System.out.println("时间: " + LocalDateTime.now().format(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));

        if (jsonMode) {
            System.out.println("响应体: " + response.body().substring(0,
                    Math.min(200, response.body().length())));
        }
    }
}

Maven 配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>native-cli</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>com.example.CliTool</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

编译为 JAR

mvn clean package

编译为 Native Image

native-image \
  --main-class com.example.CliTool \
  -jar target/native-cli-1.0.jar \
  -o clidemo

编译输出类似:

================================================================================
 GraalVM Native Image: Generating 'clidemo' (executable)...
================================================================================
[1/8] Initializing...                         (5.3s @ 0.16GB)
[2/8] Performing analysis...                  (12.1s @ 0.25GB)
[3/8] Building universe...                    (1.8s @ 0.30GB)
[4/8] Parsing methods...                      (2.4s @ 0.35GB)
[5/8] Inlining methods...                     (1.2s @ 0.32GB)
[6/8] Compiling methods...                   (18.5s @ 0.45GB)
[7/8] Layouting methods...                    (1.5s @ 0.40GB)
[8/8] Creating image...                       (2.8s @ 0.38GB)
Finished generating 'clidemo' in 45.7s.

对比运行效果

# JAR 方式
time java -jar target/native-cli-1.0.jar https://httpbin.org/get

# 输出: real 0m1.852s(JVM 启动约 1.5s + 请求约 0.3s)

# Native Image 方式
time ./clidemo https://httpbin.org/get

# 输出: real 0m0.082s(几乎瞬间启动)

# 镜像大小
ls -lh clidemo
# -rwxr-xr-x  1 user  staff  18M  May 16 22:30 clidemo

18 MB 的独立可执行文件,启动时间从 1.8 秒降到 82 毫秒。对于一个 CLI 工具来说,这个提升非常明显。

实战二:Spring Boot 3 微服务的 Native Image 编译

现实项目中更常见的需求是:把一个 Spring Boot 微服务编译为原生镜像。Spring Boot 3 基于 Spring Framework 6,官方已经提供了 Native Image 支持。

创建项目

使用 Spring Initializr 生成项目,选择:

  • Spring Boot 3.2+
  • Dependencies: Spring Web, Spring Boot Actuator

或者直接用 Maven 配置:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>native-service</artifactId>
    <version>1.0</version>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

编写控制器

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.Map;

@SpringBootApplication
@RestController
public class NativeServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(NativeServiceApplication.class, args);
    }

    @GetMapping("/api/health")
    public Map<String, Object> health() {
        return Map.of(
                "status", "UP",
                "time", LocalDateTime.now().toString(),
                "java.vendor", System.getProperty("java.vendor"),
                "runtime", "native"
        );
    }

    @GetMapping("/api/echo/{msg}")
    public Map<String, Object> echo(@PathVariable String msg) {
        return Map.of(
                "echo", msg,
                "length", msg.length(),
                "timestamp", System.currentTimeMillis()
        );
    }
}

编译原生镜像

mvn -Pnative native:compile

Spring Boot 3 的 native-maven-plugin 会自动处理大量配置,编译时间比纯命令行方式更长(因为要分析整个 Spring 框架的依赖树),通常 3-8 分钟。

编译完成后在 target/ 目录下生成可执行文件:

ls -lh target/native-service
# -rwxr-xr-x  1 user  staff  89M  May 16 22:45 native-service

./target/native-service &

# 输出类似:
# Started NativeServiceApplication in 0.085 seconds (process running for 0.120)

Spring Boot 应用在 JVM 模式下启动通常需要 3-5 秒,Native Image 编译后降到 100 毫秒以内。

常见坑点与解决方案

这里是 Native Image 最核心的部分——编译失败时的排查思路。

1. 反射报错:ClassNotFoundException / NoSuchMethodException

症状:JVM 模式下运行正常,Native Image 编译后运行时抛出 ClassNotFoundExceptionNoSuchMethodException

原因:编译期静态分析无法追踪反射调用。比如 Jackson 反序列化 JSON 时需要通过反射找到目标类的构造函数和 setter 方法,但这些类在编译期没有被引用到。

解决:创建反射配置文件 src/main/resources/META-INF/native-image/reflect-config.json

[
  {
    "name": "com.example.model.UserDTO",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  },
  {
    "name": "com.example.model.OrderDTO",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  }
]

或者使用 GraalVM 提供的代理模式自动生成配置:

# 先用 JVM 运行程序,同时开启反射追踪代理
# 注意:agent 的 JVM 版本需与 native-image 的 JDK 版本一致
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
  -jar target/native-service-1.0.jar

这个代理会记录运行期间所有通过反射访问的类和方法,自动生成 reflect-config.jsonjni-config.jsonproxy-config.jsonforeign-config.json(FFM API 相关)等配置文件。

GraalVM 25 的 agent 增加了 JVM 版本检查:如果运行 agent 的 JVM 版本与 native-image 构建版本不一致会直接中止,避免生成不兼容的配置。

另外,GraalVM 25 还引入了新的 Native Image Tracing Agent,可以直接在原生镜像运行时追踪可达性元数据,替代部分 JVM agent 的工作:

# 先构建时启用 Preserve
native-image -H:Preserve=all -o myapp MyMainClass

# 运行原生镜像时开启追踪
./myapp -XX:TraceMetadata=/tmp/trace_output

2. 资源文件找不到

症状:编译成功,但运行时找不到配置文件、模板文件或静态资源。

原因:Native Image 默认不包含 classpath 下的资源文件。需要显式声明哪些资源要打包进镜像。

解决:创建 src/main/resources/META-INF/native-image/resource-config.json

{
  "resources": {
    "includes": [
      {
        "pattern": ".*\\.properties$"
      },
      {
        "pattern": ".*\\.xml$"
      },
      {
        "pattern": "static/.*"
      },
      {
        "pattern": "templates/.*"
      }
    ]
  }
}

正则表达式匹配的资源文件会被嵌入到原生镜像中。

3. 动态代理报错

症状java.lang.reflect.Proxy 创建失败,常见于使用 Spring AOP、Feign Client、MyBatis 等依赖动态代理的场景。

解决src/main/resources/META-INF/native-image/proxy-config.json

[
  ["com.example.service.UserService", "org.springframework.aop.SpringProxy"],
  ["com.example.client.RemoteApiClient", "org.springframework.aop.SpringProxy"]
]

Spring Boot 3 的 native-maven-plugin 已经自动处理了大部分 Spring 自身的动态代理,但如果项目有自定义的 AOP 接口或 Feign 客户端,可能需要手动补充。

4. JNI 调用失败

症状:依赖本地库(如 SQLite、某些加密库)的项目编译时报错或运行时报 UnsatisfiedLinkError

原因:JNI 方法是动态链接的,编译期无法知道哪些 native 方法会被调用。

解决:同样使用 native-image-agent 自动生成 jni-config.json,或者手动编写。需要注意的是,如果本地库在编译时不存在,Native Image 编译会直接失败——必须确保 .dll/.so/.dylib 文件在编译时可用。

5. 序列化失败

症状java.io.Serializable 相关类编译后运行时异常。

解决src/main/resources/META-INF/native-image/serialization-config.json

{
  "types": [
    {
      "name": "com.example.model.UserDTO",
      "customTargetConstructorClass": "com.example.model.UserDTO"
    }
  ],
  "lambdaCapturingTypes": []
}

6. 编译内存不足

症状:编译过程中出现 OutOfMemoryError 或编译进程被 kill。

解决:增加 Native Image 编译时的内存:

native-image \
  -J-Xmx4g \
  --main-class com.example.CliTool \
  -jar target/app.jar

对于大型 Spring Boot 项目,建议至少分配 4-6 GB 内存。

7. 镜像体积过大

症状:生成的可执行文件超过 100 MB。

优化手段

native-image \
  -O3 \
  --gc=serial \
  -jar target/app.jar
  • -O3:最高级别优化(编译时间更长,但镜像更小更快)
  • --gc=serial:Community Edition 可用的最省空间的 GC(G1 仅限 Enterprise Edition)
  • 编译后用 strip(Linux/macOS)或 upx 进一步压缩
# Linux/macOS
strip native-service

# UPX 压缩(需要安装 upx)
upx --best native-service

GraalVM 25 还新增了 SBOM 相关选项,如果不需要可以关闭以减小体积:

native-image \
  --enable-sbom=false \
  -O3 \
  -jar target/app.jar

8. 不支持的特性

以下 Java 特性在 Native Image 中完全不支持或需要特殊处理:

  • Runtime.exec():可以调用,但子进程行为依赖操作系统
  • java.awt / javax.swing:GraalVM 官方支持有限,需要额外配置
  • 动态类加载(ClassLoader.defineClass):完全不支持
  • java.lang.management:大部分功能不可用
  • RMI、CORBA:不支持

GraalVM 25 新增了 -H:+RuntimeDebugInfo-H:+JDWP(实验性),可以在原生镜像运行时使用 GDB 或 JDWP 协议调试,这在排查不支持特性的问题时非常有用。

9. 利用 -H:Preserve 绕过静态分析限制(GraalVM 25 新增)

场景:项目大量使用反射或动态特性,逐个写配置文件太繁琐。

解决:GraalVM 25 引入了 -H:Preserve 实验性选项:

# 保留整个包
native-image -H:Preserve=package=com.example.model.* \
  -jar target/app.jar

# 保留整个模块
native-image -H:Preserve=module=my-module \
  -jar target/app.jar

# 保留所有 JDK 和 classpath 元素(注意内存消耗很大)
native-image -H:Preserve=all \
  -jar target/app.jar

-H:Preserve=all 会让编译期保留所有类和资源,几乎不会出现反射找不到的问题,但镜像体积和内存消耗会显著增加。适合快速验证,生产环境建议配合 -Os 优化体积。

Docker 镜像构建

生产部署更常见的方式是打包为 Docker 镜像。Spring Boot 3 提供了原生 Docker 构建支持:

# 需要先安装 Docker 和 GraalVM
mvn spring-boot:build-image -Pnative

这会生成一个基于 Cloud Native Buildpacks 的轻量级 Docker 镜像(约 80-150 MB),而不是传统的 JVM 基础镜像(通常 300-500 MB)。

Dockerfile 方式也可以:

FROM ghcr.io/graalvm/native-image:25-ol9 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -Pnative native:compile

FROM alpine:3.19
RUN apk add --no-cache libstdc++
COPY --from=builder /app/target/native-service /app/
EXPOSE 8080
ENTRYPOINT ["/app/native-service"]

GraalVM 25 还支持 Oracle Linux 10,不再支持 Oracle Linux 7。如果项目运行在 OL7 上,需要先升级操作系统。

最终镜像大小约 30-50 MB,启动时间在 100 毫秒级别。

GraalVM 25 还支持 --enable-sbom 选项在 Docker 构建中嵌入 SBOM:

native-image \
  --enable-sbom=embed,cyclonedx,class-level \
  -jar target/native-service.jar

选型建议

Native Image 不是万能药,适合和不适合的场景都很明确:

适合

  • CLI 工具:启动速度从秒级降到毫秒级,体验提升巨大
  • Serverless / FaaS 函数:冷启动时间直接决定计费成本和用户体验
  • 微服务:资源占用更低,同样的硬件可以跑更多实例
  • 分发场景:不需要用户安装 JDK,一个二进制文件直接运行

不适合

  • 长时间运行、启动频率低的服务:JVM JIT 的运行时优化在长时间运行后性能可能更好
  • 重度依赖反射/动态代理/插件化的项目:配置工作量很大
  • 需要热更新/热部署的场景:Native Image 是静态编译,无法动态加载新代码
  • 团队没有 GraalVM 使用经验且项目复杂度高:维护成本需要评估

对于新项目,如果明确要走 Native Image 路线,从架构设计阶段就应该减少反射、动态代理和运行时类加载的使用。对于已有项目,先评估依赖的第三方库是否提供了 native-image 配置——很多主流库(Jackson、Spring、Hibernate)已经有官方配置,但一些小众库可能需要自己写。