跳到主要内容

JEP 261:模块系统

QWen Max 中英对照

概述

实现由 JSR 376 规定的 Java 平台模块系统,以及相关的 JDK 特定变更和增强功能。

描述

Java 平台模块系统 (JSR 376) 规定了对 Java 编程语言、Java 虚拟机以及标准 Java API 的变更和扩展。本 JEP 实现了该规范。因此,javac 编译器、HotSpot 虚拟机以及运行时库将模块作为 Java 程序组件的一种基本新型组件来实现,并在开发的所有阶段为模块的可靠配置和强封装性提供支持。

该 JEP 还更改、扩展并添加了与 JSR 范围之外的编译、链接和执行相关的 JDK 特定工具和 API。对其他工具和 API 的相关更改,例如 javadoc 工具和 Doclet API,是单独 JEP 的主题。

本 JEP 假定读者熟悉最新的 模块系统现状 文档以及其它 Project Jigsaw JEP:

阶段

在我们熟悉的编译时(javac 命令)和运行时(java 运行时启动器)阶段之外,我们增加了 链接时 的概念,这是介于两者之间的一个可选阶段,在此阶段可以将一组模块组装并优化为自定义的运行时映像。链接工具 jlinkJEP 282 的主题;javacjava 实现的许多新的命令行选项也同样由 jlink 实现。

模块路径

javacjlinkjava 命令,以及其他一些命令,现在接受用于指定各种 模块路径 的选项。模块路径是一个序列,其中每个元素要么是 模块定义,要么是包含模块定义的目录。每个模块定义可以是以下之一:

  • 一个 模块工件,即一个包含已编译模块定义的模块化 JAR 文件或 JMOD 文件,或者

  • 一个 解压模块目录,按照惯例其名称为模块的名称,其内容是一个对应于包层次结构的“解压”目录树。

在后一种情况下,目录树可以是一个编译后的模块定义,其中包含各个类和资源文件,并在根目录下有一个 module-info.class 文件;或者在编译时,是一个源模块定义,其中包含各个源文件,并在根目录下有一个 module-info.java 文件。

模块路径与其他类型的路径一样,由一系列路径名组成,路径名之间通过主机平台的路径分隔符字符分隔(在大多数平台上为 ':',在 Windows 上为 ';')。

模块路径与类路径有很大的不同:类路径是用来定位单个类型和资源定义的手段,而模块路径则是用来定位整个模块定义的手段。类路径的每个元素是类型和资源定义的容器,,要么是一个 JAR 文件,要么是一个已解压的、按包层级排列的目录树。相比之下,模块路径的每个元素是一个模块定义,或者是一个目录,该目录中的每个元素都是一个模块定义,,一个类型和资源定义的容器,,要么是一个模块化的 JAR 文件,要么是一个 JMOD 文件,要么是一个已解压的模块目录。

在解析过程中,模块系统通过沿着几个不同的路径进行搜索来定位模块,具体路径取决于阶段,并且还会按照以下顺序搜索环境中内置的已编译模块:

  • 编译模块路径(由命令行选项 --module-source-path 指定)包含以源代码形式存在的模块定义(仅在编译时有效)。

  • 升级模块路径--upgrade-module-path)包含模块的已编译定义,这些模块优先于系统模块或应用程序模块路径中存在的可升级模块的已编译定义使用(编译时和运行时均适用)。

  • 系统模块 是环境中内置的已编译模块(编译时和运行时均适用)。这些通常包括 Java SE 和 JDK 模块,但在自定义链接镜像的情况下,还可以包括库模块和应用程序模块。在编译时,可以通过 --system 选项覆盖系统模块,该选项指定一个用于加载系统模块的 JDK 镜像。

  • 应用程序模块路径--module-path,简写为 -p)包含库模块和应用程序模块的已编译定义(适用于所有阶段)。在链接时,此路径还可以包含 Java SE 和 JDK 模块。

这些路径上的模块定义与系统模块一起定义了可观察模块的范围。

当在模块路径中搜索特定名称的模块时,模块系统会采用该名称的第一个模块定义。如果存在版本字符串,则会被忽略;如果模块路径中的某个元素包含多个同名模块的定义,那么解析将会失败,并且编译器、链接器或虚拟机将报告错误并退出。构建工具和容器应用程序的责任是配置模块路径以避免版本冲突;模块系统的目标并不是解决版本选择问题,这并非模块系统的目标

根模块

模块系统通过解析一组 根模块 相对于可观察模块集合的传递闭包依赖关系,构建一个模块图。

当编译器编译未命名模块中的代码时,或者当调用 java 启动器并将应用程序的主类从类路径加载到应用程序类加载器的未命名模块中时,未命名模块的默认根模块集合 将按照以下方式计算:

  • 如果 java.se 模块存在,它将作为一个根模块。如果它不存在,那么升级模块路径中的每个 java.* 模块或系统模块中至少导出一个无限制包的模块将成为根模块。

  • 升级模块路径中的每个非 java.* 模块或系统模块中至少导出一个无限制包的模块同样也会成为根模块。

否则,默认的根模块集取决于阶段:

  • 在编译时,它通常是正在编译的模块集(更多内容见下文);

  • 在链接时,它是空的;以及

  • 在运行时,它是通过 --module(或简写为 -m)启动器选项指定的应用程序的主模块。

有时需要将模块添加到默认的根集合中,以确保特定的平台、库或服务提供者模块会出现在最终的模块图中。在任何阶段,该选项都可以使用。

--add-modules <module>(,<module>)*

其中 <module> 是模块名称,将指定的模块添加到默认的根模块集中。此选项可以多次使用。

作为一种特殊情况,在运行时,如果 <module>ALL-DEFAULT,那么根据上述定义,未命名模块的默认根模块集将被添加到根集。这在应用程序是一个托管其他应用程序的容器时非常有用,因为这些被托管的应用程序可能会依赖容器本身并不需要的模块。

作为运行时的一个特殊案例,如果 <module>ALL-SYSTEM,那么所有的系统模块都会被添加到根集合中,无论它们是否在默认集合中。这有时是测试工具所需要的。此选项将导致许多模块被解析;通常情况下,应该优先选择 ALL-DEFAULT

