大概有人已经了解了最近我正在维护 Ghost-chu/PeerBanHelper 这个仓库,用于在 NAS 上运行并封禁连接到 BT 客户端的恶意对等体(Peer)。但由于使用 Java 语言,因此 2MB 的小工具要带上一个接近 1GB 的 JDK/JRE。更糟糕的是,程序还需要最少 512MB 的 RAM 才能稳定运行。
在 iceBear67 的提议下,我决定尝试一下 GraalVM 的 Native Image(本地映像),将 Java 程序编译为原生二进制文件。这样程序不再需要携带沉重的 JVM 才能运行,并且大大节约了 RAM 使用量。
对于 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 起到的作用
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=US
与buildArgs
中的部分目的相同——-排除编码和环境语言变量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
运行。
调试 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 使用和启动速度上取得了亮眼的改进。
遗憾的是,目前技术仍不成熟,有较大的发展空间。在处理动态特性时仍然充满挑战性。不建议在大型项目中投入生产使用。