GraalVM NativeImage 小试牛刀 & 踩坑指南

GraalVM NativeImage 小试牛刀 & 踩坑指南

使用 NativeImage 编译 Java 程序为本地映像。拒绝沉重的 JVM,Java 也能有和原生程序一样的体验。这篇文章带你快速上手 GraalVM NativeImage。

大概有人已经了解了最近我正在维护 Ghost-chu/PeerBanHelper 这个仓库,用于在 NAS 上运行并封禁连接到 BT 客户端的恶意对等体(Peer)。但由于使用 Java 语言,因此 2MB 的小工具要带上一个接近 1GB 的 JDK/JRE。更糟糕的是,程序还需要最少 512MB 的 RAM 才能稳定运行。

iceBear67 的提议下,我决定尝试一下 GraalVM 的 Native Image(本地映像),将 Java 程序编译为原生二进制文件。这样程序不再需要携带沉重的 JVM 才能运行,并且大大节约了 RAM 使用量。

使用 Native Image 编译的 Java 程序,其 RAM 占用量相比 JVM 要好得多。

对于 PeerBanHelper 来说,仅需要 ~25MB 的 RAM 就能处理绝大部分工作。磁盘占用对于 700-800MB 的 JDK/JRE 来说,50MB 的成绩相当亮眼(如果愿意压缩一下,可以压缩到 30MB)。

这篇文章除了讲述我是如何将 PeerBanHelper 转换为 Native Image 的以外,也更多的在于帮助其它像我一样刚刚接触 Native Image 的萌新,少走弯路。用最快的时间、最简单的步骤,编译出自己的第一个 Native Image。

确认我的程序是否能够转换为 Native Image

Native Image 是一个新生技术,它仍然存在不少缺陷和问题。尽管在未来这些问题有希望被攻克,但现阶段的技术限制下,Native Image 仍然无法处理复杂的,大型的 Java 应用程序。

对于大量使用反射、泛型、ObjectMapper 的程序来说,过程会比较痛苦,且程序会表现的不那么稳定。从一个简单的程序开始入手是一个绝佳的选择。好在,PeerBanHelper 恰好满足这个要求——有一些功能性的部分以及适量的反射,但并不那么复杂。是测试 GraalVM Native Image 的绝佳对象。

简单来说:

  • 尽量避免使用反射

  • 尽量避免使用复杂泛型结构

  • 尽量避免使用第三方库(因为你不知道里面是否使用了 JVM 黑魔法)

以及,你需要对 Java 和 Maven 有所了解。

安装 GraalVM

https://www.graalvm.org/ 下载 CE 版本即可。记得配置 JAVA_HOME 和 PATH。

在此案例中无需和其它技术文章一样配置 Visual Studio 的 Build Tools,我们的目标是怎么简单怎么来。负责的环境配置和构建 Native Image 的任务我们将会交给 Github Actions。

引入 GraalVM Native Image 插件

我使用的是 Maven,对于使用 Gradle 的朋友就只能先看官方文档了。

首先在 pom.xml 声明一些基本信息:

基本配置

    <properties>    
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <!-- GraalVM Native Image 有关配置 - 开始 -->
        <native.maven.plugin.version>0.10.1</native.maven.plugin.version> <!-- Native Image 插件版本号 可以在这里找到最新的版本和更改日志:https://graalvm.github.io/native-build-tools/latest/index.html#changelog -->
        <!-- Native Image 的名字,也就是编译后最终的可执行文件的名称。对于这个示例,最后输出的 Windows 可执行文件名为 peerbanhelper-binary.exe -->
        <imageName>peerbanhelper-binary</imageName>
        <!-- 主类名,也就是程序执行入口 -->
        <mainClass>com.ghostchu.peerbanhelper.Main</mainClass>
        <!-- GraalVM Native Image 有关配置 - 结束 -->
    </properties>

引入 Maven 插件

