Skip to content

JEP 238: Multi-Release JAR Files | 多版本 JAR 文件

摘要

扩展 JAR 文件格式,允许多个特定于 Java 版本的类文件在一个单独的存档中共存。

目标

  1. 增强 Java Archive Tool (jar),使其能够创建多版本 JAR 文件。
  2. 在 JRE 中实现多版本 JAR 文件,包括在标准类加载器和 JarFile API 中提供支持。
  3. 增强其他关键工具(如 javacjavapjdeps 等),以解释多版本 JAR 文件。
  4. 支持多版本模块化JAR 文件,以实现 1 到 3 的目标。
  5. 保持性能:使用多版本 JAR 文件的工具和组件的性能不能受到重大影响。特别是在访问普通(非多版本)JAR 文件时,性能不能降低。

动机

第三方库和框架通常支持一系列 Java 平台版本,通常会往后几个版本。因此,它们通常没有利用较新版本中可用的语言或 API 功能的优势,因为很难表示条件平台依赖关系,通常需要使用反射,或者为不同的平台版本分发不同的库文件。

这导致了库和框架不愿意使用新功能的不利因素,反过来也使得用户不愿意升级到新的 JDK 版本,这是一个恶性循环,阻碍了采用新版本的推广,对每个人都是不利的。

此外,一些库和框架还使用 JDK 的内部 API,当模块边界得到严格执行时,这些 API 将在 Java 9 中变得不可访问。当存在公开的、受支持的 API 替代品来替代这些内部 API 时,这也会导致不愿意支持新的平台版本。

描述

一个 JAR 文件有一个内容根目录,其中包含类和资源,以及一个包含有关 JAR 的元数据的 META-INF 目录。通过向特定文件组添加一些版本信息,JAR 格式可以以兼容的方式编码多个针对不同目标 Java 平台版本的库的版本。

多版本 JAR("MRJAR")将包含主属性:

Multi-Release: true

在 JAR 的 MANIFEST.MF 的主节中声明。该属性名称还声明为常量 java.util.jar.Attributes.MULTI_RELEASE 。与其他主属性一样,MANIFEST.MF 中声明的名称不区分大小写。该值也不区分大小写,但不能有前导或尾随空格(这样的限制有助于确保满足性能目标)。

多版本 JAR("MRJAR")将包含附加的目录,用于特定于特定 Java 平台版本的类和资源。一个典型库的 JAR 文件可能如下所示:

jar root
  - A.class
  - B.class
  - C.class
  - D.class

假设存在可以利用 Java 9 功能的 A 和 B 的替代版本。我们可以将它们捆绑到一个单独的 JAR 中,如下所示:

jar root
  - A.class
  - B.class
  - C.class
  - D.class
  - META-INF
     - versions
        - 9
           - A.class
           - B.class

在不支持 MRJAR 的 JDK 中,只有根目录中的类和资源可见,并且这两个打包方式是无法区分的。在支持 MRJAR 的 JDK 中,将忽略与任何较新 Java 平台版本对应的目录;它会首先在当前运行的主要 Java 平台版本相对应的 Java 平台特定目录中搜索类和资源,然后搜索低版本,最后在 JAR 根目录中搜索。在 Java 9 JDK 上,就好像存在一个特定于 JAR 的类路径,首先包含版本 9 的文件,然后是 JAR 根目录;在 Java 8 JDK 上,这个类路径将只包含 JAR 根目录。

假设未来发布了 Java 10 并且更新了 A 以利用 Java 10 功能。然后,MRJAR 可能看起来像这样:

jar root
  - A.class
  - B.class
  - C.class
  - D.class
  - META-INF
     - versions
        - 9
           - A.class
           - B.class
        - 10
           - A.class

这种方案使得在较新的 Java 平台版本中设计的类的版本可以覆盖先前 Java 平台版本中设计的同一类的版本。例如,在运行于支持 MRJAR 的 Java 9 JDK 上时,它会看到 A 和 B 的 9 特定版本以及 C 和 D 的通用版本;在将来支持 MRJAR 的 Java 10 JDK 上,它将会看到 A 的 10 特定版本和 B 的 9 特定版本;在旧的或不支持 MRJAR 的 JDK 上,它只能看到所有根版本。

