![GraalVM与Java静态编译:原理与应用](https://wfqqreader-1252317822.image.myqcloud.com/cover/558/41992558/b_41992558.jpg)
4.5 静态编译目标应用程序
当以上各项准备工作都已完成后,就可以开始静态编译了。GraalVM可以将jar包或者未打包的class文件编译为ELF(Executable and Linkable Format)格式的二进制可执行文件或动态共享库文件。本节以编译为二进制可执行文件为例,从整体上进行介绍。编译为动态共享库的内容会在第11章详细介绍。
目前GraalVM支持通过4种方式调用静态编译启动器,分别是命令行模式、配置文件模式、Maven插件模式和Gradle插件模式。其中命令行模式是基础,其他3种都是对命令行模式的包装,因此本节会详细介绍命令行模式的用法、命令和参数,再简要介绍其他几种模式。
4.5.1 命令行模式编译
GraalVM及其所有子项目都是命令行界面的应用程序,最初设计的使用方式就是通过命令行加各种参数启动执行。其他各种启动模式都是对命令行模式的包装,以方便用户使用,因此在掌握了基本的命令行模式后,就更容易理解其他几种模式了。
执行静态编译的基本命令格式是:
1 $GRAALVM_HOME/bin/native-image -cp $CP $OPTS [app.Main]
或
2 $GRAALVM_HOME/bin/native-image $OPTS -jar [app.jar]
第1行命令是一般的使用方式,用-cp指定编译的依赖范围,最后的[app,Main]指定编译入口。第2行命令则用于编译一个已经封装了主类信息的jar包。$GRAALVM_ HOME/bin/native-image是静态编译的启动器,-cp $CP指定编译所需的所有依赖的路径。这里的依赖包含两方面的内容。
1)目标应用程序原有的所有依赖:在4.3节中准备的依赖库。
2)动态特性的配置文件:在4.4节中预执行得到的配置文件。
$OPTS是编译时设置的选项,从使用的角度可以分为启动器选项、编译器选项和运行时选项三大类。因为选项的数量过于庞大,在此我们仅对各大类做简要介绍,并详细说明几个常用选项。
1)启动器选项用于控制启动器行为,或通过启动器传递给Substrate VM。
① -cp、--classpath、-jar、--version:虽然native-image启动器并非Java程序,但是这些选项与Java的同名选项含义相同。
② --debug-attach[=<port>]:在指定端口开启远程调试,默认端口是8000。GraalVM的静态编译框架及其各个组件都是用Java开发的,因此调试它与调试其他Java程序并无不同。但是由于Substrate VM项目结构比较复杂,难以直接调试,因此需要打开JVM的远程调试模式,将调试器连接到JVM进行调试。--debug-attach实际上最终还是被解析为-agentlib:jdwp=transport=dt_socket,server=y,address=<port>,suspend=y,并送到了编译框架的JVM里。
③ --dry-run:启动器仅做参数解析包装工作,然后输入最终实际启动静态编译框架的所有参数,但不真正启动静态编译框架。其主要用于调试。
④ --help、--help-extra、--expert-options-all:打印输出选项的帮助信息。
⑤ 编译器参数:编译器参数用于控制静态编译器的行为,少部分常用选项以“--”为前缀,可以通过$GRAALVM_HOME/bin/native-image --help查看;更多的是以“-H:”为前缀(目前共有544个)的高级选项,这些选项可以通过执行$GRAALVM_HOME/bin/native-image --expert-options-all | grep“\-H:”查看。在此仅介绍部分常用参数。
⑥ -J<Flag>:设置native-image编译框架本身的JVM参数。
⑦ --no-fallback:从不进入fallback模式。当Substrate VM发现使用了未被配置的动态特性时会默认回退到JVM模式。本选项会关闭回退行为,总是执行静态编译。
⑧ --report-unsupported-elements-at-runtime:当发现应用程序中使用了静态编译不支持的特性时不立即报告并终止编译,而是继续完成编译,等到运行时第一次执行到不支持的特性再报告。这个选项将编译时发现的错误推迟到运行时报告,从常规的软件开发流程的角度看是错误的,因为错误越早发现修复代价越小。但是推迟报告在静态编译的场景中有其必要性,因为静态编译依赖的静态分析技术存在的局限性,会导致静态编译的范围可能大于实际运行时的执行范围,也就是会编译到实际运行不需要的代码。如果不支持的特性正好位于实际不会执行的代码中,则不会有任何实质影响,但是Substrate VM无法做出这个判断,只能将其交给开发者判断。
⑨ --allow-incomplete-classpath:允许不完全的classpath。如4.3节的介绍,Java的依赖是不完全的,而静态编译的依赖是完全的,任何缺失都会导致编译失败。但是在有的场景下其实并不需要完全依赖,比如代码清单4-3中的情况,如果运行时一定不会进入else分支,则即便缺少了Foo类的依赖定义也没有关系。与前一个参数的使用场景类似,静态分析可能会将实际不会执行的代码加入编译,这部分代码的依赖是允许缺失的。
⑩ --initialize-at-run-time:将指定的单个类或包中的所有类的初始化推迟到运行时。类初始化优化是GraalVM的一个创新,但并非所有类都可以在编译时初始化。Substrate VM会自动判断一个类是否可以在编译时初始化,用户也可以手动指定类的初始化时机。
⑪ --initialize-at-build-time:将指定的单个类或包中的所有类的初始化提前到编译时。
⑫ --shared:将程序编译为共享库文件,不加此选项默认将应用程序编译为可执行文件。编译共享库文件时需用CLibrary的注解@CEntryPoint标识共享库暴露的API作为编译入口,详见第11章的介绍。
⑬ -H:Name:指定编译产生的可执行文件的名字。如不指定,则默认以主函数所在类的全部小写的类全名(full qualified name)为文件名。
⑭ -H:-DeleteLocalSymbols:禁止删除本地符号,本参数默认设置为打开,即会删除本地符号。为了减少编译后文件的大小,编译器会将程序中的本地符号删除,但是缺少符号信息会在调试时难以定位代码。因此,如果有调试需求,可以关闭此选项。
⑮ -H:+PreserveFramePointer:保留栈帧指针信息,本参数默认为关闭。同样是为了减少编译文件的大小,默认不会保留栈帧指针,这会导致在调试时无法显示调用栈名,而只能看到问号。因此,如有调试需求,可以将此参数设置为打开。
⑯ -H:+ReportExceptionStackTraces:打印编译时异常的调用栈,本参数默认为关闭。打开后就可以在静态编译出错时输出完整的异常调用栈信息,帮助发现异常原因以便修复。
2)运行时参数:运行时参数用于控制可执行程序的运行时表现,以“-R:”开头,目前共有378个,数量可能会随版本升级而变化。在此没有需要特别介绍的运行时参数,读者可以通过执行$GRAALVM_HOME/bin/native-image --expert-options-all | grep“\-R:”查看所有运行时参数及说明。
最后的app.Main是应用程序主类的全名。静态编译需要指定编译的入口,对于一般的应用程序需要给出main函数所在的主类。Substrate VM会自动在主类中寻找main函数作为编译入口。如果设置了--shared选项编译动态库文件,则无须设置主类。
4.5.2 配置文件模式
当静态编译使用的编译参数较多时,就需要通过执行脚本或配置文件来管理参数。GraalVM官方推荐使用配置文件管理,因为脚本缺少统一规范,不易管理。目前配置文件支持用户自行配置3个属性。
- Args:设置各项参数,类似4.5.1节的$OPTS。不同参数用空格分隔,换行使用“\”。
- JavaArgs:设置静态编译框架本身的JVM参数,等同于4.5.1节的-J<Flag>。
- ImageName:设置编译生成的文件名,等同于4.5.1节的-H:Name参数。但是当在配置文件中设置本属性且在命令行中设置了-H:Name时,则以命令行参数为准。
配置文件的默认保存路径是静态编译时classpath下的META-INF/native-image/native-image.properties。Substrate VM会从classpath的文件目录结构或classpath上的jar包中按上述路径寻找有效的配置文件。
4.5.3 Maven插件模式
GraalVM也支持通过Maven插件[1]启动静态编译,为通过Maven做开发管理的项目提供便利。在使用Maven插件编译项目时,必须首先保证系统环境变量GRAALVM_HOME指向了GraalVM JDK所在的目录。
使用Maven插件时需要先在应用程序的pom中添加编译所需的graal-sdk依赖。
<dependency> <groupId>org.graalvm.sdk</groupId> <artifactId>graal-sdk</artifactId> <version>${graalvm.version}</version> <scope>provided</scope> </dependency>
然后准备插件的配置信息如下:
<plugin> <groupId>org.graalvm.nativeimage</groupId> <artifactId>native-image-maven-plugin</artifactId> <version>${graalvm.version}</version> <executions> <execution> <goals> <goal>native-image</goal> </goals> <phase>package</phase> </execution> </executions> <configuration> <skip>false</skip> <imageName>example</imageName> <mainClass>app.Main</mainClass> <buildArgs> --no-fallback -H:-DeleteLocalSymbols </buildArgs> </configuration> </plugin>
静态编译的详细信息在<configuration>项中配置。
- <skip>项控制是否执行静态编译,true表示不执行,false表示执行。
- <imageName>项配置编译的文件名,对应4.5.1节的-H:Name选项。
- <mainClass>项设置编译的入口主类名,对应4.5.1节的<app.Main>。
- <buildArgs>项设置编译参数,对应4.5.1节的$OPTS,多个参数之间用空格分隔。
配置完成后执行mvn package即可在项目target目录下生成静态编译的可执行文件。需要说明的是,Maven模式实际上自动完成了4.3节的工作,但是仍然需要进行4.4节介绍的预执行。
4.5.4 Gradle插件模式
Gradle也是一种常用的Java项目构建工具,GraalVM也为其提供了一个构建插件[2],以便用户从Gradle项目中执行静态编译。具体使用步骤如下。
在项目的build.gradle项目配置文件中添加native-image插件:
plugins { // 原有内容不变 // 添加native-image插件 id 'org.graalvm.buildtools.native' version "${current_plugin_version}" }
因为目前该插件还没有发布到Gradle的插件库中,所以需要单独在项目的settings.gradle文件中添加:
pluginManagement { repositories { mavenCentral() gradlePluginPortal() } }
添加编译选项,可以通过Gradle的DSL向静态编译添加编译选项,如代码清单4-4所示。
代码清单4-4 native-image的Gradle插件配置
nativeBuild { imageName = "application" mainClass = "org.test.Main" // 待编译的主类 buildArgs("--no-server") // 需要传给静态编译框架的参数,以逗号分隔 debug = false // 是否需要生成调试信息,等价于-g verbose = false fallback = false classpath("dir1", "dir2") // 指定classpath jvmArgs("flag") // 向执行静态编译的JVM传入参数,等价于4.5.1节的-J runtimeArgs("--help") // 指定native image在运行时的参数 // 设置执行静态编译的JVM的系统属性 systemProperties = [name1: 'value1', name2: 'value2'] agent = false // 指定是否需要启动native-image-agent,也可在命令行中用"-"Pagent } nativeTest { agent = false // 指定是否需要启动native-image-agent,也可在命令行中用"-"Pagent [略] // 除了不能更改imageName外,所有nativeBuild块中的设置都在此可用 }
Gradle插件中一共有4个任务。
- nativeRun:以native image的形式执行当前项目的应用。这个任务会先对当前的Java项目执行静态编译。
- nativeBuild:将当前项目静态编译为native image。
- nativeTest:将test目录中的所有测试静态编译到一个单一native image中并执行。
- nativeTestBuild:静态编译项目的test目录中的所有测试。
Gradle插件依赖于GraalVM JDK(安装方法参见4.1节)和指向它的系统环境变量$GRAALVM_HOME。与Maven模式相比,Gradle模式支持通过属性配置native-image-agent以预执行应用程序,从而获取动态特性的配置文件。用户既可以在代码清单4-4所示的Gradle的native-image任务配置项中增加agent = true属性,也可以在执行相应的Gradle任务时加上-Pagent参数,例如下面的命令可以先挂载native-image-agent预执行一遍项目中的所有单元测试,生成配置文件,然后使用这些配置文件静态编译所有的单元测试并执行以下命令:
Gradle nativeTest -Pagent
这种通过执行单元测试得到项目配置文件的方式能够获得比较全面的配置信息,但是要求项目必须使用Junit4及以上版本作为测试框架,否则不能正确产生配置文件和静态编译测试。虽然Junit4已经推广多年,但依然有比较流行的开源项目使用Junit3作为测试框架,例如在安全领域常用的bouncycastle[3]项目。我们很难在短时间内将这种项目的测试框架升级为Junit4,此时一种有效的替代方案是在build.gradle中为Gradle的测试JVM指定native-image-agent选项(参见4.4节)。比如对bouncycastle项目根目录中的build.gradle[4]文件做如下修改,为其测试任务添加JVM选项,在运行测试时挂载native-image-agent:
--- a/build.gradle +++ b/build.gradle @@ -70,7 +70,7 @@ subprojects { test { systemProperty 'bc.test.data.home', bcTestDataHome maxHeapSize = "1536m" - + jvmArgs '-agentlib:native-image-agent=config-output-dir=test-configs' filter { includeTestsMatching "AllTest*" }
之后再执行gradle test命令就会在每个模块的目录中生成test-configs目录,其中包含执行该模块测试用例时调用的动态特性的配置文件。
[1]参见https://search.maven.org/artifact/com.oracle.substratevm/native-image-maven-plugin。
[2]参见https://github.com/graalvm/native-build-tools/tree/master/native-gradle-plugin。
[3]参见https://bouncycastle.org/java.html。
[4]参见https://github.com/bcgit/bc-java/blob/master/build.gradle。