需要注意的是,Native Image 并不像 JVM 那样完全屏蔽了平台差异。我们需要手动处理不同系统平台上的各种问题(如代码页引起的控制台乱码等)。推荐按照下面这样配置两个 Profile,这样在使用 Maven 编译时,可以分开指定编译参数。

    <profiles>
        <profile>
            <id>native-unix-like</id> <!-- Linux, macOS 配置 -->
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.buildtools</groupId>
                        <artifactId>native-maven-plugin</artifactId>
                        <version>${native.maven.plugin.version}</version>
                        <extensions>true</extensions>
                        <executions>
                            <execution>
                                <id>build-native</id>
                                <goals>
                                    <goal>compile-no-fork</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>${imageName}</imageName>
                            <fallback>false</fallback>
                            <!-- 指定构建参数,这些参数会在编译时传递给 native-image 自己和编译器 -->
                            <buildArgs>
                                <!-- 固定使用 UTF-8 编码,避免后续为了编码问题焦头烂额 -->
                                <buildArg>-J-Dfile.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dstdout.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dstderr.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dconsole.encoding=UTF-8</buildArg>
                               <!-- 固定使用英语(美国)语言,消除 java.util.logger 等支持本地化组件会返回不同文本的问题,Native Image 打包时如果没有使用对应语言调试,最终打包的镜像会缺少本地化文本  -->
                                <buildArg>-J-Duser.language=en</buildArg>
                                <buildArg>-J-Duser.region=US</buildArg>
                                <buildArg>--verbose</buildArg>
                                <!-- 开放模块访问 -->
                                <buildArg>--add-opens=java.base/java.nio=ALL-UNNAMED</buildArg>
                                <buildArg>--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED</buildArg>
                                <buildArg>--add-opens=java.base/jdk.internal.ref=ALL-UNNAMED</buildArg>
                                <!-- 允许 GraalVM Native Image 在运行时报告不支持的元素,这样就可以根据错误内容调整配置文件 -->
                                <buildArg>--report-unsupported-elements-at-runtime</buildArg>
                                <!-- 允许缺失依赖的代码加入编译,参见 https://goldsubmarine.github.io/2022/01/02/graalvm-%E9%9D%99%E6%80%81%E7%BC%96%E8%AF%91/#%E5%91%BD%E4%BB%A4%E8%A1%8C%E6%A8%A1%E5%BC%8F%E7%BC%96%E8%AF%91 -->
                                <buildArg>--allow-incomplete-classpath</buildArg>
                                <!-- 按需添加需要支持的协议,方便控制 Native Image 的大小,参见 https://www.graalvm.org/22.1/reference-manual/native-image/URLProtocols -->
                                <buildArg>--enable-url-protocols=http,https</buildArg>
                                <!-- Native Image 将会使用的 GC 垃圾回收器,CE 社区版没的选择,只能用 serial -->
                                <buildArg>--gc=serial</buildArg>
                                <!-- 启用打印构建时的错误堆栈,这个可以不加,需要的时候控制台会提示你打开此选项 -->
                                <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                                <!-- 这是重中之重,指定 Native Image 编译使用的配置文件路径,稍后的文章会介绍如何创建这些配置文件 —— 它们几乎都是自动化的 -->
                                <buildArg>-H:ConfigurationFileDirectories=./src/main/resources/META-INF/native-image</buildArg>
                                <!-- 在编译时强制初始化这些类,有助于解决编译完了之后报错 NoClassDefFoundError 的问题 -->
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.NOPLoggerFactory</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.NOP_FallbackServiceProvider</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.SubstituteServiceProvider</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.SubstituteLoggerFactory</buildArg>
                                <buildArg>--initialize-at-build-time=java.util.logging.ConsoleHandler</buildArg>
                                <buildArg>--initialize-at-build-time=java.util.logging.FileHandler</buildArg>
                                <buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
                            </buildArgs>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>

        <profile>
            <id>native-windows</id> <!-- Windows 配置,大部分都和上面的相同 -->
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.buildtools</groupId>
                        <artifactId>native-maven-plugin</artifactId>
                        <version>${native.maven.plugin.version}</version>
                        <extensions>true</extensions>
                        <executions>
                            <execution>
                                <id>build-native</id>
                                <goals>
                                    <goal>compile-no-fork</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>${imageName}</imageName>
                            <fallback>false</fallback>
                            <buildArgs>
                                <!-- 这是 Windows NT 平台编译器的独有编译参数,SUBSYSTEM 后面可以跟多个参数,WINDOWS 代表窗口程序,而 CONSOLE 会打开一个黑色的控制台来显示日志等信息。支持的参数可以见微软的文档:https://learn.microsoft.com/zh-cn/cpp/build/reference/subsystem-specify-subsystem?view=msvc-170 -->
                                <buildArg>-H:NativeLinkerOption=/SUBSYSTEM:CONSOLE</buildArg>
                                <buildArg>-J-Dfile.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dstdout.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dstderr.encoding=UTF-8</buildArg>
                                <buildArg>-J-Dconsole.encoding=UTF-8</buildArg>
                                <buildArg>-J-Duser.language=en</buildArg>
                                <buildArg>-J-Duser.region=US</buildArg>
                                <buildArg>--verbose</buildArg>
                                <buildArg>--add-opens=java.base/java.nio=ALL-UNNAMED</buildArg>
                                <buildArg>--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED</buildArg>
                                <buildArg>--add-opens=java.base/jdk.internal.ref=ALL-UNNAMED</buildArg>
                                <buildArg>--report-unsupported-elements-at-runtime</buildArg>
                                <buildArg>--allow-incomplete-classpath</buildArg>
                                <buildArg>--enable-url-protocols=http,https</buildArg>
                                <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                                <buildArg>--gc=serial</buildArg>
                                <buildArg>-H:ConfigurationFileDirectories=./src/main/resources/META-INF/native-image</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.NOPLoggerFactory</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.NOP_FallbackServiceProvider</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.SubstituteServiceProvider</buildArg>
                                <buildArg>--initialize-at-build-time=org.slf4j.helpers.SubstituteLoggerFactory</buildArg>
                                <buildArg>--initialize-at-build-time=java.util.logging.ConsoleHandler</buildArg>
                                <buildArg>--initialize-at-build-time=java.util.logging.FileHandler</buildArg>
                                <buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
                            </buildArgs>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>
    </profiles>

