标准化Native Java—统一GraalVM和OpenJDK

167 阅读28分钟

标准化的本地Java。统一GraalVM和OpenJDK

主要收获

  • 原生Java对于Java在不断发展的云计算世界中保持相关性至关重要。
  • 原生Java还不是一个已经解决的问题。
  • 开发生命周期也需要适应。
  • 通过Leyden项目实现标准化是原生Java成功的关键。
  • 原生Java需要被带入OpenJDK,以实现与其他正在进行的增强功能的共同演进。

本文是"原生编译提升Java"系列文章的一部分。你可以通过RSS订阅来接收这个系列的新文章的通知。

Java在企业应用中占主导地位。但在云中,Java比一些竞争对手更昂贵。使用GraalVM的本地编译使云中的Java更便宜。它创建的应用程序启动速度更快,使用的内存更少。

因此,原生编译为所有Java用户提出了许多问题。原生Java是如何改变开发的?我们什么时候应该切换到原生Java?什么时候不应该?我们应该使用什么框架来开发原生Java?本系列将为这些问题提供答案。

在不断发展的云计算世界中,原生Java对Java保持相关性至关重要

二十多年来,Java一直是企业应用和网络服务的首选语言。它有一个非常丰富的中间件、库和工具的生态系统,以及一个极其庞大的有经验的开发者社区。这使得它成为开发基于云的应用程序或将现有的Java应用程序转移到云的明显选择。然而,Java及其运行时的历史发展与今天的云计算要求之间存在着不匹配。因此,Java需要改变,以保持在云中的相关性!Native Java是这里最有希望的选择。让我们来解释一下传统Java和云计算之间的不匹配。

Java虚拟机(JVM)使用自适应的即时编译(JIT)来最大化长生命周期进程的吞吐量。峰值吞吐量一直是优先事项,内存被认为是廉价和可扩展的,而且启动时间也不太重要。

现在,像Kubernetes或OpenShift这样的基础设施,以及来自亚马逊、微软或谷歌的云产品,通过小型、廉价的容器进行扩展,只需少量的CPU和内存资源。因为这些容器更频繁地启动,固定的JVM启动成本在总运行时间中的比例变得更加显著。而Java应用仍然需要内存来进行JIT编译。那么,Java应用如何才能在容器中高效运行呢?

首先,Java应用程序越来越多地作为微服务运行,比它们所取代的单体执行更少的工作。这就是为什么它们的应用数据集更小,需要的内存更少。

其次,像QuarkusMicronaut这样的框架用离线转换来注入所需的服务,取代了启动时耗费资源的动态注入和转换。

第三,事实证明,修复漫长的JVM启动时间是非常困难的。最好的办法是完全避免在每次启动时预先计算相关的编译代码、元数据和常量对象数据。OpenJDK项目曾多次尝试这样做,最值得一提的是jaotc AOT编译器类数据共享。但是,jaotc已经被放弃了,而Class Data Sharing仍然是一项正在进行的工作。OpenJ9,一个不同于OpenJDK的Java实现,在AOT编译方面取得了一些引人注目的成功。但这并没有取得广泛的采用。

这些优化是困难的,因为JDK运行时也是底层硬件和操作系统的抽象和可移植层。预先计算有可能折合在构建时的假设,而这些假设在运行时不再有效。这个问题可以说是本地Java的最大挑战。这就是为什么之前的努力都集中在为同一个运行时预生成内容。尽管如此,Java的动态特性又造成了两个阻滞性问题。

首先,与其他AOT编译的语言相比,JVM和JDK保持了一个相对丰富的元数据模型。保留关于类结构和代码的信息允许在新的类被加载到运行时时对代码库进行编译和重新编译。这就是为什么对于一些应用来说,元数据的足迹相对于应用数据的足迹来说是很重要的。

第二个问题是,大多数预生成的代码和元数据链接必须是间接的,所以它可以为以后的变化而重写。其代价是双重的:将预生成的内容加载到内存中需要链接引用,而执行时使用间接访问和控制传输,这就降低了应用程序的速度。

Native Java提供了补救所有这些问题的一个重要的简化方法:不支持动态发展的应用程序。这种策略通过一个紧密链接的小型可执行文件提供了快速启动和小的足迹,所有的初始代码、数据和元数据都在启动时预先计算过。这的确是一个解决方案,但也伴随着需要理解的成本。它也没有解决构建时假设与运行时配置相匹配的问题。