作为最后一个特殊情况,在运行时和链接时,如果 <module>ALL-MODULE-PATH,那么在相关模块路径上找到的所有可观察模块都会被添加到根集。ALL-MODULE-PATH 在编译时和运行时均有效。此功能专为 Maven 等构建工具提供,这些工具已经确保模块路径上的所有模块都是必需的。它也是一种将自动模块添加到根集的便捷方法。

限制可观察模块

有时,限制可观察模块是很有用的,例如,用于调试,或者当主模块是由应用程序类加载器为类路径定义的未命名模块时,减少解析的模块数量。--limit-modules 选项可以在任何阶段使用来实现此目的。其语法为:

--limit-modules <module>(,<module>)*

其中 <module> 是模块名称。此选项的效果是将可观察模块限制为命名模块的传递闭包中的模块,加上主模块(如果有),以及通过 --add-modules 选项指定的任何其他模块。

(为解释 --limit-modules 选项而计算的传递闭包是一个临时结果,仅用于计算可观察模块的有限集合。解析器将再次被调用以计算实际的模块图。)

提高可读性

在测试和调试时,有时需要安排一个模块读取另一个模块,即使第一个模块并没有通过其模块声明中的 requires 子句依赖于第二个模块。例如,这可能是为了使被测试的模块能够访问测试工具本身,或访问与该工具相关的库。可以使用 --add-reads 选项在编译时和运行时实现这一点。其语法为:

--add-reads <source-module>=<target-module>

其中 <source-module><target-module> 是模块名称。

--add - reads 选项可以使用多次。每个实例的效果是从源模块到目标模块添加一个可读性边缘。这本质上是模块声明中 requires 子句的命令行形式,或者是 Module::addReads 方法 的无限制形式的调用。因此,如果目标模块中的包通过源模块声明中的 exports 子句、Module::addExports 方法 的调用或者 --add - exports 选项(如下定义)导出,则源模块中的代码在编译时和运行时都能够访问目标模块包中的类型。此外,如果目标模块被声明为开放的,或者该包通过源模块声明中的 opens 子句、Module::addOpens 方法 的调用或者 --add - opens 选项(也定义于下文)开放,则这样的代码在运行时也能够访问目标模块包中的类型。

例如,如果一个测试框架将一个白盒测试类注入到 java.management 模块中,并且该类扩展了(假设的)testng 模块中的一个导出的工具类,那么它所需的访问权限可以通过以下选项授予:

--add-reads java.management=testng

作为一个特例,如果 <target-module>ALL-UNNAMED,那么可读性边缘将从源模块添加到所有当前和未来的未命名模块,包括与类路径对应的模块。这允许模块中的代码通过尚未被转换为模块化形式的测试框架进行测试。

破坏封装

有时有必要违反模块系统定义的访问控制边界,以及编译器和虚拟机强制执行的边界,以便允许一个模块访问另一个模块的一些未导出类型。这样做可能是为了实现某些目的,例如,对内部类型进行白盒测试,或者向依赖这些内部 API 的代码暴露不受支持的内部 API。--add-exports 选项可以在编译时和运行时使用,以实现此目的。其语法为:

--add-exports <source-module>/<package>=<target-module>(,<target-module>)*

其中 <source-module><target-module> 是模块名称,<package> 是包的名称。

--add-exports 选项可以多次使用,但对于任何特定的源模块和包名组合,最多只能使用一次。每个实例的效果是从源模块向目标模块添加一个限定导出的指定包。这本质上是模块声明中 exports 子句的命令行形式,或者是对 Module::addExports 方法 的一种无限制形式的调用。因此,如果目标模块通过其模块声明中的 requires 子句、调用 Module::addReads 方法或使用 --add-reads 选项来读取源模块,那么目标模块中的代码就能够访问源模块中指定包的公共类型。

例如,如果模块 jmx.wbtest 包含对 java.management 模块中未导出的 com.sun.jmx.remote.internal 包的白盒测试,则可以通过以下选项授予其所需的访问权限:

--add-exports java.management/com.sun.jmx.remote.internal=jmx.wbtest

作为一个特例,如果 <target-module>ALL-UNNAMED,那么源包将被导出到所有未命名的模块,无论这些模块是最初就存在还是后来创建的。因此,可以通过以下选项将 java.management 模块中的 sun.management 包的访问权限授予类路径上的所有代码:

--add-exports java.management/sun.management=ALL-UNNAMED

--add-exports 选项允许访问指定包的公共类型。有时需要更进一步,通过 核心反射 APIsetAccessible 方法 来启用对所有非公共元素的访问。--add-opens 选项可以在运行时用来实现这一点。它与 --add-exports 选项具有相同的语法:

--add-opens <source-module>/<package>=<target-module>(,<target-module>)*

其中 <source-module><target-module> 是模块名称,<package> 是包的名称。

--add-opens 选项可以多次使用,但对于任何特定的源模块和包名组合,最多只能使用一次。每个实例的效果是从源模块向目标模块添加一个对指定包的限定开放。这本质上是模块声明中 opens 子句的命令行形式,或者是 Module::addOpens 方法 的无限制形式的调用。因此,只要目标模块读取源模块,目标模块中的代码就能够使用核心反射 API 访问源模块中指定包的所有类型(包括公共的和其他类型的)。

在编译时,开放包与未导出包无法区分,因此 --add-opens 选项可能不能在该阶段使用。

--add-exports--add-opens 选项必须非常谨慎地使用。你可以利用它们访问某个库模块的内部 API,甚至是 JDK 自身的内部 API,但这样做需要自行承担风险:如果该内部 API 被更改或移除,那么你的库或应用程序将会失败。

修补模块内容

在测试和调试时,有时需要用替代或实验性的版本替换特定模块中选定的类文件或资源,或者提供全新的类文件、资源,甚至是包。这可以通过 --patch-module 选项在编译时和运行时实现。其语法为:

--patch-module <module>=<file>(<pathsep><file>)*

其中 <module> 是模块名称,<file> 是模块定义的文件系统路径名,而 <pathsep> 是主机平台的路径分隔符字符。