至此,Native-Image 的 Maven 插件应该成功引入到了你的项目。

生成 Native Image 的配置文件

首先像正常项目一样,使用 mvn package 编译出来一个普通的 JAR,并确认它使用正常方式启动时能够正常工作。

接下来我们来介绍一下 native-image-agent。

众所周知,Java 在运行的时候对库的依赖在运行和编译的时候都可以是不完全的。只要代码不运行到缺失的部分,就可以正常跑。你甚至可以在跑到一半的时候,再去加载这些缺失的部分,只要能加载进去,程序就能接着跑。

但静态编译并非如此,所有内容都必须在编译的时候准备妥当,编译完了之后就再也无法变化。如果在编译时没有准备好,到了运行时就会直接爆开。

无配置文件

使用 native-image-agent 生成配置文件

无配置文件的 NativeImage 遇到了 jackson 报错找不到序列化器

生成配置文件后,NativeImage 能够正常运行,jackson 也不再出现错误

对比:native-image-agent 起到的作用

Native Image 的配置文件允许你指定都有哪些 “动态内容”,这样编译器在编译时就会把这些东西一起带着编译进去。但手动指定配置文件的难度太大了,也十分繁琐。如果有了第三方库那就更是雪上加霜了。因为你也不知道这些第三方库用了哪些内容。因此,使用 GraalVM 的 native-image-agent 可以帮助你生成这些文件。

