Java 主导着企业级应用。但在云计算领域,采用 Java 的成本比它的一些竞争对手更高。原生编译降低了在云端采用 Java 的成本:用它创建的应用程序启动速度更快,使用的内存更少。
那么,Java 用户的问题来了:原生 Java 是如何改变开发方式的?我们在什么情况下应该切换到原生 Java?什么情况下又不应该切换?我们应该使用什么框架?本系列文章将回答这些问题。
本文是“Native Compilations Boosts Java”系列文章的一部分。你可以通过订阅RSS接收更新通知。
GraalVM 自三年前发布以来,引发了一场 Java 开发革命。GraalVM 最常被讨论的特性之一是它的原生镜像是基于提前(AOT)编译技术。它提升了原生应用程序的运行时性能,同时保持开发人员熟悉的生产力方式和 Java 生态系统工具不变。
Java 平台最强大、最有趣的一个地方是 Java 虚拟机(JVM)执行代码的方式,它提供了出色的峰值性能。
在第一次运行应用程序时,JVM 会解释代码并收集剖析信息。尽管 JVM 解释器的性能很好,但还是不如运行已编译的代码快。这就是为什么 Oracle 的 JVM(HotSpot)也包含了即时(JIT)编译器,它可以在程序执行时将应用程序代码编译成机器码。因此,如果你的代码经过“预热”——被频繁执行,就会被 C1 编译器编译成机器码。然后,如果它们仍然执行得很频繁,并且达到某些阈值,就会被顶层的 JIT 编译器(C2 或 Graal 编译器)编译。顶层编译器会根据哪些代码分支执行得最频繁、循环执行的频率以及多态代码中使用了哪些类型来执行优化。
有时候,编译器也会进行推测性优化。例如,JVM 会根据收集到的剖析信息生成优化、编译过的方法。但是,由于 JVM 是动态执行代码的——如果它所做的假设变成无效的——JVM 将进行反优化:它将忽略已编译的代码并恢复到解释模式。正是这种灵活性让 JVM 变得如此强大:从快速执行代码开始,利用优化编译器来优化频繁执行的代码,并通过推测进行更积极的优化。
乍一看,这似乎是运行应用程序的一种理想的方法。然而,就像其他大多数事情一样,即使是这种方法也存在权衡,也需要付出成本。JVM 在执行某些操作(例如验证代码、加载类、动态编译和收集剖析信息)时,它需要进行复杂的计算,需要消耗大量的 CPU 时间。除了这个成本之外,JVM 还需要相当大的内存来存储剖析信息,在启动时也需要相当可观的时间和内存。随着许多公司将应用程序部署到云端,这些成本变得越来越重要,因为启动时间和内存直接影响部署应用程序的成本。那么,有没有一种方法既能减少启动时间和内存使用,又能保持我们都喜欢的 Java 生产力、库和工具呢?
答案是“是”,这就是 GraalVM 原生镜像所要做的事情。
10 年前,GraalVM 是 Oracle Labs 的一个研究项目。Oracle Labs 是 Oracle 的一个研究和开发分支,主要研究编程语言和虚拟机、机器学习和安全、图形处理等领域。GraalVM 就是一个很好的例子——它以多年的研究和 100 多篇发表的学术论文为基础。
这个项目的核心是 Graal 编译器——一个全新的、高度优化的现代编译器。由于采用了多种高级优化手段,在许多情况下,它生成的代码比 C2 编译器更好。其中的一种优化是部分转义分析:如果分支中的对象没有转义编译单元,就通过标量替换移除不必要的堆对象分配,Graal 编译器会确保分支中有转义的对象一定存在于堆中。
这种方法减少了应用程序的内存占用,因为堆上的对象更少了。它还可以降低 CPU 负载,因为垃圾回收更少了。此外,GraalVM 的高级推测功能利用动态运行时反馈生成更快的机器码。通过推测程序的某些部分在程序运行期间不会被执行,让代码执行变得更加高效。
你可能会惊讶地发现,GraalVM 编译器的大部分代码是用 Java 写的。如果你看一下 GraalVM 的核心 GitHub 存储库,你会看到超过 90%的代码是用 Java 写的,这再次证明了 Java 是多么的强大和通用。
Graal 编译器还是一种提前(AOT)编译器,可以生成原生可执行文件。既然 Java 是动态的,那么编译器究竟是如何做到的呢?
在 JIT 模式下,编译和执行同时发生,但在 AOT 模式下,编译器在构建期间(即执行之前)就完成了所有的编译。这里的主要思想是将所有“繁重的工作”——昂贵的计算部分——转移到了构建时,这样就可以一次性完成编译,然后生成的可执行文件在运行时就可以快速启动,并在一开始就做好准备,因为所有的东西都是预先计算和预先编译的。
GraalVM 的“native-image”工具接受 Java 字节码作为输入,并输出一个原生可执行文件。这个工具会通过假设对字节码执行静态分析。在分析过程中,工具会找出被应用程序使用的代码,并消除不必要的代码。
以下三个关键概念可以帮你更好地理解原生镜像的生成过程:
原生镜像构建过程
在分析完成后,GraalVM 会将所有可触及的代码编译成特定于平台的原生可执行文件。可执行文件本身功能完备,不需要 JVM 来运行。因此,你得到的是 Java 应用程序的精简而快速的原生可执行版本:它具备完全相同的功能,但只包含必要的代码及其所需的依赖项。
但是,谁来负责处理内存管理和线程调度等问题呢?原生镜像中还包含了一个 Substrate VM——一个提供运行时组件(比如垃圾回收器和线程调度器)的精简 VM 实现。就像 GraalVM 编译器一样,Substrate VM 是用 Java 开发的,然后用 GraalVM 原生镜像的 AOT 编译技术将其编译成原生代码!
得益于 AOT 编译和堆快照,原生镜像为你的 Java 应用程序提供了一种全新的性能。接下来让我们来仔细看一看。
你可能听说过原生镜像生成的可执行文件具有非常好的启动性能,那么究竟是怎样的性能呢?
即时启动。在 JVM 上运行时,代码需要经过验证、解释,然后(在预热之后)最终被编译,与此不同,原生可执行文件从一开始就带有优化的机器码。我喜欢用即时性能这个词来形容它——应用程序可以在启动的第一毫秒内执行有意义的任务,不需要任何分析或编译开销。
JIT | AOT |
操作系统加载JVM可执行文件 | 操作系统加载带有堆快照的可执行文件 |
VM从文件系统加载类 | 应用程序立即用优化的机器码启动 |
验证字节码 | |
开始解释字节码 | |
运行静态初始化器 | |
第一层编译(C1) | |
收集分析指标 | |
……(过了一段时间) | |
第二层编译(C2/Graal编译器) | |
最后执行优化后的机器码 |
JIT 和原生镜像的启动过程对比
内存效率。原生可执行文件不需要 JVM 及其 JIT 编译器,也不需要用于代码、分析文件数据和字节码缓存的内存。它只需要用于可执行文件和应用程序数据的内存。这里有一个例子:
JIT 和原生镜像使用的 CPU 和内存对比
上图显示了 Web 服务器在 JVM 上(左)和作为原生可执行文件(右)的运行时行为。蓝绿色的线表示使用了多少内存:在 JIT 模式下是 200MB,而原生可执行文件是 40MB。红线表示 CPU 活动:JVM 在热身 JIT 活动期间使用了大量 CPU,而原生可执行程序几乎不使用 CPU,因为所有昂贵的编译操作都发生在构建时。这种快速且资源高效的运行时行为让原生镜像成为一种很棒的部署模型,在更短的时间内使用更少的资源,可以显著降低成本——适用于微服务、无服务器和云端工作负载。
文件体积。原生可执行文件只包含必需的代码。这就是为什么它比应用程序代码、库和 JVM 的总和要小得多。在某些场景中,例如在资源受限的环境中,应用程序的体积可能是一个很重要因素。UPX等工具可以进一步压缩原生可执行文件的体积。
那么峰值性能如何呢?既然一切都是提前编译的,那么原生镜像如何在运行时优化峰值吞吐量?
我们正在努力确保原生镜像提供良好的峰值性能和快速启动。已经有一些方法可以提高原生可执行文件的峰值性能:
Renaissance 和 DaCapo 测试基准
有了这些选项,就可以利用原生镜像最大化应用程序的各个性能维度:启动时间、内存效率和峰值吞吐量。
由于原生镜像是执行 Java 应用程序的一种全新的方式,所以有几个地方需要注意。
有人说 GraalVM 原生镜像不支持反射,这不是真的。
原生镜像会基于一些假设进行静态分析。因此,要启用 Java 的动态特性(如反射),需要进行额外的配置。在对 Java 应用程序进行静态分析时,它会尝试检测和处理反射 API 调用。然而,通常情况下,这种自动分析是不够的,而且在运行时通过反射访问的程序元素必须通过配置来指定。你可以手动创建这些配置,也可以利用原生镜像跟踪代理。当程序运行在 JVM 上,代理会跟踪动态特性,并生成配置文件。原生镜像工具使用这个文件来包含调用了反射 API 的部分。虽然代理可用于获得初始的配置,但我们还是建议在必要时通过手动检查来完成这个过程。
在使用 Java 本地接口(JNI)、动态代理对象和类路径资源时,可能需要类似的配置。你也可以使用这个跟踪代理来获得这些配置。
最后,你可以使用GraalVM Dashboard,一个可视化原生镜像编译的 Web 应用程序,可以用它来发现原生可执行文件中包含了哪些包、类和方法,还可以识别哪些对象在堆中占用了最大的空间。
原生镜像将改变云端部署,它对应用程序的资源消耗产生了很大的影响。我们知道,原生镜像生成的原生可执行文件启动快,需要的内存少。对于云端部署来说,这到底意味着什么? GraalVM 如何帮助最小化 Java 容器镜像?
运行原生镜像生成的应用程序不需要 JVM:它们可以是自包含的,包括应用程序执行所需的所有东西。这意味着你可以将应用程序放入一个苗条的 Docker 镜像中,并且它本身将具备完整的功能。镜像大小取决于应用程序要完成的任务以及它包含哪些依赖项。一个使用 Java 微服务框架构建的“Hello, World!”应用程序大约有 20MB。
你还可以用原生镜像构建全静态或部分静态的可执行文件。部分静态的原生可执行文件被静态链接到所有的库,除了容器镜像提供的“libc’。你可以用 distroless 容器镜像进行轻量级部署。disroless 镜像只包含运行应用程序所需的库,不包含 shell、包管理器和其他程序。举个例子,你的 Dockerfile 可能是这样的:
```FROM gcr.io/distroless/baseCOPY build/native-image/application appENTRYPOINT ["/app"]```
复制代码
对于一个完全自主的部署(甚至不需要容器镜像提供的 libc)来说,你可以静态地将应用程序链接到“musl-libc”。你可以把它放在“FROM scratch”的 Docker 镜像中,因为它是完全自包含的。
到目前为止,我们已经讨论了如何最大化原生镜像生成的应用程序的性能,并考虑了在构建过程中可以应用的一些有用的技巧。除此之外,我们还可以做些什么来最大限度地利用应用程序呢?是的,有很多。
为了简化原生可执行文件的构建、测试和运行,可以使用 GraalVM 团队提供的 Maven 和 Gradle插件。此外,这些插件支持原生 JUnit 5 测试,并且是与 JUnit、Micronaut 和 Spring 团队合作开发的,充分彰显了 JVM 生态系统的协作关系。
要在你的 GitHub Action 工作流中设置 GraalVM 原生镜像,可以使用GraalVM的GitHub Action。可配置的 Action 支持多个 GraalVM 版本和开发者构建,并可设置好完整的 GraalVM 和特定组件。
现在我们来说一下工具。在开发 Java 应用程序时,你可以使用常规的工具。你可以使用任意的 IDE 和 JDK(包括 GraalVM JDK)来构建、测试和调试应用程序,然后使用 GraalVM 原生镜像工具来进行最终的原生编译。根据应用程序复杂程度的不同,原生镜像编译可能需要一些时间,因此建议将其作为最后一个步骤。不过,我们正在为原生镜像开发一种快速开发模式,它将跳过一些优化步骤,以此来缩短编译时间。
尽管你可以基于 JVM 开发应用程序,然后在稍后的开发过程中构建原生可执行文件,但我们收到了很多来自社区的请求,要求改进构建时间和资源使用。在过去的几个版本中,我们针对这个问题做了很多工作。在最新发布的 GraalVM(22.0)中,你可以在大约13.8秒内将一个 hello-world Java 应用程序生成一个原生可执行文件,可执行文件的大小大约为 5MB。我们还减少了大约 10%的内存使用。
要调试原生镜像生成的可执行文件,可以在命令行中使用“gdb”(在 Linux 和 macOS 上),或者使用 GraalVM 的 VS Code 扩展。这个教程提供了使用说明。
要监控原生可执行文件的性能,请使用 JDK Flight Recorder。对原生镜像的全面支持仍在开发当中,不过你已经可以用它来观察自定义事件和系统事件。
如果要进行额外的性能监控,可以生成原生可执行文件的堆转储,然后使用 VisualVM 等工具对其进行分析。这是 GraalVM Enterprise 的一个特性。
如果没有 Java 框架的支持,开发行业级应用程序将是非常困难的。幸运的是,现在有很多可用的框架。所有主流的框架都支持原生镜像(按字母顺序列出):Gluon Substrate、Helidon、Micronaut、Quarkus 和 Spring Boot。所有这些框架都利用 GraalVM 原生镜像显著改善了应用程序的启动时间和资源使用,成为高效的云端部署工具。本系列的后续文章将介绍框架是如何使用 GraalVM 原生镜像的。
自从第一次公开发布以来,原生镜像已经取得了巨大的进步。它被 Java 框架广泛采用,云供应商也将原生镜像作为 Java 运行时,许多库也都使用了原生镜像。我们对开发者的体验做了一些改变,我们去年的研究表明,70%使用 GraalVM 的开发者已经在用它来构建和发行原生可执行文件。
对于原生镜像的新特性和改进,我们有很多想法,包括:
作者简介:
Alina Yurenko 是 Oracle Labs(Oracle 的一个研究和开发部门)的 GraalVM 开发者布道师。她有开发者关系的工作经验,现在加入了 GraalVM 团队,与它的全球社区一起工作。