封装是面向对象编程的一个重要特性。类的声明由公有接口和私有实现构成,类可以通过只修改实现而不影响其用户的方式而得以演化。模块化系统为编程带来了大致相同的益处,模块使类和包可以有选择性地获取,从而使得模块的演化可以受控。在 Java 9 之前,Java 模块系统依赖于类加载器来实现类之间的隔离,而 Java 9 引入了一个由 Java 编译器和虚拟机支持的新的模块化系统,用来模块化基于 Java 平台的大型代码。
基本概念
在面向对象编程中,基础的构建要素就是类。类提供了封装,私有特性只能被具有明确访问权限的代码访问,即只能被其所属类中的方法访问,这使得对访问权限的推断成为可能。而包提供了更高一级的组织方式,包是类的集合。包也提供了一种封装级别,具有包访问权限的所有特性都只能被同一个包中的方法访问。
但在大型系统中,这种级别的访问控制仍显不足,因为所有公有特性可以从任何地方访问。假设我们想要修改或剔除一个很少使用的特性,但它是公有的,那么就没有办法推断这个变化所产生的影响。Java 平台的设计者们面对的就是这种情况,过去 20 年 JDK 呈跨越式发展,有些特性现在明显过时了。Java 平台的设计者们在面对规模超大且盘根错节的代码时,需要一种能够提供更多控制能力的构建机制。于是,他们设计了一个新系统,称为 Java 平台模块系统,现在成了 Java 语言和虚拟机的一部分。这个系统已经成功地用于 Java API 的模块化,如果愿意,也可以使用这个系统来模块化我们自己的应用程序。
模块是包的集合,但模块中的包名无须彼此相关。例如,java.sql 模块中就包含了 java.sql、javax.sql 和 javax.transaction.xa 这几个包。并且,模块名和包名重名也是完全可行的。
就像路径名一样,模块名是由字母、数字、下划线和句点构成的。而且和路径名一样,模块之间是没有任何层次关系的。如果有一个模块是 com.a,另一个模块是 com.a.b,那么就模块系统而言,它们是无关的。
模块定义
JDK 9 的模块定义主要包含以下内容:
- 依赖其他模块的列表
- 导出的包列表,即其他模块可以使用的列表
- 开放的包列表,即其他模块可反射访问模块的列表
- 使用的服务列表
- 提供服务的实现列表
1. requires
模块系统的设计目标之一就是模块需要明确它们的需求,使得虚拟机可以确保在启动程序之前所有的需求都能得以满足。因此,如果我们想要使用其他模块的类,需要通过 requires 关键字显式引用。但要注意,模块之间的引用不能产生环,即一个模块不能直接或间接地对自己产生依赖。
模块不会自动地将访问权限传递给其他模块。比如在 a 模块中声明了它需要 b 模块,而在 b 模块中也声明了它需要 c 模块,但是这并不会赋予 a 模块使用来自 c 模块中的包的权力。按照数学术语描述,requires 不是传递性的。通常,这种行为正是我们想要的,因为它使得需求必须明确化,但某些情况下可以放松这条限制。
如果我们需要这种模块之间的传递性,则可以使用 transitive 修饰符,如下示例:
module java.sql {
requires transitive java.xml;
......
}
通过以上声明,即可在任何声明需要 java.sql 的模块都自动地需要 java.xml 模块。
2. exports
一个模块如果想要使用其他模块中的包,就必须声明需要该模块。但是,这并不会自动使得所需模块中所有的包都可用。模块可以使用 exports 关键词来声明它的哪些包可用。例如,下面是 java.xml 模块的模块声明中的一部分:
module java.xml {
exports javax.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
exports javax.xml.namespace;
exports javax.xml.parsers;
......
}
这个模块让许多包都可用,但是通过不导出其他的包而隐藏了它们(例如:jdk.xml.internal 包)。当包被导出的时候,它的 public 和 protected 的类、接口以及成员变量,在模块的外部是可以访问的,而没有导出的包在其自己的模块之外是不可访问的,这与 Java 模块化之前很不相同。之前我们可以使用任何包中公有的类,尽管它可能并非是公有 API 的一部分。
此外,exports 语句还有一种变体,可通过 to 修饰符将其作用域窄化到指定的模块集合中。例如 javafx.base 模块包含下面的语句:
这样的语句被称为限定导出,所列的模块可以访问这个包,但是其他模块不行。过多地使用限定导出表明模块化结构比较糟糕。尽管如此,在模块化现有代码基时这种情况还是会发生。
3. opens
模块只能访问显式地由其他包导出的包。在过去,总是可以通过反射来克服这类访问权限问题。但是,在模块化的世界中,这条路再也行不通了。如果一个类在某个模块中,那么对非公有成员的反射式访问将会失败。
通过使用 opens 关键词,模块就可以打开包,从而启动对给定包中的类的所有实例进行反射式访问。开放的模块可以授权对其所有包的运行时访问,就像所有的包都用 exports 和 opens 声明过一样。但是,在运行时只有显式导出的包是可访问的。开放模块将模块系统编译时的安全性和经典的授权许可的运行时行为结合在一起。
此外,opens 关键字也支持使用 to 修饰符将其作用域限制到指定的模块集合中。
4. provides、uses
Java 通过 ServiceLoader 类提供了一种轻量级的 SPI 机制,用于将服务接口与实现匹配起来。在过去,使用这种 SPI 机制是通过将文本文件放置到包含实现类的 JAR 文件的 META-INF/services 目录中而提供给服务消费者的。而在 Java 平台模块系统中提供了一种更好的方式,与提供文本文件不同,提供服务实现的模块可以添加一条 provides 语句,它列出了服务接口(可能定义在任何模块中)以及实现类(必须是该模块的一部分)。
下面是来自 jdk.security.auth 模块的一个例子:
上述定义与 META-INF/services 文件等价。 使用它的消费模块需要包含一条 uses 语句:
module java.base {
uses javax.security.auth.spi.LoginModule;
......
}
当消费模块中的代码调用 ServiceLoader.load(Servicelnterface.class) 时,匹配的提供者类将被加载,尽管它们可能不在可访问的包中。通过 provides 和 uses 声明的效果,使得消费该服务的模块允许访问私有实现类。
模块化下的类加载器
为了保证兼容性,JDK 9 并没有从根本上动摇从 JDK 1.2 以来运行了二十年之久的三层类加载器架构以及双亲委派模型。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些应该被注意到的变动,主要包括以下几个方面。
首先,是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。这其实是一个很顺理成章的变动,既然整个 JDK 都基于模块化进行构建(原来的 rt.jar 和 tools.jar 被拆分成数十个 JMOD 文件),其中的 Java 类库就已天然地满足了可扩展的需求,那自然无须再保留
其次,平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader,现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 类中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
JDK 9 及以后的类加载器继承架构
这里虽然有 BootClassLoader 类的存在,但启动类加载器现在是在 Java 虚拟机内部和 Java 类库共同协作实现的类加载器,因此尽管有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(比如 StringBuilder.class.getClassLoader())中仍然会返回 null 来代替。
最后,JDK 9 中仍然维持着三层类加载器和双亲委派的架构,除了引导类加载器外,每个类加载器都有一个父类加载器。根据规定,类加载器会为它的父类加载器提供一个机会,以便加载任何给定的类,并且只有在某父类加载器加载失败时,它才会加载该给定类。
但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的破坏。