跳到主要内容

JEP 238:多版本 JAR 文件

QWen Max 中英对照

概述

扩展 JAR 文件格式,以允许在单个存档中存在多个针对不同 Java 发行版本的类文件版本。

目标

  1. 增强 Java 归档工具 (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 文件有一个内容根目录,其中包含类和资源,还有一个 META-INF 目录,其中包含有关 JAR 的元数据。通过向特定的文件组添加一些版本元数据,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 平台版本对应的特定平台目录中搜索类和资源,然后搜索较低版本的目录,最后搜索 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 的 Java 9 版本以及 C 和 D 的通用版本;在未来的支持 MRJAR 的 Java 10 JDK 上,它会看到 A 的 Java 10 版本和 B 的 Java 9 版本;而在较旧的或不支持 MRJAR 的 JDK 上,它只会看到所有类的根版本。

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

最终,该机制使库和框架开发者能够将特定 Java 平台发布版本中的 API 使用与所有用户都迁移到该版本的需求分离开来。库和框架维护者可以逐步迁移到新的功能并提供支持,同时仍然保留对旧功能的支持,从而打破先有鸡还是先有蛋的循环,这样库就可以在“准备好支持 Java 9”的同时,实际上并不需要依赖 Java 9。

详情

为了支持多版本 JAR 文件,将对 JDK 的以下组件进行修改。

  • 基于 JAR 的 URLClassLoader 必须根据运行的 Java 平台版本读取选定版本的类文件。随着 Project Jigsaw 引入的基于模块的类加载器也需要类似的修改。

  • jar URL 协议处理器和 java.util.jar.JarFile 类必须从多版本 JAR 中选择适当的类版本。

  • Java 编译器 (javac),通过底层的 JavacFileManagerZipFileSystem API,必须按照 -target-release 命令行选项指定的内容读取选定版本的类文件。工具 javahschemagenwsgen 将利用对 JavacFileManagerZipFileSystem 的底层更改。

  • Java 归档工具 (jar) 将得到增强,以便能够创建多版本 JAR 文件。

  • JAR 打包工具 (pack200/unpack200) 必须进行更新(参见 JDK-8066272)。

  • javap 工具必须进行更新以支持选择版本化的类文件。

  • jdeps 工具需要修改以显示版本信息并跟踪特定版本的类文件依赖关系。

  • JAR 规范必须修订,以描述多版本 JAR 文件格式及任何相关更改(例如,清单文件中可能的新增内容)。

兼容性

默认情况下,java.util.jar.JarFilejar 协议处理器的行为将保持不变。若要进行版本选择条目,需要显式选择以构造一个指向 MRJARJarFile。同样地,对于 jar URL 也需要显式选择(详见下一节)。

运行时为类加载创建的 JarFile 实例将会选择加入,并创建根据运行中的 Java 平台版本选择条目的配置实例。这样的 JarFile 实例被称为是运行时版本化的。

类加载器资源

由类加载器生成的资源 URL,用于标识 MRJAR 中的资源时,将直接指向一个带版本的条目(如果存在)。例如,对于一个带版本的资源 foo/baz/resource.txt

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 文件是一种多版本 JAR 文件,其根目录顶部包含一个模块描述符 module-info.class,这一点与模块化 JAR 文件相同(参见 JEP 261 的“打包:模块化 JAR 文件”部分)。此外,模块描述符也可以存在于版本化区域中。这些版本化的描述符必须与根模块描述符完全一致,但有两个例外:

  • 一个带版本的描述符可以具有不同的非 transitiverequires 子句,这些子句涉及 java.*jdk.* 模块;以及

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

这里的理由是,这些是实现细节,而不是模块 API 表面的一部分,并且随着 JDK 本身的演变,人们可能希望更改它们。不允许对非 JDK 模块的非公共 requires 进行更改。如果必须这样做,则需要新版本的模块(至少增加其版本号),这是一个不同类型的兼容性问题,超出了 MRJAR 的范围。

一个多版本模块不需要在根目录下有模块描述符。在这方面,模块描述符的处理方式与任何其他类或资源文件没有区别。这可以确保,例如,只有 Java 8 版本的类存在于根区域,而 Java 9 版本的类(包括模块描述符)存在于 9 版本的区域中。

类路径和模块路径

可以构建一个模块化的 JAR 文件,使其在 Java 8 运行时的类路径(classpath)、Java 9 运行时的类路径或者 Java 9 运行时的模块路径(modulepath)上都能正常工作。对于模块化的多版本 JAR 文件情况相同(除了 module-info.class 之外,其他类可能还针对 Java 9 平台进行了编译)。

如果一个模块描述符未将某些包声明为已导出,因此这些包中的公共类对模块来说是私有的,那么当相应的 JAR 文件被放置在模块路径上时,这些类将不可访问。然而,如果该 JAR 文件被放置在类路径上,那么这些类将是可访问的。这是支持类路径和模块路径的一个不幸后果。

因此,当多版本 JAR 文件放置在类路径上时,其公共 API 可能与放置在模块路径上时有所不同。通常情况下,jar 工具在构建多版本 JAR 文件时,如果检测到公共 API 存在差异,会尽最大努力使其失败。然而,在构建模块化的多版本 JAR 文件时,如果公共 API 的差异是由于模块私有类在 JAR 文件放置在类路径上时变得可访问,那么建议 jar 工具输出一条警告信息。

多版本 jar 包与引导加载器

引导加载程序不支持多版本 JAR(例如,当使用 -Xbootclasspath/a 选项声明一个多版本 JAR 文件时)。为其添加支持会使引导加载程序的实现复杂化,而这种情况被认为是一个罕见的用例。

替代方案

一种常见的方法是使用静态反射检查来确定某个 API 功能是否存在,并据此选择一个适当的类,该类分别依赖或不依赖该功能。反射的开销是在类初始化时产生的,而不是在每次使用依赖功能时产生。选择一个 Java 平台版本进行编译时,会将源代码和目标标志设置为较低的版本,以生成与该较低版本兼容的类文件。这种方法通常会结合使用 Animal Sniffer 等工具来检查 API 不兼容性,除了强制执行 API 兼容性之外,还可以对代码进行注解,以声明其是否依赖于较新的 Java 平台版本。这种方法存在一些局限性:

  1. 这些反射检查需要仔细维护。

  2. 不可能利用较新的语言特性。

  3. 如果平台发布 API 特性被移除(可能是内部 API),那么依赖的代码将无法编译。

曾有人考虑过“胖”类文件,其中类可能有一个或多个针对不同 Java 平台版本的方法。就支持此类方法声明和动态选择所需的语言和运行时特性而言,这被认为过于复杂。

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

风险与假设

预计 MRJAR 的生产主要与现有的流行构建工具(以及支持此类工具的 IDE)兼容,但通过改进可以提升开发者体验。

可以使用多模块项目让 Maven 支持 MRJAR 文件的源代码布局和构建。例如,参见这个 示例 Maven 项目,它可以生成一个目前还比较基础的 MRJAR 文件。根目录和特定 Java 平台版本会有对应的子项目,并且还有一个子项目用于将上述子项目组装成一个 MVJAR。组装过程可以进一步增强,或许可以使用特定的 Maven 插件,利用与 jar 工具相同的功能来确保向后兼容性。

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

依赖

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

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