27.1 Java模块系统概述
Java 模块系统自 Java 9 引入,旨在解决大型应用程序中依赖管理和模块化的问题。模块是一个包含相关包、资源(如 XML 文件)以及模块描述符的唯一命名的可重用单元。模块的核心是 module-info.java 文件,它定义了模块的名称、依赖关系、导出包等信息。
模块的主要特性:
-
依赖管理:模块系统通过 requires 关键字声明依赖关系,自动检测循环依赖并在启动时终止运行。模块间的依赖关系清晰且受控。
-
精简 JRE:通过工具如 jlink,开发者可以根据实际需求裁剪 JRE,仅保留必要模块,从而减少体积和内存占用。
-
访问控制:模块通过 exports 和 opens 精确控制包的可见性,提升了安全性和封装性。
-
开发效率提升:模块化使得代码边界清晰,单元测试独立,开发者只需关注各自负责的模块。
简单来说,一个模块就是一组包。一个模块可以(但并非必须)包含诸如图片、属性文件等资源。示意图如下:

目前,我们只关注一个模块就是一组包这一概念。一个模块规定了其包对其他模块的可访问性以及它对其他模块的依赖关系。模块中包的可访问性决定了其他模块是否能够访问该包。模块的依赖关系决定了该模块读取的其他模块的列表。
默认情况下,模块中的包仅在该模块内部可访问。如果模块中的某个包需要在模块外部可访问,包含该包的模块必须导出该包。一个模块可以将其包导出给所有其他模块,也可以仅导出给选定的其他模块列表。如果一个模块想要访问另一个模块中的包,第一个模块必须声明对第二个模块的依赖关系,并且第二个模块必须导出包,以便第一个模块能够访问它们。
自从Java 9 引入了模块系统,JDK的源码组织也发生了变化,它将JDK中的APi进行了模块化划分,可以使用以下命令查看JDK中的模块:
java --list-modules
结果如下:
java.base@25
java.compiler@25
java.datatransfer@25
java.desktop@25
java.instrument@25
java.logging@25
java.management@25
java.management.rmi@25
java.naming@25
java.net.http@25
java.prefs@25
java.rmi@25
java.scripting@25
java.se@25
java.security.jgss@25
java.security.sasl@25
java.smartcardio@25
java.sql@25
java.sql.rowset@25
java.transaction.xa@25
java.xml@25
java.xml.crypto@25
jdk.accessibility@25
jdk.attach@25
jdk.charsets@25
jdk.compiler@25
jdk.crypto.cryptoki@25
jdk.crypto.ec@25
jdk.crypto.mscapi@25
jdk.dynalink@25
jdk.editpad@25
jdk.graal.compiler@25
jdk.graal.compiler.management@25
jdk.hotspot.agent@25
jdk.httpserver@25
jdk.incubator.vector@25
jdk.internal.ed@25
jdk.internal.jvmstat@25
jdk.internal.le@25
jdk.internal.md@25
jdk.internal.opt@25
jdk.internal.vm.ci@25
jdk.jartool@25
jdk.javadoc@25
jdk.jcmd@25
jdk.jconsole@25
jdk.jdeps@25
jdk.jdi@25
jdk.jdwp.agent@25
jdk.jfr@25
jdk.jlink@25
jdk.jpackage@25
jdk.jshell@25
jdk.jsobject@25
jdk.jstatd@25
jdk.localedata@25
jdk.management@25
jdk.management.agent@25
jdk.management.jfr@25
jdk.naming.dns@25
jdk.naming.rmi@25
jdk.net@25
jdk.nio.mapmode@25
jdk.sctp@25
jdk.security.auth@25
jdk.security.jgss@25
jdk.unsupported@25
jdk.unsupported.desktop@25
jdk.xml.dom@25
jdk.zipfs@25
27.2 平台模块
JCP 团队在对 Java 平台进行模块化改造方面投入了大量精力。其中最艰巨的任务是研究和评估不同库部分之间的依赖关系,并将 JDK 中的所有类分离出来并放入各个模块中。
平台模块是将 JDK 分割后得到的模块集合。它们完全取代了单一的 JDK,并使我们能够创建自定义的运行时映像。这些映像可以由特定配置组成,该配置包含一组模块及其传递依赖项。这组模块可以代表一个模块或多个模块。它也可以代表所有模块,这相当于整个 JDK。还可以将平台模块与我们自己创建的模块结合在一起,以形成运行时映像。
每个模块都有确定的功能,并可以定义对其他模块的依赖关系。
平台模块是 Java 运行时的一部分,并包含源代码。平台模块能够导出其包,以便其他读取这些包的模块可以访问它们。当我们谈及模块时,所指的不仅包括平台模块,还包括应用程序开发人员所创建的模块。这些模块并没有特定的定义。我们可以称它们为开发者模块或程序员模块,以便将它们与默认作为平台一部分的模块区分开来,这些模块即为平台模块。
平台模块分为两种类型:标准模块和非标准模块。
标准模块
标准模块由 Java 社区进程(JCP)进行管理。标准的 Java SE 模块的名称以 “java.” 开头。这些名称足够明确,因此很容易想象该模块的作用。例如,名为 “java.rmi” 的模块定义了远程方法调用 API,而名为 “java.logging” 的模块定义了 Java 日志 API。一个标准模块可以包含标准的 API 包以及非标准的 API 包。它还可以依赖于一个或多个非标准模块。
非标准模块
这些非标准模块是特定于 JDK 的。它们的名称以"jdk." 开头。非标准模块包含包和特定的 JDK 代码,这些代码在不同的 Java 开发工具包实现之间可能有所不同。一些 JDK 模块,例如工具或服务提供者,并不对外输出任何内容,这意味着它们在模块外部是不可见的。
有两件事非常重要需要记住:首先,非标准模块不能导出标准 API 包,因此它们的可见性对外部保持隐藏状态。其次,也是最重要的一点,要牢记的是,仅依赖于 Java SE 模块的源代码将仅依赖于标准的 Java SE 类型。这是一个很大的优势,因为这样代码就可以移植到 Java SE 平台的所有现有实现中。
将 JDK 特定的 API 转换为 Java 标准 API 是可能的,但需要特别关注兼容性。在考虑这样做时,您应该通过查看其使用方式来考虑其可行性及必要性。例如,Java 调试接口之所以没有被转化为标准 API,是因为它仅被工具和调试器所使用,所以将其改进为 Java 标准 API 的一部分显然没有意义。
每个平台模块都包含一个名为 classes 的文件夹,该文件夹位于一个名为 share 的文件夹之下。classes 文件夹中包含了构成该模块的所有类,以及一个名为 moduleinfo.java 的文件中所包含的模块描述符。有些模块,例如 java.base 模块,具有针对不同操作系统(如 Windows、Linux、macOS 等)的原生代码。
27.3 创建示例项目
为了演示方便,我使用IDEA创建了一个空项目,其中创建了两个模块(这里的模块与本小节的模块不是一个概念,为避免混淆,以下我称之为项目1和项目2)。结构如下:
在项目1中,声明一个类,位于com.laotan.module1包中:
package com.laotan.module1;
/**
*
* @author 老谭
*/
public class M1Demo1 {
}
在项目2中,需要在pom.xml中新增对项目1的依赖:
<dependency>
<groupId>com.laotan</groupId>
<artifactId>module1</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
然后创建一个类,位于com.laotan.module2包中:
package com.laotan.module2;
import com.laotan.module1.M1Demo1;
/**
*
* @author 老谭
*/
public class M2Demo1 {
static void main() {
var demo1 = new M1Demo1();
}
}
此时可以看到这种结构中,项目2中的代码中可以访问项目1中的类。
27.4 声明模块
为了使项目支持模块化,需要在src根目录中创建一个特殊的文件,名称是 module-info.java。其内容语法结构如下:
[导入语句]
[open] module <模块名> {
<子句1>;
<子句2>;
...
}
在一个模块声明内部,总共可以有五种类型的子句:
- requires 子句指明当前模块所依赖的模块
- exports 子句指明当前模块所导出的包
- provides 子句指明当前模块所提供的服务实现
- uses 子句指明当前模块所使用的服务
- opens 子句指明当前模块为深度反射而开放的包
下面我先以一个语法合法的最简单的模块声明为例,在第一个项目中的src/main/java目录或其任意子目录(包)和Java文件上右键,可以点击 New/module-info.java,产生的文件可以修改模块名为 com.laotan.module1:
module com.laotan.module1 {
}
该文件产生后会存放在src/main/java文件夹中。
module 关键字用于声明一个模块。模块声明可以(但并非必须)以 open 关键字开头,以声明一个开放模块(稍后会对此进行详细说明)。module 关键字之后跟着一个模块名称。模块名称是由一个或多个 Java 标识符通过点分隔而成的序列,类似于包名。
关于模块名的命名,需要注意一下几点:
- 必须为模块指定一个名称,且同一个代码库中的两个模块不能有相同的名称
- 按照惯例,给模块命名时应与给包命名的方式相同:按照域名的逆序方式来命名。因此,模块的名称可以是其导出的包名称的前缀,但可以根据自己的意愿来命名模块,因为对模块名称的格式没有任何限制
- 模块的名称符合 Java 中标识符的一般规则,模块可以与 Java 类或接口具有相同的名称,因为模块的名称有自己的命名空间
在项目1中声明了module-info.java后,该项目即支持模块化,但此时,没有模块化的项目2中仍然可以正常发问项目1中的类。
如果以此类推,在项目2中也创建一个module-info.java文件,内容保留为空,模块名为 com.laotan.module2:
module com.laotan.module2 {
}
这个时候,会发现之前能使用到的项目1中的类就无法导入了。
package com.laotan.module2;
// 出错信息:Package 'com.laotan.module1' is declared in module 'com.laotan.module1', which does not export it to module 'com.laotan.module2'
import com.laotan.module1.M1Demo1;
/**
*
* @author 老谭
*/
public class M2Demo1 {
static void main() {
var demo1 = new M1Demo1();
}
}
出错信息翻译过来就是:com.laotan.module1 包在模块 com.laotan.module1 中声明,是否为导出它到 com.laotan.module2 模块中?
所以支持模块化的项目之间仅仅声明空的module-info.java文件是不够的。为了项目2中能正常访问到项目1中的类,需要做两件事:
-
在项目2中使用 requires 关键字指定需要使用哪个模块中的成员
-
在项目1中使用 exports 关键字导出能被其他模块访问的包
27.5 requires子句
requires 子句用于模块声明(module-info.java)内部,用于表明实际模块在实现其依赖项时所需要依赖的模块。它用于表达模块的依赖关系。其语法如如下:
requires [transitive] [static] <模块名> ;
语法简单明了,requires 关键字指定其依赖的模块的名称,后面跟着一个分号。在模块声明的大括号内部,可以使用一个或多个 requires 子句,每个子句后面跟着一个模块的名称。
比如在项目2的模块声明文件中使用 requires 表明对 com.laotan.module1 模块的依赖:
module com.laotan.module2 {
requires com.laotan.module1;
}
如果我们尝试运行模块 com.laotan.module1,那么首先会进行一次解析。解析指的是一个搜索和发现模块所需模块的过程。在类路径中找到的所有模块都会被搜索,而找到的模块还会再次被搜索以查找其依赖项。这个过程会一直持续下去,直到覆盖了所有所需的模块,并且解决了每个所需模块的所有依赖项为止。我的例子中,解析过程很简单,因为模块 com.laotan.module1 只需要一个模块:模块 com.laotan.module2 。假设最后这个模块不会依赖于其他模块,解析过程就成功完成了。如果模块 com.laotan.module2 有其他依赖项,这些依赖项也会被解析。
需要留意的是:每个模块都隐式地依赖于 java.base 模块,所以在模块声明文件中声明 requires java.base 是不必要的。
如果模块声明文件中没有包含任何 requires 子句,那么该模块除了 java.base 模块外,与其他任何模块都没有依赖关系。java.base 模块没有 requires 子句,因为它不依赖于任何其他模块。
当然,如果 requires 子句中所使用的模块未被找到,会出现编译失败。如果重复声明了所依赖的模块也会编译错误。循环依赖也是不允许的。
transitive 关键字用于表示传递依赖,比如:
module com.laotan.module1 {
}
module com.laotan.module2 {
requires transitive com.laotan.module1;
}
module com.laotan.module3 {
requires com.laotan.module2;
}
上面的示例表示com.laotan.module3 依赖 com.laotan.module2,由于com.laotan.module2传递依赖于com.laotan.module1,所以com.laotan.module3就自动依赖于com.laotan.module1。
而 static 关键字表示在编译时是必须的,而在运行时是可选的;这在开发框架或库时比较有用。
模块之间的依赖可以使用模块图可视化表示,如:

