JEP 261: Module System | 模块系统
摘要
实现 Java 平台模块系统,根据 JSR 376 的规范,以及相关的 JDK 特定的变更和增强。
描述
Java 平台模块系统(JSR 376) 指定了对 Java 编程语言、Java 虚拟机和标准 Java API 的变更和扩展。这个 JEP 实现了该规范。因此,javac
编译器、HotSpot 虚拟机和运行时库将模块作为一种全新的 Java 程序组件,并在开发的所有阶段提供了可靠的配置和强大的封装。
这个 JEP 还改变、扩展和添加了与编译、链接和执行相关的 JDK 特定工具和 API,这些超出了 JSR 的范围。与其他工具和 API 的相关变更,例如 javadoc
工具和 Doclet API,是单独 JEP 的主题。
这个 JEP 假设读者熟悉最新的模块系统状态文档,以及其他Project Jigsaw的 JEP:
阶段
在编译时(javac
命令)和运行时(java
运行时启动器)的熟悉阶段之外,我们增加了链接时的概念,这是两者之间的可选阶段,其中一组模块可以被组装和优化成自定义的运行时镜像。链接工具jlink
是JEP 282的主题;javac
和java
实现的许多新的命令行选项也被jlink
实现。
模块路径
javac
、jlink
和java
命令以及其他几个命令现在接受用于指定各种模块路径的选项。模块路径是一个序列,每个元素都是一个模块定义或包含模块定义的目录。每个模块定义可以是:
模块构件,即包含已编译模块定义的模块化 JAR 文件或 JMOD 文件,或者
已展开的模块目录,其名称通常为模块的名称,并且其内容是与包层次结构相对应的“展开”目录树。
在后一种情况下,目录树可以是已编译的模块定义,其中各个类和资源文件以及根目录下的module-info.class
文件填充,或者在编译时,可以是源模块定义,其中包含各个源文件和根目录下的module-info.java
文件。
模块路径与类路径非常不同:类路径是定位单个类型和资源定义的手段,而模块路径是定位整个模块定义的手段。类路径的每个元素是一个类型和资源定义的容器,即一个 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
选项的实例(下文定义)导出该包,则源模块中的代码将能够在编译时和运行时访问目标模块的包中的类型。如果模块被声明为open
或通过源模块的声明中的opens
子句打开该包,则此类代码还将能够在运行时访问目标模块的包中的类型。同样,这也适用于Module::addOpens
方法的调用或--add-opens
选项的实例(下文也有定义)。
例如,如果测试工具将一个白盒测试类注入到java.management
模块中,并且该类扩展了(假设存在的)testng
模块中的一个已导出的实用类,则可以通过以下选项来授予它所需的访问权限:
--add-reads java.management=testng
作为特殊情况,如果<target-module>
是ALL-UNNAMED
,则会从源模块添加可读性边缘到所有当前和未来的未命名模块,包括与类路径对应的模块。这允许测试框架在尚未转换为模块化形式的情况下测试模块中的代码。
打破封装
有时候为了允许一个模块访问另一个模块的一些未导出类型,必须违反由模块系统定义并由编译器和虚拟机执行的访问控制边界。这可能是为了实现白盒测试内部类型的目的,或者将不支持的内部 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
选项允许访问指定包中的公共类型。有时候需要更进一步,通过核心反射 API 的 setAccessible
方法启用对所有非公共元素的访问。--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
选项必须谨慎使用。您可以使用它们来访问库模块甚至 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
。
编译器有三种模式,每种模式都实现了其他选项。
- Legacy mode在编译环境由
-source
,-target
和--release
选项定义的情况下小于或等于 8 时启用。不能使用上述任何模块化选项。
在遗留模式下,编译器的行为基本上与 JDK 8 相同。
- 当编译环境为 9 或更高版本且未使用
--module-source-path
选项时,将启用Single-module mode。可以使用上述其他模块化选项;不能使用现有的选项-bootclasspath
,-Xbootclasspath
,-extdirs
,-endorseddirs
和-XXuserPathsFirst
。
Single-module mode 用于编译以传统包分层目录树组织的代码。它是简单使用遗留模式的自然替代形式
$ javac -d classes -classpath classes -sourcepath src Foo.java
如果在命令行上指定了形式为module-info.java
或module-info.class
文件的模块描述符,或者在源路径或类路径上找到该文件,则源文件将被编译为该描述符命名的模块的成员,并且该模块将是唯一的根模块。否则,如果存在--module <module>
选项,则源文件将被编译为<module>
的成员,该模块将是根模块。否则,源文件将被编译为未命名模块的成员,并且将计算根模块[如上所述]。
在此模式下可以在类路径上放置任意类和 JAR 文件,但不建议这样做,因为它相当于将这些类和 JAR 文件视为正在编译的模块的一部分。
- 当编译环境为 9 或更高版本且使用了
--module-source-path
选项时,将启用Multi-module mode。必须还使用现有的-d
选项来命名输出目录;可以使用上述其他模块化选项;不能使用现有的选项-bootclasspath
,-Xbootclasspath
,-extdirs
,-endorseddirs
和-XXuserPathsFirst
。
多模式模式用于编译一个或多个模块,其源代码以模块化的目录形式布置在模块源路径上。在此模式下,类型的模块成员资格由其源文件在模块源路径中的位置确定,因此在命令行上指定的每个源文件都必须存在于该路径的一个元素中。根模块的集合是至少指定了一个源文件的模块的集合。
与其他模式相反,在此模式下必须通过-d
选项指定输出目录。输出目录将被结构化为模块路径的元素,即它将包含自身包含类和资源文件的模块化目录。如果编译器在模块源路径上找到模块但无法找到该模块中某些类型的源文件,则它将在相应的类文件的输出目录中搜索该模块。
在大型系统中,特定模块的源代码可能分布在多个不同的目录中。在 JDK 中,例如,模块的源文件可以在任何一个目录src/<module>/share/classes
,src/<module>/<os>/classes
或build/gensrc/<module>
中找到,其中<os>
是目标操作系统的名称。为了在模块源路径中表达这一点并保留模块身份,我们允许该路径的每个元素使用大括号({
和}
)来括起逗号分隔的替代列表和一个星号(*
)来表示模块名称。然后可以将 JDK 的模块源路径写成
{src/*/{share,<os>}/classes,build/gensrc/*}
在两种模块化模式下,编译器默认情况下会生成与模块系统相关的各种警告;可以通过-Xlint:-module
选项禁用这些警告。可以通过-Xlint 选项的 exports,opens,requires-automatic 和 requires-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>
通常是linux
,macos
,solaris
或windows
之一,而<arch>
通常是x86
,amd64
,sparcv9
,arm
或aarch64
之一。
打包:模块化 JAR 文件
可以不更改地使用jar
工具创建模块化 JAR 文件,因为模块化 JAR 文件只是带有位于其根目录中的module-info.class
文件的 JAR 文件。
jar
工具实现了以下新选项,以允许在将模块打包时将其他信息插入到模块描述符中:
--main-class = <class-name>
或-e <class-name>
是原因,会将<class-name>
记录在module-info.class
文件中作为包含模块的公共静态 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:<module>/<package>(<module>/<package>)*
Add-Opens:<module>/<package>(<module>/<package>)*
每个属性的值是空格分隔的斜杠分隔的模块名称/包名称对列表。在Add-Exports
属性值中的<module>/<package>
对具有与命令行选项--add-exports <module>/<package>=ALL-UNNAMED
相同的含义。在Add-Opens
属性值中的<module>/<package>
对具有与命令行选项--add-opens <module>/<package>=ALL-UNNAMED
打包: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
子命令以与 jar
和 java
命令的 --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.debugModuleAccessChecks
当java.lang.reflect
API 中的访问检查因IllegalAccessException
或InaccessibleObjectException
失败时显示线程转储。这对于调试隐藏了异常真正原因的情况很有用,因为异常被捕获而不是重新抛出。-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
选项相同,并以相同方式工作,唯一的区别是将帮助文本写入标准输出流而不是标准错误流。
运行时产生的异常的堆栈跟踪已扩展,包括相关模块的名称和版本字符串(如果有)。ClassCastException
、IllegalAccessException
和 IllegalAccessError
等异常的详细信息字符串也已更新,包括模块信息。
已增强现有的 -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=warn
与permit
相同,只是对每个非法反射访问操作都会发出警告消息。--illegal-access=debug
与warn
相同,只是对每个非法反射访问操作都会发出警告消息和堆栈跟踪。--illegal-access=deny
禁用所有非法访问操作,除非其他命令行选项启用了这些操作,例如--add-opens
。这个模式将成为将来发布中的默认模式。
当deny
成为默认的非法访问模式时,permit
可能会至少被支持一个版本,以便开发人员可以继续迁移其代码。permit
、warn
和debug
模式以及--illegal-access
选项本身都将逐渐被删除(为了与启动脚本兼容性,不受支持的模式最有可能只是忽略,在发出警告后)。
默认模式--illegal-access=permit
旨在让您意识到在类路径上有代码至少反射访问了一些 JDK 内部 API。为了做好未来的准备,您可以使用warn
或debug
模式了解所有此类访问。对于类路径上需要非法访问的每个库或框架,有两个选择:
如果组件的维护者已经发布了一个新的修复版本,不再使用 JDK 内部 API,则可以考虑升级到该版本。
如果组件仍然需要修复,则鼓励您联系其维护者,并要求他们使用正确的导出 API 替换其对 JDK 内部 API 的使用,如果有的话。
如果您必须继续使用需要非法访问的组件,则可以使用一个或多个--add-opens
选项来只开放所需访问权限的内部包,以消除警告消息。
为了验证您的应用程序是否准备好面向未来,可以使用--illegal-access=deny
以及任何必要的--add-opens
选项运行它。剩下的非法访问错误很可能是由于编译代码对 JDK 内部 API 的静态引用导致的。您可以通过使用jdeps
工具和--jdk-internals
选项来识别这些问题。(运行时系统不会为非法的静态访问操作发出警告,因为这将需要深度虚拟机更改并降低性能。)
当检测到非法的反射访问操作时,发出的警告消息具有以下格式:
警告:非法的反射访问 $PERPETRATOR 到 $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
运行时系统会尽力抑制相同的
扩展示例
假设我们有一个应用程序模块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
类包含应用程序的入口点,我们可以直接运行这些模块:
$ 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
,则指定的包必须对测试模块同时进行导出和开放。
可以通过在TEST.ROOT
文件或任何TEST.properties
文件中将@modules
的默认参数集指定为modules
属性的值,该参数集将用于不包含此标签的目录层次结构中的所有测试。
现有的@compile
标签接受一个新选项/module=<module>
。这会使用上述定义的--module <module>
选项来调用javac
,以将指定的类作为指定模块的成员进行编译。
类加载器
Java SE 平台 API 在历史上指定了两个类加载器:引导类加载器,它从引导类路径加载类,和系统类加载器,它是新类加载器的默认委派父级,通常用于加载和启动应用程序。规范没有规定这两个类加载器的具体类型,也没有规定它们的精确委派关系。
自 1.2 版本以来,JDK 实现了一个三级层次结构的类加载器,每个加载器都委派给下一个加载器:
应用程序类加载器是
java.net.URLClassLoader
的实例,从类路径加载类,并被安装为系统类加载器,除非通过系统属性java.system.class.loader
指定了替代的系统加载器。扩展类加载器也是
URLClassLoader
的实例,加载通过扩展机制可用的类,以及一些内置于 JDK 的资源和服务提供者。(Java SE 平台 API 规范中没有明确提到这个加载器。)引导类加载器完全在虚拟机内部实现,由
ClassLoader
API 中的null
表示,它从引导类路径加载类。
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 模块都被定义给引导类加载器:
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
选项添加到引导类路径上的文件和目录。从引导类路径上找到的类将作为该加载器的未命名模块的成员进行加载。
应用程序类加载器和平台类加载器委派给各自的父加载器,以确保在类不在内置加载器定义的模块中找到时,仍然会搜索引导类路径。
已移除:引导类路径选项
在之前的版本中,-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 元素不再保证该元素在任何地方都是可访问的。可访问性现在还取决于包含该元素的包是否由定义模块导出或开放,以及包含正在尝试访问它的代码的模块是否可读取该模块。例如,以下形式的代码可能无法正常工作:javaClass<?> 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
事件。只有在 VM 初始化到可以加载除java.base
以外模块中的类的程度后,才会发出VMStart
事件,该事件表示开始启动阶段。仔细编写以处理 VM 初始化早期事件的代理可以添加两个新功能:can_generate_early_class_hook_events
和can_generate_early_vmstart
。有关详细信息,请参阅更新的class file load hook event和start event的描述。 - 安全策略文件中的
==
语法已被修改,以增加对标准和 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.jar
在javax.annotation
包中定义了注解类型,该包也由java.xml.ws.annotation
模块定义。 - 让现有应用服务器更容易迁移到 JDK 9。应用服务器经常覆盖其中一个或多个这些模块的内容,并且在短期内,它们最有可能通过继续将必要的非模块化 JAR 文件放在类路径上来这样做。如果默认情况下解析这些模块,则应用服务器的维护人员必须采取尴尬的操作来排除
一些 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
包包含两个新的顶级类:Module
和ModuleLayer
。java.lang
包以按需隐式导入的方式引入(即import java.lang.*
)。如果现有源文件中的代码以按需导入另一个包,并且该包声明了Module
或ModuleLayer
类型,而现有代码引用了该类型,则文件将无法编译通过需要进行更改。java.lang.instrument.Instrumentation
接口声明了两个新的抽象方法:redefineModule
和isModifiableModule
。该接口不打算在java.instrument
模块之外实现。如果存在外部实现,则在 JDK 9 上它们将无法编译通过,需要进行更改。在
java.lang.instrument.ClassFileTransformer
接口中声明的带有五个参数的transform
方法现在是默认方法。该接口现在还声明了一个新的transform
方法,在类加载时将相关的java.lang.reflect.Module
对象提供给变换器。现有的编译代码将继续运行,但使用现有的五参数转换方法作为功能接口的现有源代码将无法编译通过。
最后,由于 JDK 特定 API 和工具的修订,发生了以下更改:
大多数 JDK 的内部 API 在编译时默认是不可访问的,如JEP 260所述。在之前的版本中,针对这些 API 进行编译并显示警告的现有代码将无法编译通过。一个解决方法是通过
--add-exports
选项打破封装,如上所述。在
sun.misc
和sun.reflect
包中选择的关键内部 API 已经移动到jdk.unsupported
模块,如JEP 260所述。这些包中的非关键内部 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
。使用此属性的现有代码可能无法正常工作。由JEP 179引入的 JDK 特定注释
@jdk.Exported
已被删除,因为它所传达的信息现在记录在模块描述符的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.java
和module-info.class
文件,并删除了根源代码存储库中的modules.xml
文件。
JEP 220(模块化运行时映像)在 JDK 9 中的初始实现使用了一个自定义的构建时工具来构建 JRE 和 JDK 映像。该 JEP 用jlink
工具替换了该工具。
模块化 JAR 文件也可以是多版本的 JAR 文件,参考JEP 238。