Java 模块(Modules)

更新于 2025-12-27

Jakob Jenkov 2020-12-04

Java 模块是一种打包机制,允许你将 Java 应用程序或 Java API 打包为独立的 Java 模块。Java 模块被打包为模块化 JAR 文件。一个 Java 模块可以指定其包含的哪些 Java 包对使用该模块的其他 Java 模块可见。同时,Java 模块还必须声明它依赖哪些其他 Java 模块才能正常工作。这些内容将在本教程后续部分详细说明。

Java 模块是 Java 9 中的新特性,通过 Java 平台模块系统(JPMS)实现。Java 平台模块系统有时也被称为 Java JigsawProject Jigsaw(“Jigsaw” 是开发阶段使用的内部项目名称,后来更名为 Java 平台模块系统)。


Java 模块的优势

Java 平台模块系统为 Java 开发者带来了多项优势,以下是其中最重要的几点:

通过模块化的 Java 平台生成更小的应用程序分发包

作为 Project Jigsaw 的一部分,所有 Java 平台 API 都被拆分为独立的模块。这样做的好处在于,你可以明确指定应用程序需要哪些 Java 平台模块。知道了应用程序所需的 Java 平台模块后,Java 就可以只将这些实际用到的模块打包进你的应用程序中。

在 Java 9 和 JPMS 出现之前,你必须将整个 Java 平台 API 与你的 Java 应用程序一起打包,因为没有可靠的方法来准确判断你的应用程序到底使用了哪些类。由于 Java 平台 API 多年来不断增长,这会导致应用程序分发包变得非常庞大,其中很多类实际上并未被使用。

这些未使用的类会使你的应用程序分发包比实际所需更大,这在移动设备、树莓派等资源受限的环境中尤为成问题。有了 Java 平台模块系统,你现在可以只打包应用程序实际使用的 Java 平台模块,从而显著减小分发包体积。

内部包的封装(Encapsulation)

Java 模块必须显式声明其内部哪些 Java 包要对外导出(即对其他模块可见)。模块中未导出的包不能被其他模块使用,只能在模块内部使用。这些未导出的包也被称为隐藏包封装包

启动时检测缺失的模块

从 Java 9 开始,Java 应用程序也必须被打包为 Java 模块。因此,应用程序模块会明确声明它所依赖的其他模块(无论是 Java API 模块还是第三方模块)。这样,Java 虚拟机(JVM)在启动时就可以检查整个模块依赖图。如果发现缺少任何必需的模块,JVM 会立即报告缺失的模块并关闭。

而在 Java 9 之前,缺失的类(例如来自缺失的 JAR 文件)只有在应用程序实际尝试使用该类时才会被发现,这通常发生在运行时的某个不确定时刻。相比之下,在启动时就检测到缺失模块是一个巨大的优势。


Java 模块基础

现在你已经了解了 Java 模块的概念及其优势,接下来我们来看看 Java 模块的基本知识。

模块包含一个或多个包

Java 模块是由一个或多个属于同一逻辑单元的 Java 包组成的。一个模块可以是一个完整的 Java 应用程序、一个 Java 平台 API,或者一个第三方 API。

模块命名

Java 模块必须有一个唯一的名称。例如,一个有效的模块名可以是:

com.jenkov.mymodule

Java 模块的命名规则与 Java 包相同。不过,从 Java 9 开始,不建议在模块名(以及包名、类名、方法名、变量名等),因为 Java 计划在未来将下划线保留为特殊标识符。

建议将 Java 模块命名为其包含的根 Java 包的名称(如果可行的话;有些模块可能包含多个根包)。

模块根目录

在 Java 9 之前,所有 Java 类都直接放在根类目录(加入 classpath)或 JAR 文件的根目录下。例如,com.jenkov.mymodule 包的目录结构如下:

com/jenkov/mymodule

从 Java 9 开始,Java 平台模块系统提供了一种替代的目录结构,有助于简化 Java 源码的编译。模块可以放在与其模块名相同的根目录下。以上述模块为例,其目录结构变为:

com.jenkov.mymodule/
└── com/
    └── jenkov/
        └── mymodule/

注意:模块根目录名中的点(.)是模块名的一部分,不是路径分隔符

模块根目录既用于源文件,也用于编译后的类文件。例如,如果你的项目源码根目录是 src/main/java,那么每个模块都会有自己的模块根目录:

src/main/java/com.jenkov.module1
src/main/java/com.jenkov.module2

编译输出目录也会采用相同的结构。

即使一个项目只包含一个模块,也需要使用模块根目录。

模块描述符(module-info.java)

每个 Java 模块都需要一个名为 module-info.java 的模块描述符文件,并且必须放在对应的模块根目录下。例如:

src/main/java/com.jenkov.mymodule/module-info.java

模块描述符用于声明模块导出的包和依赖的其他模块。以下是一个基本的空模块描述符示例:

module com.jenkov.mymodule {
}
  • module 是关键字;
  • 后跟模块名;
  • 花括号内填写导出和依赖声明。

注意:虽然文件名是 module-info.java(包含连字符 -),但这是唯一允许在 .java 文件名中使用连字符的情况。

模块导出(Exports)

Java 模块必须显式导出希望其他模块访问的包。导出声明写在模块描述符中。例如:

module com.jenkov.mymodule {
    exports com.jenkov.mymodule;
}

注意:导出一个包不会自动导出其子包。例如,如果 mymodule 包下还有一个 util 子包,则 com.jenkov.mymodule.util 不会因为导出了父包而自动导出。

要导出子包,必须显式声明:

module com.jenkov.mymodule {
    exports com.jenkov.mymodule;
    exports com.jenkov.mymodule.util;
}

你也可以只导出子包而不导出父包:

module com.jenkov.mymodule {
    exports com.jenkov.mymodule.util;
}

模块依赖(Requires)

如果一个模块依赖其他模块才能工作,必须在模块描述符中声明。例如:

module com.jenkov.mymodule {
    requires javafx.graphics;
}

这表示该模块依赖标准 Java 模块 javafx.graphics

不允许循环依赖

模块之间不允许存在循环依赖。即:如果模块 A 依赖模块 B,那么模块 B 不能反过来依赖模块 A。模块依赖图必须是有向无环图(DAG)。

不允许拆分包(Split Packages)

同一个 Java 包在运行时只能由一个模块导出。换句话说,不能有两个(或更多)模块同时导出同名的包。JVM 在启动时会报错。

这种情况也称为“拆分包”(split package),即一个包的内容被分散在多个模块中,这是不被允许的。


编译 Java 模块

要编译 Java 模块,需使用 Java SDK 自带的 javac 命令(需 Java 9 或更高版本):

javac -d out --module-source-path src/main/java --module com.jenkov.mymodule
  • -d out:指定编译输出目录;
  • --module-source-path:指向源码根目录(不是模块根目录);
  • --module:指定要编译的模块名,多个模块可用逗号分隔。

编译后,out 目录下会生成一个以模块名命名的子目录,其中包含编译后的 .class 文件和 module-info.class


运行 Java 模块

使用 java 命令运行模块的主类:

java --module-path out --module com.jenkov.mymodule/com.jenkov.mymodule.Main
  • --module-path:指向包含所有已编译模块的根目录;
  • --module:格式为 模块名/主类全限定名,中间用 / 分隔。

构建 Java 模块 JAR 文件

使用标准 jar 命令打包模块:

jar -c --file=out-jar/com-jenkov-mymodule.jar -C out/com.jenkov.mymodule .
  • -c:创建新 JAR;
  • --file:输出路径;
  • -C:切换到指定目录后打包当前目录(.)内容。

JAR 文件根目录必须包含编译后的包结构和 module-info.class


设置 JAR 主类

可在打包时指定主类:

jar -c --file=out-jar/com-jenkov-mymodule.jar --main-class=com.jenkov.mymodule.Main -C out/com.jenkov.mymodule .

从 JAR 运行 Java 模块

将模块 JAR 放入模块路径后运行:

java --module-path out-jar -m com.jenkov.mymodule/com.jenkov.mymodule.Main

若 JAR 已设置主类,且不依赖其他模块,可简写为:

java -jar out-jar/com-jenkov-mymodule.jar

如果依赖其他模块,仍需提供 --module-path


打包为独立应用程序

使用 jlink 命令将模块及其依赖、JRE 一起打包为独立应用(无需用户预装 Java):

jlink --module-path "out;C:\Program Files\Java\jdk-9.0.4\jmods" \
      --add-modules com.jenkov.mymodule \
      --output out-standalone
  • --module-path:包含自定义模块和 JDK 的 jmods 目录;
  • --add-modules:要包含的模块;
  • --output:输出目录(必须不存在)。

运行独立应用

进入输出目录,执行:

bin/java --module com.jenkov.mymodule/com.jenkov.mymodule.Main

未命名模块(Unnamed Module)

Java 9 之后,所有类都必须属于某个模块。对于旧版 JAR 或类文件,可通过 -classpath 加载,这些类会被放入 未命名模块

特点:

  • 未命名模块导出所有包;
  • 只有其他未命名模块或自动模块能读取它,命名模块无法读取;
  • 若命名模块和未命名模块包含同名包,优先使用命名模块的包;
  • 未命名模块隐式依赖所有模块路径上的模块。

自动模块(Automatic Modules)

当你将非模块化的 JAR(如 Java 8 编写的库)放在 模块路径(而非 classpath)上时,JVM 会将其转换为 自动模块

特点:

  • 自动模块可读取所有命名模块和未命名模块;
  • 所有命名模块可读取自动模块(但仍需在 requires 中声明);
  • 自动模块名由 JAR 文件名转换而来(如 mylib-1.0.jarmylib,连字符转为点);
  • 同样受“拆分包”限制。

服务(Services)

Java 9 引入了基于模块系统的 服务机制,包含:

  1. 服务接口模块:仅定义接口;
  2. 服务实现模块:提供具体实现;
  3. 服务客户端模块:使用服务。

服务接口模块

只需正常导出接口所在包:

module com.jenkov.myservice {
    exports com.jenkov.myservice;
}

服务实现模块

需:

  1. requires 接口模块;
  2. 实现接口;
  3. module-info.java 中声明实现:
module com.blabla.myservice {
    requires com.jenkov.myservice;
    provides com.jenkov.myservice.MyService with com.blabla.myservice.MyServiceImpl;
}

服务客户端模块

需声明使用服务:

module com.client.myserviceclient {
    requires com.jenkov.myservice;
    uses com.jenkov.myservice.MyService;
}

运行时通过 ServiceLoader 查找实现:

Iterable<MyService> services = ServiceLoader.load(MyService.class);

模块版本控制

Java 平台模块系统本身不支持模块版本管理。你不能在模块路径上放置同一模块的多个版本。

版本管理应交由构建工具(如 Maven、Gradle)处理。


多版本模块 JAR 文件(Multi-Release JAR)

从 Java 9 开始,JAR 可包含针对不同 Java 版本编译的类:

META-INF/
├── MANIFEST.MF
└── versions/
    ├── 9/
    │   └── com/...
    └── 10/
        └── com/...
com/...          # Java 8 及以下版本使用

MANIFEST.MF 中需添加:

Multi-Release: true

JVM 会根据运行时版本自动选择对应目录下的类。


迁移到 Java 9

JPMS 设计了两种迁移策略:

自底向上(Bottom-up)

先迁移底层工具库,再迁移主应用。

自顶向下(Top-down)

先迁移主应用,再逐步迁移依赖库。

推荐迁移步骤:

  1. 升级到 Java 9,不修改任何代码,将所有类/JAR 放在 classpath(进入未命名模块);
  2. 将工具库 JAR 移至模块路径,成为自动模块
  3. 逐步将内部/第三方库改为完整模块(从无依赖的开始);
  4. 最后将主应用改为命名模块。

如果条件允许,一次性全面升级是最理想的方案,避免长期混用不同模块类型。