JAR 元数据(例如在 MANIFEST.MF 文件和 META-INF/services 目录中找到的元数据)无需进行版本控制。MRJAR 本质上是一个发布单元,因此它只有一个发布版本(与通过 Maven Central 分发的普通 JAR 没有什么不同),即使在内部它包含了多个库实现版本,供不同的 Java 平台版本使用。每个库的版本应该提供相同的 API;需要进一步研究是否应该严格向后兼容,其中 API 完全相同(字节码签名相等),或者是否可以在不一定启用新增强功能的情况下放宽一些程度,以模糊一个发布单元的概念。至少,这可能意味着在发布特定目录中存在的公共类也应该存在于根目录中,尽管它不需要存在于早期的发布目录中。运行时系统不会验证此属性,但工具可以和应该检测这种 API 兼容性问题,库方法也可以提供执行此类验证的方法(例如在 java.util.jar.JarFile 上)。

最终,这种机制使得库和框架开发人员能够将 API 的使用与特定 Java 平台版本分离,而不需要要求所有用户迁移到该版本。库和框架维护者可以逐步迁移到并支持新功能,同时还携带着对旧功能的支持,打破了鸡生蛋的循环,使得一个库可以“准备好 Java 9”,而不必实际需要 Java 9。

细节

JDK 的以下组件将被修改以支持多版本 JAR 文件。

  • 基于 JAR 的 URLClassLoader 必须按照运行 Java 平台版本指示的方式读取选定版本的类文件。引入 Project Jigsaw 的模块化类加载器将需要类似的修改。
  • jar URL 方案的协议处理程序和 java.util.jar.JarFile 类必须从多版本 JAR 中选择适当的类版本。
  • Java 编译器(javac)通过底层的 JavacFileManagerZipFileSystem API,必须按照 -target-release 命令行选项指定的方式读取选定版本的类文件。工具 javahschemagenwsgen 将利用底层的 JavacFileManagerZipFileSystem 的变化。
  • Java Archive 工具(jar)将被增强,使其可以创建多版本 JAR 文件。
  • JAR 打包工具(pack200 / unpack200)必须更新(参见 JDK-8066272 )。
  • javap 工具必须更新以启用选择版本化的类文件。
  • jdeps 工具需要进行修改,以显示版本信息并遵循版本特定的类文件依赖关系。
  • JAR 规范必须进行修订,以描述多版本 JAR 文件格式和任何相关变化(例如可能添加到清单中的内容)。

兼容性

默认情况下,java.util.jar.JarFilejar 方案协议处理程序的行为将保持不变。必须选择功能才能构造指向 MRJAR 的 JarFile,以选择条目的版本。同样,在 jar URL 中也需要选择功能(详见下一节)。

由运行时为类加载所创建的 JarFile 实例会选择加入并创建按照运行的 Java 平台版本选择条目的实例。这种 JarFile 实例被称为运行时版本化。

类加载器资源

由类加载器生成的资源 URL,用于识别 MRJAR 中的资源,将直接引用版本化的条目(如果存在)。例如,对于版本化的资源 foo/baz/resource.txt

java
URL r = loader.getResource("foo/baz/resource.txt");

URL 'r' 可能是:

jar:file:/mrjar.jar!/META-INF/versions/9/foo/baz/resource.txt

而不是:

jar:file:/mrjar.jar!/foo/baz/resource.txt

这种方式被认为是最少干扰的选项。更改资源 URL 的结构并不是没有风险的(例如新方案或添加的片段)。旧代码可能会直接处理 URL 字符,而不是解析 URL 并正确提取组件。虽然这种 URL 过程是不正确的,但考虑到不破坏这样的代码,这被认为是首选项。

模块化的多版本 JAR 文件

模块化的多版本 JAR 文件是一个具有模块描述符 module-info.class 的多版本 JAR 文件,它与模块化 JAR 文件一样,在顶层根目录上有一个模块描述符(参见 JEP 261 的“打包:模块化 JAR 文件”部分)。此外,模块化描述符也可以存在于版本化的区域中。这些版本化的描述符必须与根模块描述符完全相同,但有两个例外:

  • 版本化的描述符可以具有不同的非传递性的 java.*jdk.* 模块的 requires 子句;

  • 版本化的描述符可以具有不同的 uses 子句,即使是在 java.*jdk.* 模块之外定义的服务类型。