原生Java还不是一个已解决的问题

乍一看,打包似乎是GraalVM Native和JVM的主要区别。JVM应用程序需要目标主机的Java运行时,包括Java二进制文件、各种JVM库、JDK运行时类,以及应用程序的JAR文件。

相比之下,GraalVM Native将所有这些JAR文件作为构建时的输入,并将JDK运行时类和一些提供JVM同等功能的额外Java代码扔进去。它将所有这些编译并链接到一个目标CPU和操作系统的本地二进制文件中。该二进制文件不需要类加载或JIT编译器。

这种完全的AOT编译有两个关键细节。首先,它需要对所有方法将被执行的类的完整知识。其次,它需要对目标CPU和操作系统的详细了解。这两个要求都带来了巨大的挑战。

封闭世界的假设

第一个要求被称为 "封闭世界假设"。这个封闭的世界应该只包括将实际运行的代码。关闭一个应用程序,首先要确定哪些类和方法被明确地从入口类的主方法中引用。这是对classpath上和JDK运行时中的所有字节码代码的相对直接的分析,尽管很复杂。不幸的是,仅仅通过名字追踪对类、方法和字段的引用是不够的。

链接- Java提供了类、方法和字段的间接链接,而没有明确提到它们的名字。实际被链接的东西可能取决于任意复杂的应用逻辑,对AOT分析是不透明的。像Class.forName() 这样的方法可以加载类,可能在运行时计算出一个名字。字段和方法可以使用反射或方法和var句柄来访问,这也可能是从计算出来的名字中得到的。一个聪明的分析可能会发现使用字符串字面的情况--但不是计算值。

字节码生成器--一个更糟糕的问题是,一个类可以通过基于输入数据或运行时环境的应用程序生成的字节码来定义。一个相关的问题是字节码的运行时转换。在最好的情况下,也许可以用AOT编译的等价物来修改其中的一些应用程序。然而,对于整类应用程序来说,这是不可能的。

装载器和模块委托--这也不仅仅是一个什么类型或代码的问题。即使知道哪些类可能被加载,应用逻辑也可以决定类的链接和可见性。再一次,这样的应用程序不能使用AOT编译。

资源和服务加载--在加载classpath资源时也会出现类似的困难。有可能识别classpath JARs中的资源,并将其放入本地二进制文件。但可能并不清楚哪些是真正要使用的,而且它们可能有一个计算过的名字。这一点特别重要,因为它影响到JDK运行时的服务提供模式,包括动态加载FileSystemProvider或LocaleProvider实现等功能。AOT编译器可以在编译时支持每个选项--但要以牺牲可执行文件的大小和内存占用为代价。

封闭世界的要求对开发者的影响

所有这些都意味着,开发者现在必须保证在构建时可以获得目标系统所需的所有代码。GraalVM最近对处理classpath资源的改变就是这种额外负担的一个例子。最初,在构建时缺少的类会中止构建。--allow-incomplete-classpath 选项可以解决这个问题,将构建时的配置错误变成运行时的错误。GraalVM最近把这个解决方法变成了默认行为。虽然这可以使应用程序顺利进入本地Java,但由此产生的运行时错误延长了编译-测试-异常-修复的周期。

还有就是封闭世界的 "第二日 "成本。监控工具通常在运行时检测类。理论上来说,这种检测可以在构建时进行。但这可能是困难的,甚至是不可能的,特别是对于当前运行时配置的特定代码或运行时的数据输入。对本地可执行文件的监控正在改善,但今天,开发人员不能依靠他们常用的工具和工作流程来监控本地部署。

构建时间与运行时的编译器配置

第二个要求是AOT编译的一个普遍问题。它要么针对目标环境的特定硬件和运行时能力,要么为目标环境范围生成虚无的代码。这增加了编译过程的复杂性:开发人员现在必须在构建时选择和配置编译器选项,而这些选项通常是在程序启动时默认的或配置的。

这不仅仅是一个简单的问题,例如针对Linux或硬件矢量支持,就像其他AOT编译的语言,如C或Go。它还需要提前配置特定的Java选择,如垃圾收集器或应用程序的语言环境。