--patch-module 选项可以多次使用,但对于任何特定的模块名称,最多只能使用一次。每个实例的效果是改变模块系统在指定模块中搜索类型的方式。在检查实际模块之前,无论是系统的一部分还是在模块路径上定义的模块,它都会首先按顺序检查为该选项指定的每个模块定义。补丁路径命名了一组模块定义的序列,但它并不是一个模块路径,因为它具有类似类路径的泄漏语义。这允许测试工具(例如)将多个测试注入到同一个包中,而无需将所有测试复制到单个目录中。

--patch-module 选项不能用于替换 module-info.class 文件。如果在修补路径上的模块定义中找到 module-info.class 文件,则会发出警告并且该文件将被忽略。

如果在补丁路径上的模块定义中找到的包尚未被该模块导出或打开,那么它仍然不会被导出或打开。可以通过反射 API 或 --add-exports--add-opens 选项显式地导出或打开它。

--patch-module 选项替代了已被移除的 -Xbootclasspath:/p 选项(见下文)。

--patch-module 选项仅用于测试和调试目的。强烈建议不要在生产环境中使用该选项。

编译时间

javac 编译器实现了上述适用于编译时的选项:--module-source-path--upgrade-module-path--system--module-path--add-modules--limit-modules--add-reads--add-exports--patch-module

编译器在三种模式之一中运行,每种模式都实现了其他选项。

  • 当编译环境(由 -source-target--release 选项定义)小于或等于 8 时,将启用 传统模式。上述任何模块化选项均不可使用。

在传统模式下,编译器的行为方式与在 JDK 8 中的基本相同。

  • 当编译环境为 9 或更高版本且未使用 --module-source-path 选项时,将启用单模块模式。可以使用上述其他模块化选项;但现有的 -bootclasspath-Xbootclasspath-extdirs-endorseddirs-XXuserPathsFirst 选项不可使用。

单模块模式用于编译以传统包层次结构目录树组织的代码。它是对旧有模式的简单使用的自然替代形式,例如:

$ javac -d classes -classpath classes -sourcepath src Foo.java

如果在命令行中指定了以 module-info.javamodule-info.class 文件形式存在的模块描述符,或者在源路径或类路径中找到了该描述符,那么源文件将被编译为该描述符所命名的模块的成员,并且该模块将成为唯一的根模块。如果存在 --module <module> 选项,则源文件将被编译为 <module> 的成员,而 <module> 将成为根模块。否则,源文件将被编译为未命名模块的成员,根模块将按照上述方法进行计算。

在这种模式下,可以将任意类和 JAR 文件放入类路径中,但不建议这样做,因为这相当于将这些类和 JAR 文件视为正在编译的模块的一部分。

  • 当编译环境为 9 或更高版本,并且使用了 --module-source-path 选项时,将启用多模块模式。此时必须使用现有的 -d 选项来命名输出目录;上面描述的其他模块化选项可以使用;但现有的 -bootclasspath-Xbootclasspath-extdirs-endorseddirs-XXuserPathsFirst 选项则不能使用。

多模块模式用于编译一个或多个模块,这些模块的源代码布局在模块源路径上的展开模块目录中。在此模式下,类型的模块成员资格由其源文件在模块源路径中的位置决定,因此命令行中指定的每个源文件都必须存在于该路径的某个元素内。根模块的集合是指至少有一个源文件被指定的模块集合。

与其他模式相比,此模式下必须通过 -d 选项指定一个输出目录。该输出目录将被组织为模块路径中的一个元素,,它将包含一些展开的模块目录,而这些目录本身又包含类文件和资源文件。如果编译器在模块源路径中找到一个模块,但无法找到该模块中某个类型的源文件,那么它将在输出目录中搜索相应的类文件。

在大型系统中,某个特定模块的源代码可能分布在多个不同的目录中。在 JDK 本身中,例如,一个模块的源文件可能位于以下任意一个目录中:src/<module>/share/classessrc/<module>/<os>/classesbuild/gensrc/<module>,其中 <os> 是目标操作系统的名称。为了在模块源路径中表达这一点,同时保留模块标识,我们允许该路径的每个元素使用大括号({})来包含用逗号分隔的选项列表,并使用单个星号(*)代表模块名称。因此,JDK 的模块源路径可以写为:

{src/*/{share,<os>}/classes,build/gensrc/*}

在两种模块化模式下,编译器默认会生成与模块系统相关的各种警告;这些警告可以通过选项 -Xlint:-module 来禁用。通过 -Xlint 选项的 exportsopensrequires-automaticrequires-transitive-automatic 键,可以对这些警告进行更精确的控制。

新的选项 --module-version <version> 可用于指定正在编译的模块的版本字符串。

类文件属性

一个特定于 JDK 的类文件属性 ModuleTarget 会可选地记录包含它的模块描述符的目标操作系统和架构。其格式为:

ModuleTarget_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 os_arch_index; // index to a CONSTANT_utf8_info structure
}

常量池中 os_arch_index 处的 UTF-8 字符串格式为 <os>-<arch>,其中 <os> 通常是 linuxmacossolariswindows 中的一个,而 <arch> 通常是 x86amd64sparcv9armaarch64 中的一个。

打包:模块化 JAR 文件

jar 工具可以不加修改地用来创建模块化 JAR 文件,因为模块化 JAR 文件只是一个在其根目录中包含 module-info.class 文件的 JAR 文件。

jar 工具实现了以下新选项,以允许在模块打包时将附加信息插入到模块描述符中:

  • --main-class=<class-name>,或者简写为 -e <class-name>,会将 <class-name> 记录在 module-info.class 文件中,作为包含模块 public static void main 入口点的类。(这不是一个新选项;它已经在 JAR 文件的清单中记录了主类。)

  • --module-version=<version> 会将 <version> 记录在 module-info.class 文件中,作为模块的版本字符串。

  • --hash-modules=<pattern> 会将特定模块的内容哈希值记录在 module-info.class 文件中,这些模块依赖于当前模块,并且位于一组可观察模块中,供以后用于依赖关系的验证。哈希值只会为名称匹配正则表达式 <pattern> 的模块记录。如果使用此选项,则还必须使用 ---module-path 选项(或简写为 -p),以指定用于计算依赖于该模块的可观察模块集合。

  • --describe-module,或者简写为 -d,用于显示指定 JAR 文件的模块描述符(如果有的话)。

jar 工具的 --help 选项可用于显示其命令行选项的完整摘要。

定义了两个新的 JDK 特定的 JAR 文件清单属性,分别对应于 --add-exports--add-opens 命令行选项:

  • Add-Exports: <模块>/<包>( <模块>/<包>)*
  • Add-Opens: <模块>/<包>( <模块>/<包>)*

每个属性的值是一个由斜杠分隔的模块名/包名对组成的空格分隔列表。Add-Exports 属性值中的 <module>/<package> 对与命令行选项 --add-exports <module>/<package>=ALL-UNNAMED 具有相同的意义。Add-Opens 属性值中的 <module>/<package> 对与命令行选项 --add-opens <module>/<package>=ALL-UNNAMED 具有相同的意义。

每个属性在 MANIFEST.MF 文件的主部分中最多只能出现一次。特定的键值对可以列出多次。如果指定的模块未被解析,或者指定的包不存在,则相应的键值对将被忽略。这些属性仅在应用程序的主可执行 JAR 文件中解释,即在通过 Java 运行时启动器的 -jar 选项指定的 JAR 文件中有效;而在所有其他 JAR 文件中,它们将被忽略。

打包:JMOD 文件

新的 JMOD 格式超越了 JAR 文件,包含本地代码、配置文件和其他种类的数据,这些数据如果不适合放入 JAR 文件的话。JMOD 文件用于打包 JDK 本身的模块;开发者也可以根据需要使用它们来打包自己的模块。

JMOD 文件可以在编译时和链接时使用,但不能在运行时使用。要使它们在运行时可用,通常需要我们准备好动态地提取并链接本地代码库。这在大多数平台上是可行的,尽管可能会非常棘手,并且我们尚未看到很多需要这种能力的使用案例,因此为了简化起见,在本次发布中我们选择限制 JMOD 文件的用途。

一个新的命令行工具 jmod 可用于创建、操作和检查 JMOD 文件。其通用语法为:

$ jmod (create|extract|list|describe|hash) <options> <jmod-file>

对于 create 子命令,<options> 可以包括上面针对 jar 工具描述的 --main-class--module-version--hash-modules--module-path 选项,还可以包括:

  • --class-path <path> 指定一个类路径,其内容将被复制到生成的 JMOD 文件中。

  • --cmds <path> 指定包含要复制的本地命令的一个或多个目录。

  • --config <path> 指定包含要复制的配置文件的一个或多个目录。

  • --exclude <pattern-list> 指定要排除的文件,其中 <pattern-list> 是逗号分隔的模式列表,形式为 <glob-pattern>glob:<glob-pattern>regex:<regex-pattern>

  • --header-files <path> 指定包含要复制的 C 和 C++ 头文件的一个或多个目录。

  • --legal-notices <path> 指定包含要复制的法律声明的一个或多个目录。

  • --libs <path> 指定包含要复制的本地库的一个或多个目录。

  • --man-pages <path> 指定包含要复制的手册页的一个或多个目录。

  • --target-platform <os>-<arch> 指定目标操作系统和架构,该信息将记录在 module-info.class 文件的 ModuleTarget 属性中。

extract 子命令接受一个选项 --dir,用于指定应将指定 JMOD 文件的内容写入的目录。如果目录不存在,则会创建该目录。如果此选项不存在,则内容将被提取到当前目录。

list 子命令列出指定 JMOD 文件的内容;describe 子命令以与 jarjava 命令的 --describe-module 选项相同的格式显示指定 JMOD 文件的模块描述符。这些子命令不接受任何选项。

hash 子命令可用于对现有的 JMOD 文件集合进行哈希处理。它需要同时使用 --module-path--hash-modules 选项。

jmod 工具的 --help 选项可用于显示其命令行选项的完整摘要。

命令行链接工具 jlink 的详细信息在 JEP 282 中有描述。总体上,其通用语法为:

$ jlink <options> ---module-path <modulepath> --output <path>

其中 ---module-path 选项指定了链接器要考虑的可观察模块集,--output 选项指定了包含生成的运行时映像的目录路径。其他 <options> 可以包括上面描述的 ---limit-modules---add-modules 选项,以及额外的链接器特定选项。

jlink 工具的 --help 选项可用于显示其命令行选项的完整摘要。

运行时间

HotSpot 虚拟机实现了上述适用于运行时的选项:--upgrade-module-path--module-path--add-modules--limit-modules--add-reads--add-exports--add-opens--patch-module。这些选项可以传递给命令行启动器 java,也可以传递给 JNI 调用 API

此阶段特有的另一个选项并且受到启动器支持的是:

  • --module <module>,或者简写为 -m <module>,用于指定模块化应用程序的主模块。这将成为构建应用程序初始模块图的默认根模块。如果主模块的描述符未指示主类,则可以使用语法 <module>/<class>,其中 <class> 命名包含应用程序 public static void main 入口点的类。

启动器支持的其他诊断选项包括:

  • --list-modules 显示可观察模块的名称和版本字符串,然后退出,其方式与 java --version 相同。

  • --describe-module <module>,或简写为 -d <module>,显示指定模块的模块描述符,格式与 jar -d 选项和 jmod describe 子命令相同,然后退出。

  • --validate-modules 验证所有可观察模块,检查冲突和其他潜在错误,然后退出。

  • --dry-run 初始化虚拟机并加载主类,但不调用主方法;这对于验证模块系统的配置非常有用。

  • --show-module-resolution 使模块系统在构建初始模块图时描述其活动。

  • -Dsun.reflect.debugModuleAccessChecksjava.lang.reflect API 中的访问检查因 IllegalAccessExceptionInaccessibleObjectException 失败时显示线程转储。当失败的根本原因被隐藏时(因为异常被捕获且未重新抛出),这对调试非常有用。

  • -Xlog:module+[load|unload][=[debug|trace]] 使虚拟机在运行时模块图中定义和更改模块时记录调试或跟踪消息。这些选项在启动期间会产生大量输出。

  • -verbose:module-Xlog:module+load -Xlog:module+unload 的简写形式。

  • -Xlog:init=debug 在模块系统初始化失败时显示堆栈跟踪。

  • --version--show-version--help--help-extra 分别显示与现有的 -version-show-version-help-Xhelp 选项相同的信息,并以相同的方式工作,不同之处在于它们将帮助文本写入标准输出流而不是标准错误流。

运行时为异常生成的堆栈跟踪已扩展为在存在相关模块的情况下包含其名称和版本字符串。诸如 ClassCastExceptionIllegalAccessExceptionIllegalAccessError 等异常的详细信息字符串也已更新为包含模块信息。

现有的 -jar 选项已得到增强,因此如果启动的 JAR 文件的清单文件包含 Launcher-Agent-Class 属性,则该 JAR 文件将同时作为应用程序及其代理启动。这允许使用 java -jar foo.jar 来代替更冗长的 java -javaagent:foo.jar -jar foo.jar

放松的强封装

在此版本中,默认情况下,JDK 的某些包的强封装被放宽了,这是 Java SE 9 平台规范所允许的。这种放宽由一个新的启动器选项 --illegal-access 在运行时控制,其工作方式如下:

  • --illegal-access=permit 将运行时镜像中的每个模块里的每个包开放给所有未命名模块中的代码,,类路径上的代码,前提是该包在 JDK 8 中已存在。这使得可以通过编译后的字节码进行静态访问,也可以通过平台的各种反射 API 进行深度反射访问。

    对任何此类包的首次反射访问操作都会触发一个警告,但在此之后不会再发出警告。这个单一的警告会描述如何启用更多的警告。此警告无法被抑制。

    此模式是 JDK 9 中的默认设置。它将在未来的版本中逐步被淘汰,并最终被移除。

  • --illegal-access=warnpermit 相同,但会对每次非法的反射访问操作发出一条警告信息。

  • --illegal-access=debugwarn 相同,但会对每次非法的反射访问操作既发出警告信息,又生成堆栈跟踪信息。

  • --illegal-access=deny 禁止所有的非法访问操作,除非有其他命令行选项(例如 --add-opens)明确允许。

    此模式将在未来的版本中成为默认设置。

deny 成为默认的非法访问模式时,permit 很可能至少在一个发行版中仍然受支持,以便开发者可以继续迁移他们的代码。随着时间的推移,permitwarndebug 模式以及 --illegal-access 选项本身都将被移除。(为了启动脚本的兼容性,在发出相关警告后,不受支持的模式很可能将被忽略。)

默认模式 --illegal-access=permit 旨在让你知道类路径上是否有代码至少一次通过反射访问了某些 JDK 内部 API。为了为将来做好准备,你可以使用 warndebug 模式来了解所有此类访问。对于类路径上需要非法访问的每个库或框架,你有两种选择:

  • 如果该组件的维护者已经发布了不再使用 JDK 内部 API 的新修复版本,那么你可以考虑升级到该版本。

  • 如果该组件仍然需要修复,我们鼓励你联系其维护者,要求他们如果可能的话,用适当的导出 API 替代对 JDK 内部 API 的使用。

如果必须继续使用需要非法访问的组件,那么可以通过使用一个或多个 --add-opens 选项来消除警告消息,这些选项仅打开那些需要访问的内部包。

为了验证您的应用程序是否为未来做好了准备,请使用 --illegal-access=deny 以及任何必要的 --add-opens 选项运行它。任何剩余的非法访问错误很可能是因为编译代码对 JDK 内部 API 的静态引用导致的。您可以通过使用 --jdk-internals 选项运行 jdeps 工具来识别这些问题。(运行时系统不会对非法静态访问操作发出警告,因为这需要对虚拟机进行深层次的更改,并且会降低性能。)

检测到非法的反射访问操作时发出的警告消息具有以下形式:

WARNING: Illegal reflective access by $PERPETRATOR to $VICTIM

其中:

  • $PERPETRATOR 是包含调用相关反射操作的代码的类型的全限定名,以及代码来源(即 JAR 文件路径),如果可用的话,还有

  • $VICTIM 是描述被访问成员的字符串,包括外围类型的全限定名。

在默认模式下,--illegal-access=permit,最多会发出一条这样的警告信息,并附有额外的指导文本。以下是一个运行 Jython 时的例子:

$ java -jar jython-standalone-2.7.0.jar
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by jnr.posix.JavaLibCHelper (file:/tmp/jython-standalone-2.7.0.jar) to method sun.nio.ch.SelChImpl.getFD()
WARNING: Please consider reporting this to the maintainers of jnr.posix.JavaLibCHelper
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
Jython 2.7.0 (default:9987c746f838, Apr 29 2015, 02:25:11)
[OpenJDK 64-Bit Server VM (Oracle Corporation)] on java9
Type "help", "copyright", "credits" or "license" for more information.
>>> ^D

运行时系统会尽力避免对同一个 PERPETRATORPERPETRATOR 和 VICTIM 发出重复警告。

一个扩展的例子

假设我们有一个应用模块 com.foo.bar,它依赖于库模块 com.foo.baz。如果我们在模块路径目录 src 中同时拥有这两个模块的源代码:

src/com.foo.bar/module-info.java
src/com.foo.bar/com/foo/bar/Main.java
src/com.foo.baz/module-info.java
src/com.foo.baz/com/foo/baz/BazGenerator.java

然后我们可以一起编译它们:

$ javac --module-source-path src -d mods $(find src -name '*.java')

输出目录 mods 是一个模块路径目录,包含两个模块的已编译定义:

mods/com.foo.bar/module-info.class
mods/com.foo.bar/com/foo/bar/Main.class
mods/com.foo.baz/module-info.class
mods/com.foo.baz/com/foo/baz/BazGenerator.class

假设 com.foo.bar.Main 类包含应用程序的入口点,我们可以按原样运行这些模块:

假设 `com.foo.bar.Main` 类包含应用程序的入口点,我们可以按原样运行这些模块:
markdown
$ java -p mods -m com.foo.bar/com.foo.bar.Main

或者,我们可以将它们打包成模块化的 JAR 文件:

$ jar --create -f mlib/com.foo.bar-1.0.jar \
--main-class com.foo.bar.Main --module-version 1.0 \
-C mods/com.foo.bar .
$ jar --create -f mlib/com.foo.baz-1.0.jar \
--module-version 1.0 -C mods/com.foo.baz .

mlib 目录是一个模块路径目录,包含两个模块的打包、编译后的定义:

$ ls -l mlib
-rw-r--r-- 1501 Sep 6 12:23 com.foo.bar-1.0.jar
-rw-r--r-- 1376 Sep 6 12:23 com.foo.baz-1.0.jar

我们现在可以直接运行打包好的模块:

$ java -p mlib -m com.foo.bar

jtreg 增强功能

jtreg 测试框架 支持一种新的声明性标签 @modules,用于表达测试对被测系统中模块的依赖关系。该标签接受一系列以空格分隔的参数,每个参数可以是以下形式:

  • <module>,其中 <module> 是模块名称,表示指定的模块必须存在;

  • <module>/<package>,表示指定的模块必须存在,并且指定的包必须导出到测试模块;或者

  • <module>/<package>:<flag>,表示指定的模块必须存在,并且如果标记为 open,则指定的包必须对测试模块开放,否则如果标记为 +open,则指定的包必须同时导出并开放给测试模块。

可以将默认的 @modules 参数集指定为 TEST.ROOT 文件或任何 TEST.properties 文件中 modules 属性的值,该参数集将用于目录层次结构中所有未包含此类标签的测试。

现有的 @compile 标签接受一个新选项 /module=<module>。其作用是使用上面定义的 --module <module> 选项调用 javac,以将指定的类编译为所指示模块的成员。

类加载器

Java SE 平台 API 历史上指定了两个类加载器:引导类加载器(bootstrap class loader),它从引导类路径加载类;以及系统类加载器(system class loader),它是新类加载器的默认委托父加载器,并且通常是用于加载和启动应用程序的类加载器。规范并未强制要求这两个类加载器的具体类型,也未明确规定它们之间的精确委托关系。

自 1.2 版本发布以来,JDK 实现了三级类加载器层次结构,其中每个加载器都会委托给下一个加载器:

  • 应用类加载器(application class loader),是 java.net.URLClassLoader 的一个实例,用于从类路径(class path)中加载类,并被安装为系统类加载器,除非通过系统属性 java.system.class.loader 指定了替代的系统加载器。

  • 扩展类加载器(extension class loader),同样是 URLClassLoader 的一个实例,用于加载通过扩展机制可用的类,同时也加载一些内置于 JDK 的资源和服务提供者。(该加载器在 Java SE 平台 API 规范中未明确提及。)

  • 启动类加载器(bootstrap class loader),完全在虚拟机内部实现,在 ClassLoader API 中以 null 表示,用于从启动类路径(bootstrap class path)加载类。

JDK 9 保留了这个三级层次结构,以保持兼容性,同时进行了以下更改来实现模块系统:

  • 应用类加载器不再是 URLClassLoader 的实例,而是内部类的实例。它是命名模块(既不是 Java SE 也不是 JDK 模块)的默认加载器。

  • 扩展类加载器不再是 URLClassLoader 的实例,而是内部类的实例。它不再通过已被 JEP 220 移除的扩展机制来加载类。然而,它确实定义了部分选定的 Java SE 和 JDK 模块,更多内容见下文。在此新角色中,该加载器被称为 平台类加载器,可以通过新的 ClassLoader::getPlatformClassLoader 方法 获取,并且将被 Java SE 平台 API 规范所要求。

  • 引导类加载器在库代码和虚拟机内部均有实现,但为了兼容性,在 ClassLoader API 中仍然由 null 表示。它定义了核心的 Java SE 和 JDK 模块。

平台类加载器的保留不仅是为了兼容性,同时也为了提升安全性。由引导类加载器加载的类型会隐式地被授予所有安全权限(AllPermission),但其中许多类型实际上并不需要所有权限。我们通过将不需要所有权限的模块定义到平台类加载器而非引导类加载器,并在默认的安全策略文件中授予它们实际所需的权限,实现了对这些模块的去特权化。定义到平台类加载器的 Java SE 和 JDK 模块包括:

java.activation*            jdk.accessibility
java.compiler* jdk.charsets
java.corba* jdk.crypto.cryptoki
java.scripting jdk.crypto.ec
java.se jdk.dynalink
java.se.ee jdk.incubator.httpclient
java.security.jgss jdk.internal.vm.compiler*
java.smartcardio jdk.jsobject
java.sql jdk.localedata
java.sql.rowset jdk.naming.dns
java.transaction* jdk.scripting.nashorn
java.xml.bind* jdk.security.auth
java.xml.crypto jdk.security.jgss
java.xml.ws* jdk.xml.dom
java.xml.ws.annotation* jdk.zipfs

(这些列表中的星号 '*' 表示可升级的模块。)

提供工具或导出工具 API 的 JDK 模块被定义为应用程序类加载器:

jdk.aot                     jdk.jdeps
jdk.attach jdk.jdi
jdk.compiler jdk.jdwp.agent
jdk.editpad jdk.jlink
jdk.hotspot.agent jdk.jshell
jdk.internal.ed jdk.jstatd
jdk.internal.jvmstat jdk.pack
jdk.internal.le jdk.policytool
jdk.internal.opt jdk.rmic
jdk.jartool jdk.scripting.nashorn.shell
jdk.javadoc jdk.xml.bind*
jdk.jcmd jdk.xml.ws*
jdk.jconsole

所有其他的 Java SE 和 JDK 模块都定义为引导类加载器(bootstrap class loader):

java.base                   java.security.sasl
java.datatransfer java.xml
java.desktop jdk.httpserver
java.instrument jdk.internal.vm.ci
java.logging jdk.management
java.management jdk.management.agent
java.management.rmi jdk.naming.rmi
java.naming jdk.net
java.prefs jdk.sctp
java.rmi jdk.unsupported

这三个内置的类加载器协同工作,按照以下方式加载类:

  • 应用类加载器首先搜索所有内置加载器定义的命名模块。如果某个合适的模块定义到了这些加载器之一,那么该加载器将加载这个类。如果在这些加载器定义的命名模块中未找到某个类,则应用类加载器会委托给其父加载器。如果其父加载器未能找到该类,则应用类加载器会搜索类路径。在类路径上找到的类将作为此加载器未命名模块的成员进行加载。

  • 平台类加载器搜索所有内置加载器定义的命名模块。如果某个合适的模块定义到了这些加载器之一,那么该加载器将加载这个类。(因此,平台类加载器现在可以委托给应用类加载器,这在升级模块路径上的模块依赖于应用模块路径上的模块时非常有用。)如果在这些加载器定义的命名模块中未找到某个类,则平台类加载器会委托给其父加载器。

  • 启动类加载器搜索定义到自身的命名模块。如果在启动加载器定义的命名模块中未找到某个类,则启动类加载器会搜索通过 -Xbootclasspath/a 选项添加到启动类路径的文件和目录。在启动类路径上找到的类将作为此加载器未命名模块的成员进行加载。

应用程序类加载器和平台类加载器会委托给各自的父加载器,以确保当在定义到某个内置加载器的模块中找不到类时,仍然会搜索引导类路径。

已移除:Bootstrap 类路径选项

在早期的版本中,-Xbootclasspath 选项允许覆盖默认的引导类路径,而 -Xbootclasspath/p 选项允许将一系列文件和目录添加到默认路径的前面。此路径的计算值通过 JDK 特定的系统属性 sun.boot.class.path 报告。

由于引导类是从各自的模块中加载的,因此在模块系统就位的情况下,默认情况下引导类路径是空的。javac 编译器仅在传统模式下支持 -Xbootclasspath 选项,java 启动器不再支持这些选项,并且系统属性 sun.boot.class.path 已被移除。

编译器的 --system 选项可用于指定系统模块的替代来源,如上所述;其 -release 选项可用于指定替代的平台版本,详见 JEP 247(为旧平台版本编译)。在运行时,如上所述,可以使用 --patch-module 选项将内容注入到初始模块图中的模块。

一个相关的选项 -Xbootclasspath/a 允许将文件和目录追加到默认的引导类路径中。此选项以及 java.lang.instrument 包中的相关 API 有时会被检测代理使用,因此为了兼容性,它在运行时仍然受支持。如果指定了其值,则会通过 JDK 特定的系统属性 jdk.boot.class.path.append 进行报告。此选项可以传递给命令行启动器 java,也可以传递给 JNI 调用 API。

测试

许多现有的测试受到模块系统引入的影响。在 JDK 9 中,根据需要向单元测试和回归测试中添加了上述的 @modules 标签,并且更新了使用 -Xbootclasspath/p 选项或假设系统类加载器是 URLClassLoader 的测试。

当然,模块系统本身有一套广泛的单元测试。在 JDK 9 的源代码库中,运行时测试位于 jdk 仓库的 test/jdk/modules 目录和 hotspot 仓库的 runtime/modules 目录中;编译时测试则位于 langtools 仓库的 tools/javac/modules 目录中。

在模块系统开发期间,始终可以获取包含此处所述更改的早期访问版本。强烈鼓励 Java 社区成员针对这些版本测试他们的工具、库和应用程序,以帮助识别兼容性问题。

风险与假设

该提案的主要风险在于对现有语言结构、API 和工具的更改可能引发的兼容性问题。

主要由于引入 Java 平台模块系统 (JSR 376) 而产生的变化包括:

  • public 修饰符应用于 API 元素不再保证该元素在任何地方都可访问。可访问性现在还取决于包含该元素的包是否由其定义模块导出或打开,以及该模块是否可被包含试图访问它的代码的模块读取。例如,以下形式的代码可能无法正常工作:

    Class<?> c = Class.forName(...);
    if (Modifier.isPublic(c.getModifiers()) {
    // 假设 c 是可访问的
    }
  • 如果一个包同时在命名模块和类路径中定义,则类路径上的包将被忽略。因此,类路径不能再用于扩展内置到环境中的包。例如,javax.transaction 包由 java.transaction 模块定义,因此不会在类路径中搜索该包的类型。此限制对于避免跨类加载器和跨模块拆分包非常重要。在编译时和运行时,可以使用升级模块路径来升级内置到环境中的模块。--patch-module 选项可用于其他临时修补。

  • ClassLoader::getResource* 方法不能再用于定位除类文件之外的 JDK 内部资源。模块私有的非类资源可以通过 Class::getResource* 方法、Module::getResourceAsStream 方法或通过 JEP 220 中定义的 jrt: URL 方案和文件系统读取。

  • java.lang.reflect.AccessibleObject::setAccessible 方法 不能用于访问未由其定义模块导出或打开的包的公共成员,也不能用于访问未由其定义模块打开的包的非公共成员;在这两种情况下,都会抛出 InaccessibleObjectException 异常。如果框架库(如序列化器)需要在运行时访问这些成员,则必须通过声明包含模块为开放模块、声明该包为开放包或通过 --add-opens 命令行选项打开该包,以向框架模块开放相关包。

  • JVM TI 代理程序不能再对在运行时环境启动早期运行的 Java 代码进行插桩。特别是,在原始阶段期间不再发送 ClassFileLoadHook 事件。VMStart 事件表示启动阶段的开始,仅在虚拟机初始化到能够加载除 java.base 以外的模块中的类之后才会发布。两个新的能力,can_generate_early_class_hook_eventscan_generate_early_vmstart,可以由精心编写的代理添加,以处理虚拟机初始化早期的事件。更多细节可以在更新后的 类文件加载钩子事件启动事件 描述中找到。

  • 安全策略文件中的 == 语法已修订为增强授予标准模块和 JDK 模块的权限,而不是覆盖它们。因此,覆盖 JDK 默认策略文件其他方面的应用程序不需要复制授予标准模块和 JDK 模块的默认权限。

定义 Java EE API 或主要用于 Java EE 应用程序的 API 的模块已被弃用,并将在未来的版本中移除。它们默认不会在类路径上的代码中解析:

  • 未命名模块的默认根模块集基于 java.se 模块,而不是 java.se.ee 模块。因此,默认情况下,未命名模块中的代码将无法访问以下模块中的 API:

    java.activation
    java.corba
    java.transaction
    java.xml.bind
    java.xml.ws
    java.xml.ws.annotation

    这是一个有意为之的选择,尽管可能会带来一些不便,主要由两个目标驱动:

    • 避免与某些流行库在相同包中定义类型时产生不必要的冲突。例如,广泛使用的 jsr305.jarjavax.annotation 包中定义了注解类型,而该包也由 java.xml.ws.annotation 模块定义。

    • 让现有的应用服务器更容易迁移到 JDK 9。应用服务器通常会覆盖这些模块中的一个或多个的内容,并且在短期内,它们最有可能通过继续将必要的非模块化 JAR 文件放置在类路径上来实现这一点。如果这些模块默认被解析,那么应用服务器的维护者将不得不采取尴尬的操作来排除它们以便覆盖它们。

    这些模块仍然是 JDK 9 的一部分。可以通过 --add-modules 选项根据需要授予类路径上的代码访问一个或多个这些模块的权限。

某些 Java SE API 的运行时行为已发生改变,不过仍然以继续遵守其现有规范的方式进行:

  • 如上所述,应用程序类加载器和平台类加载器不再是 java.net.URLClassLoader 类的实例。现有的调用 ClassLoader::getSystemClassLoader 并盲目地将结果强制转换为 URLClassLoader 的代码,或者对那个类加载器的父加载器做相同操作的代码,可能无法正常工作。

  • 如上所述,一些 Java SE 类型已被取消特权,并且现在由平台类加载器而不是引导类加载器加载。直接委托给引导类加载器的现有自定义类加载器可能无法正常工作;它们应该更新为委托给平台类加载器,平台类加载器可以通过新的 ClassLoader::getPlatformClassLoader 方法 轻松获取。

  • 由内置类加载器为命名模块中的包创建的 java.lang.Package 实例没有规范或实现版本。在以前的版本中,此信息是从 rt.jar 的清单中读取的。现有的期望 Package::getSpecification*Package::getImplementation* 方法始终返回非空值的代码可能无法正常工作。

有几处与源代码不兼容的 Java SE API 更改:

  • java.lang 包含两个新的顶级类,ModuleModuleLayerjava.lang 包会被隐式地按需导入(即 import java.lang.*)。如果现有源文件中的代码按需导入了其他包,并且该包声明了 ModuleModuleLayer 类型,同时现有代码引用了该类型,则文件在未经修改的情况下将无法编译。

  • java.lang.instrument.Instrumentation 接口声明了两个新的抽象方法:redefineModuleisModifiableModule。此接口并不打算在 java.instrument 模块之外实现。如果有外部实现,则它们在 JDK 9 中未经修改将无法编译。

  • java.lang.instrument.ClassFileTransformer 接口中声明的五个参数的 transform 方法现在是一个默认方法。该接口现在还声明了一个新的 transform 方法,该方法在加载时对类进行检测时,会将相关的 java.lang.reflect.Module 对象提供给转换器。现有的编译代码将继续运行,但使用现有的五个参数的 transform 方法作为函数式接口的现有源代码将不再能够编译。

最后,由于对 JDK 特定 API 和工具的修订而产生的变更包括:

  • JEP 260 中所述,默认情况下,编译时无法访问 JDK 的大部分内部 API。之前版本中针对这些 API 编译的现有代码会发出警告,现在将无法再编译。解决方法是通过上面定义的 --add-exports 选项来打破封装。

  • 根据 JEP 260 的描述,sun.miscsun.reflect 包中的部分关键内部 API 已被迁移到 jdk.unsupported 模块。这些包中的非关键内部 API(例如 sun.misc.BASE64{De,En}coder)已被迁移或移除。

  • 如果存在安全管理器,则需要运行时权限 accessSystemModules 才能通过 ClassLoader::getResource*Class::getResource* 方法访问 JDK 内部资源;在之前的版本中,需要读取文件 ${java.home}/lib/rt.jar 的权限。

  • 如上所述,-Xbootclasspath-Xbootclasspath/p 选项已被移除。在编译时,可以使用新的 --release 选项指定替代的平台版本(参见 JEP 247)。在运行时,可以使用上述新的 --patch-module 选项将内容注入到系统模块中。

  • JDK 特定的系统属性 sun.boot.class.path 已被移除,因为默认情况下引导类路径为空。使用此属性的现有代码可能无法正常工作。

  • JDK 特定的注解 @jdk.Exported(由 JEP 179 引入)已被移除,因为它所传达的信息现在记录在模块描述符的 exports 声明中。我们尚未发现 JDK 外部工具有使用该注解的证据。

  • 之前在 rt.jar 和其他内部构件中找到的 META-INF/services 资源文件不会出现在对应的系统模块中,因为服务提供者和依赖关系现在已在模块描述符中声明。扫描此类文件的现有代码可能无法正常工作。

  • JDK 特定的系统属性 file.encoding 可以像以前一样通过命令行中的 -D 选项设置,但它必须指定基础模块中定义的字符集。如果指定了任何其他字符集,运行时系统将无法启动。指定此类字符集的现有启动脚本可能无法正常工作。

  • 默认情况下,com.sun.tools.attach API 不再可用于将代理附加到当前进程或当前进程的祖先进程。可以通过在命令行中设置系统属性 jdk.attach.allowAttachSelf 来启用此类附加操作。

  • 在未来的版本中,默认情况下将禁用 JVM TI 代理的动态加载。为了为这一变化做好准备,我们建议允许动态代理的应用程序开始使用选项 -XX:+EnableDynamicAgentLoading 明确启用加载。选项 -XX:-EnableDynamicAgentLoading 则用于禁用动态代理加载。

依赖

JEP 200(模块化的 JDK) 最初以 XML 文档的形式定义了 JDK 中存在的模块,作为一种临时措施。此 JEP 将这些定义迁移到了适当的模块描述符中,即 module-info.javamodule-info.class 文件,并移除了根源代码存储库中的 modules.xml 文件。

JEP 220(模块化运行时镜像) 在 JDK 9 中的初始实现使用了一个自定义的构建时工具来构造 JRE 和 JDK 镜像。本 JEP 使用 jlink 工具取代了那个工具。

模块化的 JAR 文件也可以是多版本 JAR 文件,依据 JEP 238