- 编译器
- JIT编译
- 语言运行时
- 高性能计算
- 内存管理
【免费下载链接】graal
GraalVM compiles applications into native executables that start instantly, scale fast, and use fewer compute resources 🚀
Native Image 采用与传统 Java 虚拟机(VM)截然不同的编译模型,通过构建期静态分析找出应用中所有可达方法并将其直接编译为原生可执行文件,从而换取极快的启动速度和极低的内存占用。本文以 GraalVM 官方 Compatibility Guide 为核心,系统梳理这一编译模型带来的行为差异——哪些 Java 特性需要额外元数据、哪些特性不兼容闭世界假设、哪些特性在原生镜像中行为与 JVM 不同,以及 Linux AArch64 架构上的特殊限制。阅读本文后,你将能够准确判断自己的应用在迁移到 Native Image 时可能遇到的兼容性问题,并掌握对应的配置手段与规避方案。
概述:构建时与运行时,以及闭世界假设
Native Image 将 Java 应用程序的编译过程划分为两个截然不同的阶段(详见 Native Image Basics):
- 构建时(build time):
native-image构建器执行静态分析,从应用程序的入口点(main方法)出发,寻找所有可达的方法;随后只将这些(且仅这些)方法编译进可执行二进制。 - 运行时(run time):编译生成的原生可执行文件被实际执行。
这一模型之所以成立,依赖于所谓的闭世界假设(closed-world assumption):所有代码在构建时都是已知的,运行时不再加载任何新代码。由于这一假设,原生镜像可以达到最小化的内存占用和极快的启动速度。
但正如多数优化手段一样,并非所有应用都适合这种编译方式。如果native-image构建器无法在构建时对应用完成优化,它会生成一个所谓的fallback file(回退文件),该文件仍需要 Java VM 才能运行——此时"原生镜像"的启动与内存优势将完全丧失。因此,理解哪些特性会导致回退、哪些特性会改变行为,是迁移前必须做好的功课。
需要元数据(Metadata)的特性
在闭世界假设下,静态分析只能确定"构建时可推导"的程序元素;而 JVM 上大量基于动态语言特性的访问方式(例如反射、资源加载、动态代理、JNI 等)依赖的类、方法、字段或资源 URL 在运行期才被解析,静态分析无法覆盖。
为了在"保持镜像最小"与"保证运行期正确"之间取得平衡,以下 Java 特性通常需要向native-image构建器提供可达性元数据(reachability metadata):
- 反射(
Class.forName、getMethod、getDeclaredField等); - 资源访问(
getResource/getResourceAsStream); - 动态代理(
Proxy.newProxyInstance); - JNI(
RegisterNatives、FindClass等); - 序列化;
- 资源包(ResourceBundle)。
元数据可以以多种方式提供给构建器(详见 Reachability Metadata):
- 在代码中计算元数据:例如
Class.forName("Foo")这类以常量参数调用的动态访问,会在构建期被折叠为常量并写入镜像初始堆(image heap); - 提供 JSON 文件:在 classpath 的
META-INF/native-image/<groupId>/<artifactId>/目录放置reachability-metadata.json文件; - 使用
-H:Preserve=标志保留指定的包、模块或类; - 使用 Feature API(
org.graalvm.nativeimage.hosted.Feature)处理需要扫描 classpath 的高级场景。
为降低维护成本,GraalVM 社区已发布共享可达性元数据仓库(GraalVM Reachability Metadata Repository),第三方依赖的元数据可以复用而非重复维护。此外,也可以使用native-image-agent自动收集元数据(见 Collecting Metadata Automatically)。
与闭世界假设不兼容的特性
闭世界假设要求"所有被调用的方法及其调用点都是已知的",但部分 Java 语言与运行时的特性恰恰在运行期引入调用,一旦使用这些特性,构建器无法完成静态优化,结果就是生成需要 JVM 才能运行的 fallback file。
invokedynamic字节码与方法句柄(Method Handles)
invokedynamic指令和方法句柄(java.lang.invoke.MethodHandle)可以在运行期引入新的调用,或改变实际被调用的方法,这与闭世界假设直接冲突。
不过需要特别说明的是:javac为lambda 表达式和字符串拼接(String concatenation)生成的那些invokedynamic用例是受支持的——它们不会在运行期改变被调用的方法,静态分析可以将其归约为确定目标。也就是说,日常 Java 代码中由编译器自动生成的invokedynamic通常没有问题,真正的问题出现在运行期才决定方法目标的手写用法上。
在原生镜像中行为可能不同的特性
即使应用能够成功构建为原生镜像,部分 Java 特性在 Native Image 中的实现与标准 Java VM 也存在差异。以下逐一说明。
安全管理器(Security Manager)
在原生镜像中,安全管理器相关 API 的行为与 JVM 不同:
java.lang.System#getSecurityManager()始终返回null,即使通过-Djava.security.manager在启动时设置了安全管理器也是如此;java.lang.System#setSecurityManager(SecurityManager)若以非null参数调用,当程序启动时-Djava.security.manager的值不是disallow,会抛出java.lang.SecurityException。
这一行为在源码层面有明确的实现依据:在 SubstrateSharedGraphBuilderPlugins.java 中,System.getSecurityManager被注册为一个 RequiredInvocationPlugin,直接压入null常量(注释明确写着 "System.getSecurityManager() always returns null")。这属于构建器级别的常量折叠优化,而非运行期行为模拟。
类初始化(Class Initializers)
默认情况下,Native Image 中的类在运行时初始化,这保证了兼容性,但限制了一些优化空间。为了更快的启动速度和更好的峰值性能,建议让类在构建时初始化,可通过以下选项控制:
--initialize-at-build-time=<class 或 package>:指定类或包在构建时初始化(也支持全部类);--initialize-at-run-time=<class 或 package>:指定类或包在运行时初始化。
JDK 类库中的类默认在构建时初始化。
注意:构建时初始化可能破坏既有代码中的某些假设。例如:
- 类初始化器中加载的文件,在构建时与运行时的路径可能不同;
- 文件描述符、运行中的线程等对象绝不能存储在原生可执行文件中;
- 如果此类对象在构建时可达,
native-image构建器会直接报错终止。
类初始化是 Native Image 中影响性能与正确性最关键的主题之一,建议进一步阅读 Class Initialization in Native Image。从该文档可知,构建时初始化能够消除运行期的初始化检查(否则每次访问类的字段或方法都要做检查,性能可下降超过两倍),而一个最简单的 "Hello, World!" 应用就需要初始化 300 多个类。Native Image 还提供安全类自动初始化机制:如果一个类的相关父类型都安全、且其初始化器不调用不安全的 native 方法、虚拟方法或被替换(substituted)的方法,则该类会被自动判定为可在构建时初始化。使用-H:+PrintClassInitialization选项可以查看哪些类被初始化及其原因,用于指导初始化策略的调优。
终结器(Finalizers)
java.lang.Object定义了finalize()方法,JVM 中它由垃圾回收器在对象不再被引用时调用,子类可覆盖它以释放系统资源或执行清理。
但终结器自 Java SE 9 起已被弃用,其语义设计存在严重缺陷——例如终结器可以通过把对象引用存入静态字段使对象"复活"(resurrection)。因此,Native Image 不会调用终结器。这一决策在源码中有明确证据:
- Target_java_lang_ref_Finalizer.java 使用
@TargetClass+@Delete注解将java.lang.ref.Finalizer及其FinalizerThread直接删除,注释标明 "SubstrateVM does not support Finalizer references" 与 "SubstrateVM does not run a Finalizer thread"; - SnippetRuntime.java 中的
registerFinalizer实现为空操作:"We do not support finalizers, so nothing to do."
建议:使用弱引用(WeakReference)配合引用队列(ReferenceQueue)替代终结器来实现资源清理。
线程(Threads)
java.lang.Thread中早已废弃的方法(如Thread.stop())在 Native Image 中未实现。任何依赖这些废弃方法的代码都需要改写,否则在原生镜像中将无法正常工作。
Unsafe 内存访问(sun.misc.Unsafe)
对于通过sun.misc.Unsafe访问的字段,如果类在构建时初始化,则需要将这些字段标记出来供静态分析识别。大多数情况下这一过程是自动完成的:
- 存储在
static final字段中的字段偏移量,会被自动从 hosted 值(即运行native-image构建器所在 JVM 上的字段偏移)重写为原生可执行文件的字段偏移;作为重写的一部分,该字段会被自动标记为Unsafe访问。
对于非标准模式,可以使用RecomputeFieldValue注解手动重算字段偏移。该注解定义于 RecomputeFieldValue.java,其Kind枚举提供了丰富的重算策略,主要包括:
| Kind | 行为 |
|---|---|
None | 字段值不修改(默认) |
Reset | 字段重置为默认值(null、0、false) |
NewInstance/NewInstanceWhenNotNull | 用declClass的默认构造器新建实例赋值 |
FromAlias | 取@Alias字段的值 |
FieldOffset | 重算为declClass中name字段的偏移(等价于Unsafe.objectFieldOffset) |
StaticFieldBase | 重算为静态字段的 base 对象 |
ArrayBaseOffset/ArrayIndexScale/ArrayIndexShift | 重算数组基址偏移、元素大小及 log2 移位量 |
AtomicFieldUpdaterOffset | 支持AtomicXxxFieldUpdater用到的字段偏移 |
TranslateFieldOffset | 将 hosted universe 中的字段偏移翻译为 substrate universe 中的对应偏移 |
Manual/Custom | 标记由手动逻辑或FieldValueTransformer接管 |
需要注意的是,官方文档同时指出:受支持的 API是使用BeforeAnalysisAccess#registerFieldValueTransformer(Feature API)来注册字段值转换器,RecomputeFieldValue注解属于非 API 的辅助机制。
调试与监控(Debugging and Monitoring)
Java 规范包含一些可选接口用于调试和监控 Java 程序,例如JVMTI。这些接口基于"运行时存在 Java 字节码"这一假设构建,而闭世界优化构建出的原生镜像中并不存在字节码,因此:
- 必须使用原生调试器和监控工具(如 GDB、VTune)而不是面向 Java 的工具;
- JVMTI 及其他基于字节码的工具不受支持。
好在 GraalVM 为原生镜像提供了专门的能力,包括 Chrome DevTools 协议调试(JDWP,见 JDWP)、Java Flight Recorder(见 JFR)以及jcmd诊断命令(见 JCmd),可满足运行期诊断需求。
Linux AArch64 架构上的限制
Native Image 的绝大多数特性在Linux AArch64架构上都受支持,但存在以下例外:
-R:[+|-]WriteableCodeCache:该选项必须被禁用(即使用-R:-WriteableCodeCache);--libc=<value>:musl不可用,即不支持静态链接到 musl libc 的构建方式(glibc与bionic仍是可用选项,完整的--libc选项说明见 Build Options)。
native-image构建器的全部选项清单可参考 Build Options。
迁移实践建议小结
综合全文,将应用迁移到 Native Image 时建议按以下路径排查兼容性:
- 先构建:直接运行
native-image,观察是否产生 fallback file 或报错,快速定位不兼容点; - 再补元数据:对反射、资源、代理、JNI 等动态访问,通过 agent 自动收集或手写 JSON 提供可达性元数据(Reachability Metadata);
- 调类初始化:使用
-H:+PrintClassInitialization观察类初始化情况,用--initialize-at-build-time/--initialize-at-run-time平衡启动性能与正确性(Class Initialization in Native Image); - 规避不兼容 API:去除
finalize()依赖(改用弱引用 + 引用队列)、Thread.stop()等废弃线程方法,以及依赖 JVMTI 字节码工具的调试方案; - 确认目标平台:若面向 Linux AArch64,确认
WriteableCodeCache被禁用、--libc不使用 musl。
相关文档
- Class Initialization in Native Image
- Reachability Metadata
- Native Image Basics
- Build Options
- Automatic Metadata Collection
- 编译器
- JIT编译
- 语言运行时
- 高性能计算
- 内存管理
【免费下载链接】graal
GraalVM compiles applications into native executables that start instantly, scale fast, and use fewer compute resources 🚀
相关推荐
CuPy 与 NumPy 行为差异详解:GPU 数组库的接口兼容性与边界语义
CuPy 与 NumPy 行为差异详解:GPU 数组库的接口兼容性与边界语义 CuPy 是一个面向 GPU 的 NumPy/SciPy 兼容数组库,其接口设计以
科学计算高性能计算ArchiSteamFarm跨平台兼容性:Windows与Linux行为差异
ArchiSteamFarm跨平台兼容性:Windows与Linux行为差异 你是否在Linux服务器部署ArchiSteamFarm时遇到过文件权限错误?或者
后端Tweepy跨平台兼容性:Windows、macOS与Linux行为差异
Tweepy跨平台兼容性:Windows、macOS与Linux行为差异 在使用Tweepy开发Twitter API应用时,你是否遇到过在Windows上运行
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考