Java 技术
#JPMS#split-package#module-path#module-resolution#Java

Java 模块系统分裂包问题:JPMS 中的包冲突与模块路径解析

当多个模块导出同名包时,JPMS 会因分裂包而拒绝启动。本文以大型应用从类路径迁移到模块路径为场景,解释模块解析过程中包唯一性约束的由来,剖析导致分裂包的常见依赖结构,并给出通过模块描述符、构建工具及命令行选项诊断与修复问题的具体步骤。

一个启动失败的场景

一个大型 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-corelib-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.corelib.ext,并递归处理它们的依赖。如果所有被枚举的模块都是可观察的,解析进入第二阶段。

第二阶段构建可读性图,其中节点是模块,边表示可读性关系。对于每一对模块 A 读取 B,系统会检查 B 导出的包是否与 A 自身包含的包或 A 读取的其他模块导出的包冲突。在我们的场景中,app 同时读取 lib.corelib.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.extcom.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 创建定制运行时镜像打下基础。

资料来源

  1. Java Platform, Standard Edition Java Language Updates, Release 9
  2. JEP 261: Module System
  3. Java 模块简介 - Dev.java - Java 编程语言
  4. java.lang.module (Java SE 26 & JDK 26)