上面的模块图表达的依赖关系一目了然 ,无需解释,稍加注意的是:
- 所有的模块隐式依赖 java.base 模块
- 不能初选循环依赖,比如上述依赖关系中不能出现 com.laotan.module4 依赖 com.laotan.module2
再回到上面的示例项目中,在项目2的模块声明中声明了requires com.laotan.module1;但是仍然无法访问项目1中的类。因为仅仅声明一个模块需要另一个模块才能访问该模块中的类型是不够的。
27.6 exports子句
exports 子句在编译时以及运行时都进行包导出的功能。它允许一个模块指定它要导出哪些包。只有被导出的包才能被其他模块使用;未被导出的包不会对任何其他模块可用。
一个模块默认情况下不会导出任何包。这意味着默认情况下,当前模块中的任何包都不可供其他模块访问使用。
在模块声明文件中,通过使用 exports 关键字并后跟包名来指定导出部分,不能使用逗号来分隔不同的包。对于每个包,都必须有一个单独的 exports 子句。也可以指定将包指定到哪些模块,不指定就使用导出到所有的模块。完整的语法如下:
exports <包名> [to <模块1> [, <模块2>...]];
在本例中,我们只要在项目1的模块声明文件中使用 exports 子句将包 com.laotan.module1 导出即可:
module com.laotan.module1 {
exports com.laotan.module1; // 这里的 com.laotan.module1 是包名
}
有几个注意的点:
- 导出的包不能重复声明
- 指定的包不包括子包,导出 com.laotan 包不能表示 com.laotan.module1 包中的类可以被导出
- 导出的包不能使用通配符
- 不能导出空包,即包中没有任何Java文件的包即为空包
27.7 小结
Java模块系统自Java 9引入,通过module-info.java文件定义模块名称、依赖关系和导出包,实现依赖管理、JRE精简和访问控制。模块分为标准模块和非标准模块,前者由JCP管理,后者为JDK特有。创建模块化项目时,需在src/main/java下添加module-info.java文件,使用requires声明依赖、exports控制包可见性。模块命名建议采用反向域名格式,与包名保持一致但无强制要求。模块系统提升了代码组织性、安全性和开发效率。

被折叠的 条评论
为什么被折叠?