native-image-agent 是在运行时工作的,也就是程序只有运行到那些需要生成配置文件的部分,native-image-agent 才能检测到它们并生成对应的配置文件这就需要你在启动 native-image-agent 后,尽可能地长时间运行你的程序,并让程序尽可能地运行到每一个分支、每一个模块。如果没有这么做,可能上线之后跑到某个没测到的分支程序就爆开了。

要使用 native-image-agent 启动并开始生成配置文件,需要使用 GraalVM 附加下列参数来启动 JAR:

/path/to/graalvm/bin/java -agentlib:native-image-agent=config-merge-dir=/path/to/your/project/native-image-conf/directory -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dconsole.encoding=UTF-8 -Duser.language=en -Duser.region=US -jar application.jar

其中:

  • config-merge-dir 代表与现有的配置文件合并,增加新检测到的部分到配置文件中。可以替换为config-create-dir,它与 config-merge-dir 的区别是:config-merge-dir 不会覆盖已有的配置文件,而是追加;config-create-dir 则是直接覆盖现有配置文件
    你需要将路径更改为在 Maven 插件中配置的相同路径

  • -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dconsole.encoding=UTF-8 -Duser.language=en -Duser.region=USbuildArgs 中的部分目的相同——-排除编码和环境语言变量

  • application.jar 是你的 JAR 文件(Executable)

有关 native-image 配置文件的更多信息,可以查看这篇文章:GraalVM 静态编译 —— GoldSubmarine's Blog

解决 Windows 下的控制台编码问题

Windows 的控制台默认并不使用 UTF-8,在我们手动指定 UTF-8 编码输出后会遇到乱码问题。你可能会想为什么不加这些参数,让程序自动检测 —— 虽然 JVM 会自动处理,但 Native Image 不会,因此我们最好手动处理编码问题,保持一致性。

无编码处理

有编码处理

因代码页问题出现乱码

修正后一切正常

对比:手动修正编码的效果

要解决这个问题很简单, 启动参数添加 -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dconsole.encoding=UTF-8 -Duser.language=en -Duser.region=US 后,再在 main 方法中检测 Windows 系统并手动切换代码页:

if (System.getProperties().getProperty("os.name").toUpperCase(Locale.ROOT).contains("WINDOWS")) {
                ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/c", "chcp", "65001").inheritIO(); // 需要使用 inheritIO
                Process p = pb.start();
                p.waitFor();
                System.out.println("代码页已切换到 UTF-8 (65001)");
 }

使用 Github Actions 构建编译

一切准备就绪,是时候构建第一个 Native Image 镜像了。

创建一个 Github 仓库,然后在 Actions 中创建一个配置文件 native-image.yml 并填写如下内容:

name: JavaCI - GraalVM NativeImage

on:
  push:
    branches: [ "master", "main" ]
  pull_request:
    branches: [ "master", "main" ]