之所以需要后一种选择,是因为将所有功能编译到生成的可执行文件中会使其比动态Java大得多、慢得多。JIT编译器为当前硬件和运行时环境的特定能力生成代码。相比之下,AOT编译器将不得不引入条件代码或生成多种变体的编译方法,以考虑到所有可能的情况。

构建时编译器配置要求对开发者的影响

AOT编译使持续集成(CI)更加复杂。想同时支持x86-64和arch64上的Linux部署?这使CI系统中的编译时间增加了一倍。还要为Windows和macOS上的开发者构建本地可执行文件?CI的编译时间又增加了一倍。所有这些都增加了直至拉动请求准备合并的时间。

而且在未来,情况只会越来越糟。测试一个不同的GC策略?那是一个完整的编译周期,而不是一个命令行开关。验证压缩引用对应用程序堆大小的影响?这又是一个完整的编译周期。

开发周期中的这些持续的剪纸剥夺了开发者的快乐。他们减缓了实验的速度,并使收集结果变得繁重。部署时间增加了,导致延迟将变化带入生产并恢复中断。

建立时间的初始化

实际上,在减少启动时间和AOT编译的足迹方面,还有第三个关键要求。本地可执行文件没有类加载器或JIT编译器,有一个更轻的虚拟机,类和代码的元数据更少。但AOT编译并不一定意味着更少的类或方法:在大多数情况下,JVM运行时已经只加载需要的代码。所以,AOT编译器不会大幅减少运行时的代码量或运行时间。这种减少需要一个更积极的策略,要么删除代码,要么用一个需要更少空间和时间来执行的等价物来替代。

AOT编译的最关键的创新正是这样做的。JVM在应用程序启动时的大部分工作是静态JDK运行时状态的初始化代码--其中大部分代码每次都重复得一模一样。在构建时计算这一状态并将其包含在本地可执行文件中可以极大地改善启动。这同样适用于中间件和应用程序状态。

因此,构建时的初始化通过在构建时进行避免了运行时的工作。但它也允许从本地可执行文件中删除构建时初始化代码,因为它只在构建时运行。在许多情况下,这会产生删除其他方法和类的连锁反应,因为它们只在启动时被调用。这种综合效应在GraalVM中最能减少启动时间和占用空间。

不幸的是,构建时的初始化和前两个要求一样面临着很多问题,甚至更多。大多数静态初始化都很简单,将一个字段设置为常数或一些确定的计算结果。这个值在任何硬件的任何运行时环境下都是一样的。

但有些静态字段取决于运行时的具体情况。静态初始化器可以执行任意的代码,包括依赖于初始化的精确顺序或时间、硬件或操作系统配置、甚至依赖于输入到应用程序的数据的代码。如果构建时的初始化是不可能的,那么运行时的初始化就会介入。这是一个按类决定的问题。只要有一个字段不能在构建时初始化,整个类就会进入运行时初始化。

静态字段的值也可能依赖于其他静态字段。因此,验证构建时初始化需要全局分析,而不是局部分析。

构建时初始化对开发者的影响

虽然构建时初始化是本地Java的一个超级能力,但它很容易不断给开发者带来复杂性。每个构建时初始化的静态字段都迫使构建时初始化像波浪一样在创建其值所需的可达类中移动。

来看一个例子:

class A implements IFoo {
  static final String name = "A";

  void foo() { … }
}

class B extends A {
  private static final Logger LOGGER = Logger.getLogger(B.class.getName());

  static int important_constant = calculate_constant();

  ...
}

class BTIExample {
  static final B myB = new B();
}

假设BTIExample 类在构建时被初始化。这就要求它的所有超类和实现的接口以及它的静态初始化器所引用的类都在构建时初始化:BTIExample, Object, B, A, IFoo, Logger, String, Logger’s 超类。而在calculate_constant() 方法和Logger.getLogger() ,以及B’s 构造函数(未显示)中使用的类,也需要在构建时兼容。

对这些类中的任何一个--或它们所依赖的类--的改变都可能导致无法在构建时初始化BTIExample 。构建时初始化可以被看作是通过类的依赖关系图传播的病毒性条件。看似无辜的错误修复、重构或库的升级,都会迫使类进入构建时初始化,以支持另一个类或限制一个类在运行时初始化。

