一个启动失败的场景
一个大型 Java 应用在从传统的类路径部署迁移到 Java 模块系统(JPMS)时,经常会在启动瞬间遇到这样的错误:
Error occurred during initialization of boot layer
java.lang.module.ResolutionException: Modules moduleA and moduleB export package com.example.util to module app
这条消息指向了一个在类路径下从未出现过的问题——分裂包(split package)。在类路径下,多个 JAR 包含同名包是完全合法的,类加载器会按照顺序合并它们。但在模块路径下,JPMS 强制要求每个包只能由一个模块导出,一旦两个模块都导出了同一个包,模块解析就会失败。
本文将以一个典型的迁移场景为主线:一个名为 app 的应用模块依赖于两个库模块 lib-core 和 lib-ext,两者都导出了 com.example.util 包。我们将跟随模块解析的完整过程,理解分裂包为何被禁止,并掌握诊断和修复这类问题的具体方法。
模块解析如何发现分裂包
模块解析是 JPMS 在编译时和运行时都会执行的一个两阶段过程:第一步递归枚举模块依赖,第二步计算可读性图。分裂包检测发生在第二步。
当执行 java --module-path mods --module app 时,模块系统首先将 app 作为根模块,查找其模块描述符 module-info.class,从中读取 requires 指令。假设 app 的声明如下:
module app {
requires lib.core;
requires lib.ext;
}
解析器会在模块路径上定位 lib.core 和 lib.ext,并递归处理它们的依赖。如果所有被枚举的模块都是可观察的,解析进入第二阶段。
第二阶段构建可读性图,其中节点是模块,边表示可读性关系。对于每一对模块 A 读取 B,系统会检查 B 导出的包是否与 A 自身包含的包或 A 读取的其他模块导出的包冲突。在我们的场景中,app 同时读取 lib.core 和 lib.ext,而两者都导出了 com.example.util。此时,app 的可读性图中出现了两个模块向它导出同名包的情况,这直接触发了 ResolutionException。
这一约束的根源在于 JPMS 的强封装原则:模块边界必须在编译时和运行时都得到保证。如果允许分裂包,那么同一个包中的类型可能来自不同模块,导致类型解析的不确定性,也破坏了模块作为自包含单元的可维护性。
分裂包的产生根源
分裂包并非模块系统本身的设计缺陷,而是历史遗留的依赖结构在模块化迁移中被暴露出来。常见模式包括:
- 公共工具包被多个模块重新打包:例如
commons-lang的早期版本被多个库以非标准方式嵌入,导致每个库都包含一份org.apache.commons.lang。 - API 与实现分离不彻底:一个接口包被同时放在 API 模块和实现模块中,两者都导出该包。
- 多模块项目共享同一个基础包:例如一个多模块 Maven 项目的
common模块和service模块都定义了com.example.shared包,但各自只包含部分类。
在类路径下,这些问题被类加载器的合并行为掩盖了。类加载器遇到同名包时,会简单地将所有类合并到同一个逻辑包中,只要类名不重复就不会报错。但模块系统要求每个包有唯一的归属模块,因为模块是依赖解析和封装的原子单元。
诊断分裂包的具体步骤
当遇到 ResolutionException 时,第一步是确定冲突的包和涉及的模块。错误消息通常会明确指出包名和模块名,如开头所示。但有时消息可能不够清晰,尤其是在涉及自动模块或传递依赖时。
可以使用 --show-module-resolution 选项观察解析过程:
java --show-module-resolution --module-path mods --module app
输出会列出每个模块被解析的原因以及可读性边的建立过程。如果解析失败,最后几行通常会指出冲突。
另一个有用的工具是 jar --describe-module,它可以查看模块 JAR 的导出信息:
jar --describe-module --file=lib-core.jar
输出示例:
lib.core jar:file:///.../lib-core.jar/!module-info.class
exports com.example.util
requires java.base mandated
通过对比冲突模块的描述符,可以快速确认它们是否都导出了同一个包。
在构建工具层面,Maven 和 Gradle 的插件(如 maven-jar-plugin 配合 module-info.java)可以在打包阶段检测分裂包,但前提是模块描述符已经正确配置。
修复方案一:调整模块导出与依赖
最直接的修复是消除分裂包,使每个包只由一个模块导出。根据具体情况,可以选择以下策略:
合并模块
如果两个模块在逻辑上紧密相关,且分裂包是由于历史拆分不当造成的,可以考虑将它们合并为一个模块。例如,将 lib-ext 的代码移入 lib-core,并删除 lib-ext 模块。
重新分包
如果两个模块确实应该独立,但无意中共享了包名,可以重命名其中一个模块中的包。例如将 lib-ext 中的 com.example.util 改为 com.example.ext.util。这需要修改源代码和所有引用该包的代码。
隐藏内部包
如果某个模块的包并不打算被外部使用,只是由于历史原因被导出,可以简单地从 module-info.java 中删除 exports 指令。例如,lib-ext 可能只在内部使用 com.example.util,并不需要让 app 访问它。修改后的描述符:
module lib.ext {
// 不再导出 com.example.util
exports com.example.ext;
}
此时 app 只能看到 lib.core 导出的 com.example.util,冲突消失。
修复方案二:使用命令行选项临时规避
在无法立即修改模块描述符的情况下(例如依赖第三方库),JPMS 提供了命令行选项来调整模块的可读性和导出行为。这些选项主要用于诊断和临时过渡,不应作为永久解决方案。
—add-reads 打破可读性
--add-reads 允许一个模块读取另一个模块,即使没有 requires 指令。但在分裂包场景中,它不能直接解决冲突,因为冲突源于两个模块向同一个读取者导出同名包。不过,如果能够通过重构让 app 不再直接读取冲突的某一方,则可以配合其他选项使用。
—add-exports 限定导出
--add-exports 可以将一个模块的包导出到特定的模块,而不是所有模块。如果 lib.ext 的 com.example.util 只需要被某个特定模块使用,可以在启动时指定:
java --module-path mods \
--add-exports lib.ext/com.example.util=specific.module \
--module app
但这要求 app 本身不读取 lib.ext,否则冲突依然存在。
—patch-module 覆盖模块内容
--patch-module 可以用类路径上的类覆盖模块中的包。在极端情况下,可以用它来临时修补一个模块,移除或替换冲突的包。但这种方式非常脆弱,且容易引入类版本不一致的问题,不推荐在生产环境使用。
下表总结了不同修复方案的适用场景和权衡:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 合并模块 | 两个模块逻辑上不可分 | 彻底消除冲突,简化依赖 | 可能破坏模块职责单一性 |
| 重新分包 | 包名冲突但模块独立 | 保持模块独立,符合最佳实践 | 需要修改大量代码和引用 |
| 隐藏内部包 | 包不构成公开 API | 改动最小,立即生效 | 可能影响反射或内部调用 |
| —add-exports | 第三方库无法修改 | 快速绕过,适合临时调试 | 增加启动复杂度,非永久方案 |
| —patch-module | 需要临时替换包内容 | 灵活,可覆盖任意类 | 脆弱,易导致版本混乱 |
模块化迁移的边界与未解决问题
分裂包问题揭示了从类路径到模块路径迁移的一个根本边界:模块系统要求明确的包归属,而类路径下的松散合并则没有这一概念。在迁移大型应用时,除了分裂包,还会遇到以下相关挑战:
- 自动模块的传递依赖:自动模块会读取所有其他自动模块,如果多个自动模块包含同名包,同样会触发分裂包错误。
- 反射访问内部 API:类路径下通过反射访问的
sun.misc.Unsafe等内部 API 在模块化后被强封装,需要--add-opens选项才能继续工作。 - 服务加载器与分裂包:
ServiceLoader在模块化环境下会从所有可观察模块中查找服务提供者,如果服务接口包被分裂,加载过程可能失败。
目前,JPMS 没有提供内置的版本选择机制,因此当多个模块包含同名包的不同版本时,只能通过构建工具在模块路径上确保只有一份拷贝。这要求开发者在依赖管理上更加严格,例如使用 Maven 的 dependencyManagement 或 Gradle 的依赖约束来统一版本。
从分裂包到稳定模块化
分裂包错误是 JPMS 在保障模块边界清晰性时的一种强制约束。它迫使开发团队正视历史依赖中的结构性问题,而不是继续依赖类路径下的偶然合并。修复过程虽然可能涉及代码重构,但最终会带来更清晰的模块职责和更可靠的封装。
在迁移过程中,建议采用渐进式策略:先通过 --add-exports 等选项让应用跑起来,然后逐步消除分裂包,最终移除所有临时选项。同时,利用 jdeps 工具分析依赖关系,提前发现潜在的分裂包风险。模块化不是一次性的切换,而是一个持续清理依赖结构的过程。
以下流程图展示了从启动失败到最终修复的典型排查路径:
flowchart TD
A[启动模块化应用] --> B{是否抛出 ResolutionException?}
B -- 是 --> C[从错误消息提取包名和模块名]
B -- 否 --> D[应用正常启动]
C --> E[使用 jar --describe-module 查看模块导出]
E --> F{确定分裂包来源}
F --> G[选择修复策略]
G --> H{能否修改模块源码?}
H -- 能 --> I[合并模块/重命名包/移除导出]
H -- 不能 --> J[使用 --add-exports 等命令行选项]
I --> K[重新构建并启动]
J --> K
K --> L{是否仍有错误?}
L -- 是 --> C
L -- 否 --> D
最终,一个没有分裂包的模块图会让应用的结构更接近其真实的依赖关系,也为后续使用 jlink 创建定制运行时镜像打下基础。