这里的原因是这些是实现细节而不是模块的 API 表面的一部分,并且随着 JDK 本身的演进,人们可能想要更改它们。不允许更改非公共的非 JDK 模块的requires。如果有必要,则需要新版本的模块(至少增加其版本号),这是一种不同的兼容性问题,超出了 MRJAR 的范围。

多版本模块化不需要在位于根目录的位置上具有模块描述符。在这方面,模块描述符将与任何其他类或资源文件一样对待。例如,这可以确保在根目录中只存在 Java 8 的版本化类,而在 9 版本化区域中存在 Java 9 的版本化类(包括模块描述符)。

类路径和模块路径

模块化的 JAR 文件可以构建成在 Java 8 运行时的类路径、Java 9 运行时的类路径或 Java 9 运行时的模块路径上正确工作。对于多版本的模块化 JAR 文件来说情况也是一样的(除了module-info.class之外,其他类可能也针对 Java 9 平台进行编译)。

如果模块描述符没有将某些包声明为导出的,则属于该模块的公共类对于该模块来说是私有的。因此,当相应的 JAR 文件放置在模块路径上时,这些类将无法访问。但是,如果将 JAR 文件放置在类路径上,则这些类将是可访问的。这是支持类路径和模块路径的一个不幸结果。

因此,当将多版本 JAR 文件放置在类路径上时,与将其放置在模块路径上相比,多版本 JAR 文件的公共 API 可能会有所不同。通常,jar 工具在构建多版本 JAR 文件时将尽最大努力检测公共 API 中的任何变化。然而,在构建模块化的多版本 JAR 文件时,建议 jar 工具在将 JAR 文件放置在类路径上时输出警告,如果公共 API 的差异是由于模块私有类在类路径上可访问而导致的。

多版本 JAR 和引导加载器

引导加载器不支持多版本 JAR(例如,当使用-Xbootclasspath/a 选项声明多版本 JAR 文件时)。这种支持会对引导加载器的实现造成复杂化,而实际上它被认为是一种罕见的用例。

替代方案

一种常见的方法是使用静态反射检查来确定 API 功能是否存在,并相应地选择适当的类来依赖于该功能。反射成本发生在类初始化时,而不是每次使用依赖功能时都发生。可以选择使用较低版本的源代码和目标代码标志编译 Java 平台版本。通常,这种方法会使用像Animal Sniffer这样的工具来检查 API 不兼容性,除了强制 API 兼容性外,还可以使用注释来说明它是否依赖于较新的 Java 平台版本。这种方法存在一些限制:

  1. 需要仔细维护反射检查。
  2. 不可能利用更新的语言特性。
  3. 如果平台版本的 API 功能被删除(例如,内部 API),那么依赖的代码将无法编译。

"Fat"类文件被考虑过,其中一个类可以针对不同的 Java 平台版本具有一个或多个方法。从语言和运行时特性的角度来看,这被认为过于复杂,无法支持此类方法声明和动态选择。

由于需要保持二进制兼容性的需要,无法使用方法句柄(invokedynamic)。

风险和假设

预计 MRJAR 的生产主要与现有的流行构建工具兼容,因此支持这些工具的 IDE 可能通过增强来改善开发者体验。

可以使用 Maven 的多模块项目来支持 MRJAR 文件的源码布局和构建。例如,请参阅此示例 Maven 项目(https://github.com/hboutemy/maven-jep238),该项目可以生成一个目前还很简单的 MRJAR 文件。将会有一些子项目用于根和特定的 Java 平台版本,并有一个子项目来将上述子项目组装成一个 MVJAR。装配过程可以进行增强,例如使用特定的 Maven 插件,利用与jar工具相同的功能来强制向后兼容。

MRJAR 的运行时处理的设计和实现目前假定运行时使用 URL 类加载器或自定义类加载器来利用JarFile获取特定于平台的类文件。使用ZipFile加载类的类加载器将不会意识到 MRJAR。需要检查流行的应用框架和工具(如 Jetty、Tomcat 和 Maven 等)的兼容性。

依赖关系

正在考虑用于 Java 平台模块系统的增强 JAR 文件格式需要考虑多版本 JAR 元数据。

JEP 247(针对旧平台版本进行编译)支持针对旧版本平台库进行编译,可能有助于构建工具生成多版本 JAR 文件。