但构建时初始化也会捕获太多关于构建环境的信息。一个例子是捕获一个代表构建机器的环境变量,并将其存储在构建时初始化类的静态字段中。这在现有的Java类中是常规做法,以确保对该值的一致看法,并避免重复获取它。但在本地Java中,这可能会带来安全风险。

开发周期也需要调整

原生Java不只是改变了应用程序的部署。整个开发过程都会发生变化。作为一个开发者,你不仅需要考虑采用新的框架,尽量减少反射和其他动态行为,并最大限度地利用构建时的初始化。你还需要检查你的构建和测试过程。

你需要考虑如何初始化你的库,因为一个库的构建时初始化可能需要(或被另一个库阻止!)。在构建时捕获的每一个状态都需要验证,以确保它不会捕获安全敏感的信息,并且对所有未来的执行都有效。

将工作转移到构建时间也意味着在本地和CI中构建你的应用程序将花费更长的时间。AOT编译需要有大量CPU和内存的机器来完全分析你的程序的每一个元素。Native Java明确地要求这种权衡--编译时间不会消失。它只是从运行时的JIT编译转移到构建时的AOT编译。它还需要更长的时间,因为构建时初始化的封闭世界分析和验证要比JIT编译复杂得多。

正如Quarkus上的文章所示,最好是在动态JVM上运行你的测试。这可以锻炼你的业务逻辑,运行单元和集成测试,并确保所有的部分在动态JVM上正确运行。

然而,测试本地可执行文件仍然是必不可少的。正如其他文章所显示的,由你的框架在GraalVM Native Image的帮助下构建的封闭世界版本的应用程序,可能会缺少一些部件。而本地Java并不保证与动态JVM的bug-for-bug兼容。

现在,单元测试通常不能针对独立的本地可执行文件运行,因为所需的方法可能没有被注册为反射,或者已经从本地代码中删除。但是,在本地可执行文件中包括单元测试,使额外的方法保持活力,增加了文件大小和安全攻击面。毕竟,没有人把他们的单元测试放在动态JVM上。

因此,测试必须同时在动态JVM和本地可执行文件上进行。你在日常开发中会怎么做?只是在动态JVM上测试?在开启拉取请求之前,先编译成本地程序?这里的变化会影响你的内循环速度。

说到速度,那些较长的编译时间将影响你的CI管道的运行速度。更长的构建和更长的测试周期是否会改变你的DevOps指标,如平均恢复时间(MTTR)?也许吧。它是否会因为更强大的编译机器而增加你的CI成本?也许吧。它是否使现有的应用性能监控(APM)工具(如Datadog)和其他仪器代理的使用变得复杂?当然会。

这里有一些权衡。将工作转移到构建时间(以及延伸到开发时间)是一种选择:它提供了很好的运行时间优势,但成本不会消失。采用本地Java需要大量的改变。这些好处虽然令人印象深刻,但并不值得每一个用例都这样做。请认真思考,准备不仅对你的软件,而且对你的工作方式做出改变。

通过莱顿项目实现标准化是本地Java成功的关键

Java的超级力量之一是相当无聊的东西:标准化。它是整个Java生态系统的稳定基石。标准化确保了无论JDK有多少不同的实现,在概念上都有一个Java语言和一个Java运行时模型作为目标。同样的应用程序可以在任何JDK上运行--无论它是OpenJDK的衍生产品还是像Eclipse OpenJ9这样的独立实现。这就为开发者建立了信心:这个平台 "就是能用"。再加上Java长期以来对向后兼容性的承诺--旧的Java 1.4 JAR文件今天仍然可以在Java 18上运行--我们看到框架、库和应用程序的生态系统蓬勃发展。Java持续的、谨慎的、有标准支持的发展是这种增长的关键。

Java的标准化做出了一个承诺:你的应用程序将持续运行。在这个生态系统中投资是值得的。库可以继续工作,而不需要为每个版本做重大改变。框架不需要在每次发布时都重新进行投资以重新创建自己。应用程序开发人员可以专注于增加商业价值,而不是不断适应不兼容的变化。不是所有的编程语言生态系统都能提供这些保证。而随着Java版本的快速和频繁发布,这些保证对于Java的持续发展而不失去你这个开发者来说是至关重要的。