jobs:
  # Linux/MacOS
  build-unix-like:
    name: ${{ matrix.version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        version: [ latest ]
        # 在 macos 和 ubuntu 上进行编译
        os: [ macos-latest, ubuntu-latest ]
    steps:
      # 克隆仓库
      - uses: actions/checkout@v2
      # 安装 GraalVM
      - uses: graalvm/setup-graalvm@v1
        with:
          java-version: '22' # 目标 Java 版本
          distribution: 'graalvm-community' # 使用 CE 社区版
          components: 'native-image' # 显式声明需要 native-image 组件
          version: '23.0.2' # 版本
          github-token: ${{ secrets.GITHUB_TOKEN }} # Token
      # 缓存 Maven 本地仓库,加快依赖解析速度
      - name: Cache Maven packages
        uses: actions/cache@v2
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
          restore-keys: ${{ runner.os }}-m2
      # 使用在 pom.xml -> <profiles> 中定义的 native-unix-like 的 Maven Profile
      - name: Build with Maven
        run: mvn -B -ntp package -Pnative-unix-like
        env:
          # 非 Linux 系统下,不在 Docker 内进行
          NO_DOCKER: ${{ runner.os != 'Linux' }}
      # 上传编译产物到 Actions 
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-artifacts ${{ matrix.version }} on ${{ matrix.os }}
          # 这里是要上传的文件,需要换成你自己的:
          path: |
            target/PeerBanHelper.jar
            target/peerbanhelper-binary
            target/*.exe
  # Windows 配置文件,与 Linux 的大差不离
  build-windows:
    name: ${{ matrix.version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        version: [ latest ]
        # 使用 Windows 构建
        os: [ windows-latest ]
    steps:
      - uses: actions/checkout@v2
      - uses: graalvm/setup-graalvm@v1
        with:
          java-version: '22'
          distribution: 'graalvm-community'
          components: 'native-image'
          version: '23.0.2'
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Cache Maven packages
        uses: actions/cache@v2
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
          restore-keys: ${{ runner.os }}-m2
      - name: Build with Maven
        run: mvn -B -ntp package -Pnative-windows # 这是 Windows 部分唯一不同的部分,换了个 Maven Profile
        env:
          NO_DOCKER: ${{ runner.os != 'Linux' }}
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-artifacts ${{ matrix.version }} on ${{ matrix.os }}
          path: |
            target/PeerBanHelper.jar
            target/peerbanhelper-binary
            target/*.exe

添加到 git 中,并推送到 Github,在 Actions 中就能看到你的编译任务了。编译成功后,可以下载编译产物。打开下载的压缩包后,里面会有 jar 文件和可执行文件。对于 Windows 来说,双击 .exe 可执行文件,即可直接运行。对于 macOS 和 Linux 来说,chmod +x 赋予运行权限后,即可 ./imagename 运行。

进入 Actions 构建任务

在 Artifacts 中找到构建产物

调试 Native Image

Native Image 之路绝对不会一帆风顺——如果真的一帆风顺,你应该考虑买个彩票。各种编译和运行时错误会始终陪伴你。这些成本必不可少且无法避免(目前来说)。也许很多年后这些都不再是问题,但至少现在跑不了。

通常错误都是因为使用 native-image-agent 生成配置文件时,没有测试完全,一些分支没有测试到,导致配置文件缺失部分。

还有一些 NoClassDefFoundError (通常源自日志框架或者服务),可以尝试使用 --initialize-at-build-time 来解决。

如果你是一个新的项目,一个最佳实践是尽量避免使用复杂的第三方库和各类 JVM 黑魔法。

例如:如果你在用 okhttp 或者 Unirest、Apache HttpClient,但仅仅是为了发送 GET/POST 请求,那么你可以替换为 Java 11+ 新加入的 HttpClient。

Native Image 内存管理

NativeImage 也可以通过 -Xmx、-Xms 以及 -XX:MaxRAMPercentage 等参数控制堆内存,具体可用的参见官方文档

不过作为 NativeImage,自然希望占用的 RAM 越小越好。通过手动运行 GC System.gc() 可以立刻执行垃圾回收并释放进程占用的内存。

对于 PeerBanHelper 来说,由于业务代码基于循环逻辑——即程序所有逻辑由一个循环控制。因此我选择将 System.gc()放在循环结束时——此时所有本周期的所有工作都已完成,出现停顿也不会有太大影响,且此时临时对象和变量都不再有意义,可以立即回收,并释放内存。

尾言

GraalVM 的 Native Image 是一场技术革命。将 Java 应用静态编译成本地映像后,在 RAM 使用和启动速度上取得了亮眼的改进。

遗憾的是,目前技术仍不成熟,有较大的发展空间。在处理动态特性时仍然充满挑战性。不建议在大型项目中投入生产使用。

除特殊说明以外,本站原创内容采用 知识共享 署名-非商业性使用 4.0 (CC BY-NC 4.0) 许可。转载时请注明来源,以及原文链接。
Comment