JPMS 模块化:module-info 与 JLink 精简运行时

深入 Java 平台模块系统,讲解模块描述符 module-info.java 的依赖导出与服务加载,拆分模块与 jdeps 分析,JLink 自定义运行时,以及 Maven 与 Spring Boot 的兼容实践

Java 平台模块系统(JPMS)是 Java 9 引入的最重大结构调整:它把「类路径上的一堆 jar」变成「边界清晰的模块」,让强封装、显式依赖与可裁剪运行时成为语言级能力。本文从模块描述符、依赖导出、服务加载、模块拆分到 JLink 精简运行时,完整解析 JPMS 的工程实践与落地约束。

前置基础可先阅读 JVM 内存模型与类加载机制 与 Spring Boot 核心原理与自动配置。

1. JPMS 背景与目标

1.1 类路径时代的痛点

类路径把不同来源的类混在一起,带来三类经典问题:

痛点后果
类名冲突两个 jar 含相同类,先加载者赢
隐式依赖编译期不知依赖谁,升级失控
无封装所有 public 类都能被外部访问

1.2 模块化核心概念

模块(Module)是比包更大的封装单元:模块声明依赖(requires)、公开接口(exports)、提供服务(provides)与消费服务(uses)。模块边界在编译与运行期都被强制检查,违者直接报错。

1.3 模块路径与类路径

JPMS 引入了与类路径并行的模块路径(--module-path)。放在模块路径上的模块遵循强封装规则,放在类路径上的代码仍按旧规则运行。迁移阶段两者可共存:

# 模块路径:显式声明的模块
java --module-path mods:lib -m order.app/com.example.Main
# 类路径:普通 jar,作为未命名模块运行
java -cp lib/*.jar com.example.LegacyMain

理解两条路径的区别,是解读大多数 JPMS 报错(Package not found、Module not found)的前提。

2. 模块描述符 module-info.java

2.1 基本语法

每个模块根目录下有一个 module-info.java,声明模块的全部意图:

// order-service/src/main/java/module-info.java
module order.service {
    requires order.api;               // 依赖 order.api 模块
    requires java.sql;                // 依赖 JDK 模块
    requires com.fasterxml.jackson.core;

    exports com.example.order.service; // 对外公开的包
}

exports 之外的包即使类是 public 也不可被外部访问,这就是强封装。

2.2 限定导出

只对指定模块开放某个包,适合内部实现共享:

module order.service {
    exports com.example.order.spi to order.extension;
}

to 之后列出允许访问的模块名,未列出的模块引用会编译失败。

2.3 requires transitive

当模块 A 公开 API 的签名引用了模块 B 的类型时,依赖方必须用 requires transitive,把 B 的依赖传递下去,否则调用方编译报「类型不可访问」:

module order.api {
    requires transitive order.model;   // 使用方自动获得 order.model
    exports com.example.order.api;
}

3. 依赖与导出

3.1 强封装与模块图

模块系统在运行期构建模块依赖图,任何 requires 缺失或循环依赖都会在启动时抛出 ResolutionException:

java -m order.service/com.example.Main
# 若依赖图中存在缺失模块,启动即失败

3.2 opens 与反射

强封装默认阻止反射访问未导出的包。像 JPA、MyBatis、Spring 这类依赖反射的框架需要显式开放:

module order.service {
    requires jakarta.persistence;
    opens com.example.order.entity to org.hibernate.orm;
    // 只向 Hibernate 开放实体包的反射访问
}

全开放(opens ... to 不带模块)或无条件开放(open module)应谨慎,会削弱封装。

3.3 未命名模块与类路径

放在类路径(而非模块路径)上的 jar 属于未命名模块,可以读取任何模块但看不到已命名模块的未导出包。这是迁移期的关键兼容机制:老应用不改代码也能运行,只是享受不到强封装。

3.4 拆包的代价与缓解

强封装与拆包(splitting)是模块化最常见的冲突。同一包名被拆到多个 jar 时,模块系统会报 Package conflict。缓解手段是让包名与模块一一对应:

拆分前:com.example.service 既在 a.jar 又在 b.jar  → 冲突
拆分后:com.example.service.api 在 order-api 模块
        com.example.service.impl 在 order-core 模块 → 不冲突

包的归属必须唯一,这是拆分模块时的第一纪律。

4. 服务加载

4.1 provides 与 uses

服务加载让模块之间以接口为契约、运行期解耦,是 JDK 的依赖注入雏形:

// order.spi 模块定义接口
module order.spi {
    exports com.example.order.spi;
}

// order.biz 模块提供实现
module order.biz {
    requires order.spi;
    provides com.example.order.spi.PriceCalculator
        with com.example.order.biz.DefaultPriceCalculator;
}

// order.web 模块消费服务
module order.web {
    requires order.spi;
    uses com.example.order.spi.PriceCalculator;
}

4.2 ServiceLoader 使用

// 消费方通过 ServiceLoader 发现所有实现
List<PriceCalculator> calculators = new ArrayList<>();
for (PriceCalculator c : ServiceLoader.load(PriceCalculator.class)) {
    calculators.add(c);
}

模块化的 provides 比传统的 META-INF/services 更可靠,且不会因类路径顺序产生歧义。

4.3 服务解析与 Spring 的兼容

Spring Boot 的自动装配走 META-INF/spring.factories 或 @AutoConfiguration,本身不依赖 JPMS 服务。若模块 A 以 provides 提供了某个接口实现,Spring 的 @Service 扫描仍需要包被 exports 到 Spring 模块或 opens 给反射扫描。

5. 拆分模块

5.1 拆分策略

把现有 jar 拆成多个模块,遵循「按依赖方向分层」的原则:

order-model(纯领域对象,无外部依赖)
  ↑
order-api(接口与 SPI,依赖 model)
  ↑
order-core(业务实现,依赖 api 与 model)
  ↑
order-app(启动入口,依赖 core)

顶层模块依赖下层,禁止反向依赖,模块图保持无环。

5.2 jdeps 分析

拆分前用 jdeps 分析现有 jar 的真实依赖,识别跨包访问与 JDK 内部 API 依赖:

jdeps --module-path . --generate-module-info out order-service.jar

--generate-module-info 能自动生成初版 module-info.java,是迁移的起点。再配合 --check 检查模块依赖图是否完备。

5.3 模块依赖图可视化

// 生成模块描述,再用 jmod 或工具画依赖图
jdeps --module-path . -s order.service
order.app → order.core → order.api → order.model
                 └──────→ java.sql, com.fasterxml.jackson.core

依赖图越小、方向越清晰,模块化的收益越明显。

5.4 增量迁移路径

已有工程迁移到 JPMS 不必一步到位,推荐四步渐进:

第 1 步:先用 jdeps 分析依赖,找出 JDK 内部 API 的调用点
第 2 步:将可模块化的核心 jar 加入模块路径,其余留在类路径
第 3 步:逐个补充 module-info.java,用 --generate-module-info 打底
第 4 步:全部收敛到模块路径后,启用 jlink 做运行时裁剪

每一步都可在 CI 中用 --check 校验模块图,把迁移风险前置到构建阶段。

jlink 基于模块描述生成最小化运行时镜像,只打包用到的模块,显著缩小 JDK 体积:

# 生成精简运行时到 target/plume-runtime
jlink --module-path "$JAVA_HOME/jmods":target/mods \
      --add-modules order.app,jdk.unsupported \
      --output target/plume-runtime \
      --strip-debug --no-man-pages --no-header-files \
      --compress=2

6.2 自定义运行时镜像

镜像生成后,目录结构与 JRE 一致,直接执行应用入口:

target/plume-runtime/bin/java -m order.app/com.example.Main

--compress=2 用 zip 压缩类文件,进一步缩小镜像体积;--strip-debug 移除调试信息。镜像内自带精简的 java.base 与依赖模块,不含无关模块。

6.3 与 Docker 结合

精简运行时配合基础镜像,能做出体积很小的容器:

FROM alpine:3.20
COPY target/plume-runtime /opt/plume
COPY target/app /app
ENTRYPOINT ["/opt/plume/bin/java", "-m", "order.app/com.example.Main"]
完整 JDK 镜像约 300MB,jlink 精简运行时加 Alpine 可压到 50MB 以内

7. 与 Maven 和 Spring Boot 兼容性

7.1 Maven 模块与自动模块

Maven 的多模块工程与 JPMS 模块不是一回事:前者是构建期聚合,后者是运行期边界。未提供 module-info.java 的第三方 jar 在模块路径上会自动成为「自动模块」(模块名取自 jar 文件名),可以被 requires。

<!-- Maven 多模块与 JPMS 并存:每个模块 src/main/java/module-info.java -->
<modules>
  <module>order-model</module>
  <module>order-api</module>
  <module>order-core</module>
  <module>order-app</module>
</modules>

7.2 Spring Boot 模块化支持

Spring Boot 3 基于 Java 17,整体可作为自动模块被依赖,但完整模块化需要逐个 open:

module order.app {
    requires spring.boot;
    requires spring.boot.autoconfigure;
    requires spring.context;
    opens com.example.order.app to spring.core, spring.beans, spring.context;
    uses com.example.order.spi.PriceCalculator;
}

启动类所在包必须 opens 给 Spring 容器,否则 @ComponentScan 扫描不到。

7.3 常见坑

坑现象对策
使用 JDK 内部 APIIllegalAccessError用标准 API 替代或 --add-exports 兜底
第三方依赖非模块找不到模块放类路径或接受自动模块
反射包未 opens运行期空指针/代理失败逐个 opens 给对应框架
模块名与 jar 冲突启动解析失败统一用倒置域名命名
资源文件封装getResourceAsStream 读不到用 Module.getResourceAsStream

7.4 运行时参数兜底

对存量系统,启动参数是模块化的「逃生舱」。--add-exports、--add-opens 可在不改代码的情况下放行特定访问:

java --add-opens java.base/java.lang=order.app \
     --add-exports java.base/sun.nio.ch=ALL-UNNAMED \
     -m order.app/com.example.Main

这些参数是临时的兼容手段,应记录在构建脚本中并逐步消除,避免长期依赖成为新的技术债。

8. 总结

主题核心要点
模块边界requires 声明依赖,exports 公开接口
强封装未导出包不可访问,反射需 opens
服务加载provides 实现、uses 消费、ServiceLoader 发现
模块拆分按依赖方向分层,jdeps 辅助分析
JLink按需打包模块,运行时镜像体积大幅缩小
兼容落地Maven 多模块并存,Spring 需 open 启动包

JPMS 的价值不在「必须模块化」,而在于给了架构以语言级约束力:依赖显式、封装强制、运行时可裁剪。对追求极致交付体积的云原生场景,JLink 精简运行时是立竿见影的收益;对成熟微服务,按模块拆分则能长期守护依赖边界的干净。理解 module-info 与模块路径的运行规则,是 Java 工程师面向未来的必修课。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java-enterprise」更多文章

  1. CDC 数据同步:Debezium 与 Kafka 架构实战
  2. 可观测性工程:Micrometer 指标模型与 OTLP 导出
  3. 多租户 SaaS 架构:隔离模型与租户上下文传递