这些承诺听起来不错。但是,"标准化 "在这里究竟是如何运作的呢?当我们谈论 "Java标准 "时,我们实际上是在谈论Java语言规范(JLS)和JVM规范(JVMS),以及核心JDK运行时类的全面Javadoc规范。这两个规范是Java保证的核心,因为它们,而不是一个实现,定义了 "Java "的含义。它们足够详细地定义了语言和运行时行为,因此实现者可以创建独立的JVM和Java源编译器的实现。相比之下,许多语言都有一个标准,但更多的是把它当作他们的实现的文档,而不是它必须做什么的指令。

每个JDK版本都是基于对规范的相应更新。这种定期的修订创造了两个关键的 Java 产品:一个是对特定版本的 Java 和 JDK 的明确行为的明确声明,另一个是对不同版本之间的行为差异的明显说明。这种明确性和可见性对于任何实施和维护 Java 应用程序、中间件和库的人来说都是至关重要的。

对Java规范的更新甚至比新的OpenJDK版本更重要。它需要令人难以置信的谨慎,即每个新功能都能连贯地发展规范而不破坏现有的应用程序。详尽的测试套件检查实现与规范的一致性。重要的是,这些测试是基于规范的,而不是基于实现的。

Native Java一直存在于这个规范的过程之外,所以今天的生态系统是分裂的。这不是故意的,但如果原生Java的发展继续独立于Java平台的其他部分,这种分裂将继续存在。现在不可避免的是,框架开发者和库的作者必须努力工作,以掩盖这种分裂。他们这样做是为了只依赖在原生Java中工作的特性,而放弃大多数动态特性的使用,如反射、MethodHandles,甚至是动态类加载。这实际上形成了动态Java的一个子集。再加上功能语义的改变,如最终处理程序、信号处理程序或类初始化,结果是动态虚拟机上的Java和本地Java之间的分歧越来越大。

而且不能保证今天用原生Java构建的应用程序在下一个原生Java版本中会以同样的方式构建和表现。主要的行为--如前面讨论的--allow-incomplete-classpath ,以及从构建时到运行时初始化作为默认值的变化--在不同的版本中翻转其默认值。这些选择是实际的和务实的决定,以牺牲当前用户的利益来增加采用率。这些决定本质上并不坏。但它们破坏了本地Java生态系统的稳定性,因为它们削弱了Java标准化的承诺。

许多行为--尤其是像构建时初始化这样的关键功能--对于本地Java来说仍然处于变化之中。而这很好!即使是动态的Java也在变化!原生Java缺少的是对什么应该工作,什么不应该工作,以及如何变化的明确声明。如果原生Java的边界只是有很宽的范围,那就可以了。但我们并不真正知道边界在哪里。而且它正在以未知的方式发生变化。

这种缺乏标准化的情况不仅仅是框架和库作者的问题,因为这些看似务实的变化影响了原生Java的稳定性和保证。当应用程序开发人员需要为每个版本验证他们的应用程序时,他们会感到这种痛苦--特别是其资源使用情况。现在动态Java也需要对新版本进行一些验证。但这通常需要一个高度具体的用户响应,并且只带来边际的性能成本。原生Java可能需要持续的调整工作或遭受部署成本的增加--这是开发人员在每次更新时都要支付的税。

莱顿项目的任务是解决Java的 "启动时间慢,达到性能峰值的时间慢,以及占用空间大",正如马克-莱因霍尔德在2020年4月所说。最初,它的任务主要是将 "静态图像的概念引入Java平台和JDK"。现在,马克最近的帖子重申了对这些相同痛点的关注,同时认识到在完全动态的JVM和本地Java之间有一个 "约束谱"。Leyden的目标是探索这个频谱,并确定和量化介于完全AOT编译的本地镜像和完全动态的JIT编译的运行时之间的中间位置如何能够提供对足迹和启动时间的增量改进,同时仍然允许用户选择保留他们的应用程序所需的一些动态行为。

Leyden将扩展现有的Java规范,以支持这个频谱上的不同点,包括最终实现本地Java所要求的封闭世界的约束。本机Java的所有关键属性--封闭世界的假设、构建时编译和构建时初始化--都将被探索,并被赋予精确的、定义的语义,并被纳入标准化进程。虽然Leyden项目最近才创建了它的邮件列表,但在整个Java生态系统和GraalVM社区中已经围绕这些主题进行了探索。

