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 走的是另一条路:
- 静态分析:从入口点(main 方法)出发,通过 Class Hierarchy Analysis(CHA)追踪所有可能执行到的类、方法和字段
- 死代码消除:没有被引用到的代码不会包含在最终镜像中
- AOT 编译:将分析结果编译为平台相关的原生可执行文件(Linux ELF、macOS Mach-O、Windows PE)
- 封闭运行:生成的二进制自带精简版运行时(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 编译后运行时抛出 ClassNotFoundException 或 NoSuchMethodException。
原因:编译期静态分析无法追踪反射调用。比如 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.json、jni-config.json、proxy-config.json、foreign-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)已经有官方配置,但一些小众库可能需要自己写。