通过Project Leyden将原生Java带入现有的Java标准化进程,将提供同样坚实的基础,使传统的Java、其生态系统、库和中间件得以蓬勃发展。标准化是对原生和动态Java之间不断增长的技术债务的补救措施。Leyden所概述的渐进式路径将有助于减轻所有开发者的迁移痛苦。

原生Java需要被带入OpenJDK,以实现不断增强的共同进化

一系列的文章已经证明了原生Java的好处。它对Java在云计算中保持相关性至关重要。这对Java社区有很大的好处。然而,原生Java需要大规模改变应用程序的开发和部署方式。由于它存在于核心平台和标准化过程的稳定性保障之外,它有可能分化Java的定义。

同时,动态Java在OpenJDK中继续发展。有一些重大项目正在进行中。Loom增加了轻量级线程和结构化并发,Valhalla引入了 "像类一样编码,像int一样工作 "的类型,Panama改善了Java与非Java代码的工作方式,Amber发布了一些小的功能,使开发者更有生产力。

这些项目为Java带来了新的功能,通过进一步使用MethodHandles、invokedynamic和运行时代码生成,增加了平台的动态性质。它们被设计成一个连贯的整体。原生Java还不是这个连贯整体的一部分。通过Project Leyden将原生Java引入OpenJDK,使动态Java、其新特性和原生Java共同进化。

这种共同进化对于本地Java的长期成功至关重要。没有它,原生Java将永远落后于动态Java。最近的一个例子是,当使用注解时,原生Java中的Java记录的JSON序列化被破坏,这就是这种落后的支持。但是GraalVM目前也错过了影响新Java功能设计的机会。对规范进行细微的调整和适应,可能是一个简单而高效的本地实现、一个在内存使用和编译时间上都很昂贵的实现,以及一个根本无法在本地Java中实现的东西之间的区别。

到目前为止,原生Java在追踪平台方面非常成功。但它只是通过使用Substitutions来适应Java平台和核心JDK库而获得了成功:这些是辅助性的Java类,可以修改类来与本地Java一起工作。但它们有可能破坏它们所修改的代码的不变性。当原始代码不能被改变时,替换是一个非常实用的解决方案。但它们不能扩展。而且它们与其他语言中的 "猴子补丁 "解决方案存在同样的问题--强大但危险。它们会因为它们所修改的类的变化而变得不正确。最近的一个例子是一个JDK运行时替换,在JDK改变后变得无效了。幸好Quarkus团队发现并解决了这个问题。

将本地Java引入OpenJDK提供了一个 "做得更好 "的机会,修改Java平台而不是使用像Substitution这样的技巧--不仅直接更新JDK类库,而且可能还更新编程模型。它确保了现有的OpenJDK项目在开发功能时考察整个平台--包括动态和本地用例。它还能确保应用程序受益于本地Java在平台中成为一流的公民,为两种部署模式带来更好的解决方案。

总结

在过去的20年里,Java一直是占主导地位的企业语言,它建立在其标准化过程所提供的稳定性之上。语言、运行时和库之间的共同进化,所有这些历史性地追随摩尔定律带来的快速硬件改进,简化了开发人员的工作,因为他们努力从他们的应用程序中获取最大的性能。

Native Java已经崛起,使Java适应资源受限的云部署。但它现在站在一个十字路口。它可以继续自我发展,并冒着每次发布都与动态Java分离的风险,直到它有效地成为一个独立的实体,有自己的利益相关者、社区和图书馆。或者,原生Java可以加入到Java标准的旗帜下,并与平台的其他部分一起发展,成为有利于所有用例的东西。这将为原生Java的功能带来稳定性,并允许出现共同的部署实践。

随着Leyden项目开始成形,我们希望它能成为一个地方,在那里,动态和原生Java会聚在一起,为所有Java用户带来快速启动和更小的足迹的共同未来。今天,GraalVM仍然是原生Java的务实之选。在不远的将来,一个单一的Java规范将决定你的程序在从动态到原生的任何地方运行时意味着什么,而与底层实现无关。