JDK9 模块系统JPMS:告别classpath hell的模块化解决方案
2026/4/30大约 9 分钟
JDK9 模块系统JPMS:告别classpath hell的模块化解决方案
适读人群:遇到依赖冲突、想理解JPMS设计的Java后端开发者 | 阅读时长:约18分钟
开篇故事
2021年,我们公司有一个运行了七年的老系统,叫做"大平台"。这个系统的classpath里大概有600多个JAR包,其中光是commons-lang就有三个版本:2.6、3.8、3.12。
有一天,一个核心功能突然报错:NoSuchMethodError: org.apache.commons.lang3.StringUtils.isBlank。所有人都懵了,这个方法明明存在。排查了三个小时才发现:某个第三方库依赖了旧版commons-lang3,ClassLoader先加载了旧版本的JAR,导致新版本的方法找不到。
这就是典型的"classpath hell"(类路径地狱)。
我当时就想,如果用JDK9的模块系统,这个问题会怎么处理?后来真的在一个新项目里用了JPMS,踩了不少坑,也收获了很多。今天把整个模块系统的核心讲清楚。
一、classpath hell的根本原因
1.1 JDK8之前的类加载机制
传统classpath加载机制的问题:
classpath = [jar-a-1.0.jar, jar-a-2.0.jar, commons-lang-2.6.jar, commons-lang-3.12.jar]
ClassLoader的加载规则:
找到第一个匹配的类就加载,后面的忽略
后果:
├── 版本冲突:哪个版本被先加载完全取决于JAR的顺序
├── 包污染:任何代码都能访问classpath上所有类(无封装)
└── 启动慢:JVM启动时扫描所有JAR1.2 JPMS的三大目标
JDK9的Java平台模块系统(JPMS,Project Jigsaw,历经10年开发)解决了三个核心问题:
- 可靠配置(Reliable configuration):在启动时就检测所有模块依赖,而不是运行时才发现缺失
- 强封装(Strong encapsulation):模块可以声明哪些包对外暴露,内部包对外不可见
- 可伸缩平台(Scalable platform):JDK本身拆成了72个模块,可以按需组合
引入版本:JDK9(2017年9月)GA
关键文件:module-info.java
工具命令:java --list-modules, jdeps, jlink, jmod二、JPMS模块系统深度解析
2.1 模块的基本结构
myapp/
├── src/
│ └── com.myapp/ ← 模块根目录(名称=目录名)
│ ├── module-info.java ← 模块描述符
│ └── com/
│ └── myapp/
│ ├── Main.java
│ └── internal/
│ └── Helper.java ← 内部包,不对外暴露
└── mods/ ← 编译输出目录module-info.java是模块的核心配置文件:
// module-info.java 完整语法示例
module com.myapp {
// requires:声明依赖的模块
requires java.sql; // 普通依赖
requires transitive java.logging; // 传递依赖(使用此模块的人也能使用java.logging)
requires static java.annotation; // 编译期依赖,运行时可选
// exports:声明对外暴露的包
exports com.myapp.api; // 对所有模块暴露
exports com.myapp.internal to // 仅对特定模块暴露(限定导出)
com.myapp.test,
com.myapp.plugin;
// opens:允许反射访问(运行时)
opens com.myapp.model; // 允许所有模块反射访问
opens com.myapp.config to // 仅允许特定模块反射访问
com.fasterxml.jackson.databind; // Jackson需要反射访问
// uses:声明使用的服务接口(ServiceLoader)
uses com.myapp.spi.Plugin;
// provides:声明提供的服务实现
provides com.myapp.spi.Plugin with
com.myapp.plugin.DefaultPlugin,
com.myapp.plugin.AdvancedPlugin;
}2.2 模块系统的架构图
┌─────────────────────────────────────────────────────────────────┐
│ JPMS 模块依赖图 │
│ │
│ java.se(聚合模块) │
│ │ │
│ ├── java.desktop ──► java.prefs ──► java.xml │
│ │ │
│ ├── java.sql ──► java.xml │
│ │ └──► java.logging │
│ │ │
│ ├── java.net.http │
│ │ │
│ └── java.base(所有模块隐式依赖) │
│ ├── java.lang │
│ ├── java.util │
│ ├── java.io │
│ └── java.nio │
│ │
│ 自定义模块: │
│ com.myapp ──requires──► com.mylib ──requires──► java.sql │
│ │ │
│ └──exports──► com.myapp.api(对外暴露) │
│ com.myapp.internal(对外隐藏) │
└─────────────────────────────────────────────────────────────────┘Mermaid版本:
2.3 模块路径 vs 类路径
传统启动方式(classpath):
java -cp lib/a.jar:lib/b.jar com.example.Main
模块化启动方式(module-path):
java --module-path mods --module com.myapp/com.myapp.Main
混合模式(迁移期常见):
java --module-path mods --add-modules ALL-UNNAMED \
-cp legacy-libs/* com.example.Main2.4 四种模块类型
1. 命名模块(Named Module)
- 有module-info.java的模块
- 强封装生效
2. 自动模块(Automatic Module)
- 放在module-path上但没有module-info.java的JAR
- 模块名从JAR文件名推导
- 自动exports所有包,requires所有其他模块
- 迁移期的过渡方案
3. 未命名模块(Unnamed Module)
- classpath上的所有类
- 只有一个,代表"老世界"
- 可以访问命名模块exports的包,但命名模块不能依赖它
4. 平台模块(Platform Module)
- JDK自带的模块(java.base等)三、完整代码示例
3.1 一个完整的多模块项目
项目结构:
multimodule-demo/
├── build.sh
├── mods/ ← 编译输出
└── src/
├── com.mylib/ ← 库模块
│ ├── module-info.java
│ └── com/mylib/
│ ├── StringUtils.java
│ └── internal/
│ └── InternalHelper.java
└── com.myapp/ ← 应用模块
├── module-info.java
└── com/myapp/
└── Main.javacom.mylib/module-info.java:
// 库模块:只暴露API包,隐藏内部实现
module com.mylib {
requires java.base; // 隐式,可省略
// 只暴露API包
exports com.mylib;
// 内部包com.mylib.internal不出现在exports里,对外不可见
}com.mylib/com/mylib/StringUtils.java:
package com.mylib;
public class StringUtils {
// 公开API
public static boolean isBlank(String str) {
return str == null || str.trim().isEmpty();
}
public static String capitalize(String str) {
if (isBlank(str)) return str;
return Character.toUpperCase(str.charAt(0)) + str.substring(1).toLowerCase();
}
// 使用内部工具(外部模块无法直接访问InternalHelper)
public static String processWithInternal(String input) {
return com.mylib.internal.InternalHelper.process(input);
}
}com.mylib/com/mylib/internal/InternalHelper.java:
package com.mylib.internal;
// 这个类对外部模块不可见,即使是public的
public class InternalHelper {
public static String process(String input) {
return "processed:" + input;
}
}com.myapp/module-info.java:
// 应用模块:依赖库模块
module com.myapp {
requires com.mylib; // 显式声明依赖
requires java.logging; // 需要日志
}com.myapp/com/myapp/Main.java:
package com.myapp;
import com.mylib.StringUtils;
// import com.mylib.internal.InternalHelper; // 编译错误!包不可访问
import java.util.logging.Logger;
public class Main {
private static final Logger log = Logger.getLogger(Main.class.getName());
public static void main(String[] args) {
String result = StringUtils.capitalize("hello world");
log.info("Result: " + result);
System.out.println(result); // Hello world
// 尝试访问内部包会在编译期报错:
// com.mylib.internal.InternalHelper.process("test"); // 编译错误!
// 运行时反射访问也会失败(强封装):
try {
Class<?> clazz = Class.forName("com.mylib.internal.InternalHelper");
// java.lang.reflect.InaccessibleObjectException!
} catch (Exception e) {
System.out.println("强封装阻止反射: " + e.getClass().getSimpleName());
}
}
}编译和运行脚本:
#!/bin/bash
# build.sh
# 编译库模块
javac --module-source-path src \
-d mods \
$(find src/com.mylib -name "*.java")
# 编译应用模块(依赖库模块)
javac --module-source-path src \
--module-path mods \
-d mods \
$(find src/com.myapp -name "*.java")
# 运行
java --module-path mods \
--module com.myapp/com.myapp.Main3.2 ServiceLoader模式(SPI)
// ===== 服务接口模块 =====
// module-info.java for com.plugin.api
module com.plugin.api {
exports com.plugin.api; // 暴露接口
}
// com/plugin/api/Plugin.java
package com.plugin.api;
public interface Plugin {
String getName();
void execute(String input);
}
// ===== 服务实现模块 =====
// module-info.java for com.plugin.impl
module com.plugin.impl {
requires com.plugin.api;
provides com.plugin.api.Plugin // 声明提供的服务
with com.plugin.impl.DefaultPlugin;
}
// com/plugin/impl/DefaultPlugin.java
package com.plugin.impl;
import com.plugin.api.Plugin;
public class DefaultPlugin implements Plugin {
@Override
public String getName() { return "DefaultPlugin"; }
@Override
public void execute(String input) {
System.out.println("Default processing: " + input);
}
}
// ===== 消费者模块 =====
// module-info.java for com.myapp
module com.myapp {
requires com.plugin.api;
uses com.plugin.api.Plugin; // 声明使用的服务
}
// 使用ServiceLoader加载插件
import com.plugin.api.Plugin;
import java.util.ServiceLoader;
public class PluginRunner {
public static void main(String[] args) {
// 旧写法(JDK8及之前,通过META-INF/services)
// ServiceLoader仍然可用,但模块系统提供了更强的保证
// 新写法(模块系统):
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
for (Plugin plugin : loader) {
System.out.println("Found plugin: " + plugin.getName());
plugin.execute("hello");
}
// 或者用Stream API
loader.stream()
.map(ServiceLoader.Provider::get)
.forEach(p -> p.execute("world"));
}
}3.3 迁移老项目到JPMS的策略
// ===== 旧写法:传统classpath项目 =====
// 所有依赖混在classpath,无任何边界
// 迁移步骤1:用jdeps分析依赖
// $ jdeps --multi-release 11 --generate-module-info mods myapp.jar
// 自动生成module-info.java草稿
// 迁移步骤2:选择迁移策略
// 策略A(推荐):从外到内,先封装应用层
// module com.myapp {
// requires java.base;
// requires java.sql;
// // 第三方库还在classpath,通过unnamed module访问
// }
// 策略B:使用--add-opens临时破封装(解决第三方库反射问题)
// java --add-opens java.base/java.lang=ALL-UNNAMED ...
// 策略C:完全不迁移,用unnamed module运行
// java -cp everything/*.jar com.example.Main
// 检查工具示例
public class MigrationChecker {
public static void checkModuleInfo() {
// 获取当前类所在的模块
Module module = MigrationChecker.class.getModule();
System.out.println("Module name: " + module.getName());
System.out.println("Is named: " + module.isNamed());
// 检查一个包是否被导出
System.out.println("java.lang exported: " +
java.lang.reflect.ModuleLayer.boot()
.findModule("java.base")
.map(m -> m.isExported("java.lang"))
.orElse(false));
}
}四、踩坑实录
坑1:第三方库反射访问被拒(最常见)
# 错误信息:
# java.lang.reflect.InaccessibleObjectException:
# Unable to make field private int java.util.ArrayList.size accessible:
# module java.base does not "opens java.util" to unnamed module
# 临时解决方案(不推荐长期使用):
java --add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
-jar myapp.jar
# 根本解决方案:
# 1. 升级第三方库到支持JPMS的版本
# 2. 在module-info.java中添加opens声明
# 3. 如果是自己的代码,改为不依赖反射的实现坑2:split packages(拆分包)问题
错误:同一个包名出现在多个模块中,JPMS不允许!
com.example.util 同时出现在 module-a 和 module-b
解决方案:
1. 重命名其中一个包
2. 将两个模块合并为一个
3. 将共享代码提取到单独的模块
这是迁移老项目最痛的点,特别是那些包名管理混乱的大型项目坑3:自动模块名不稳定
// 问题:JAR文件名是不稳定的,不同版本文件名不同
// guava-30.0-jre.jar -> 自动模块名: guava
// guava-31.0-jre.jar -> 自动模块名: guava(还好)
// 但如果改名就麻烦了
// 解决方案1:在MANIFEST.MF中声明Automatic-Module-Name
// META-INF/MANIFEST.MF:
// Automatic-Module-Name: com.google.guava
// 解决方案2:等第三方库发布正式的模块化版本
// Guava 从31.x开始提供了完整的module-info.java
// 检查JAR的自动模块名
// $ jar --describe-module --file=guava-32.jar坑4:反射框架(Spring、Hibernate)兼容问题
<!-- Maven配置:解决Spring等框架的反射问题 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
--add-opens java.base/java.io=ALL-UNNAMED
</argLine>
</configuration>
</plugin>// module-info.java 中为Spring开放必要的包
module com.myapp {
requires spring.context;
requires spring.beans;
// 允许Spring反射扫描
opens com.myapp.model to spring.core;
opens com.myapp.config to spring.context;
opens com.myapp.service to spring.beans;
// 或者全部开放给Spring(不那么安全但方便)
// opens com.myapp to spring.core, spring.beans, spring.context;
}坑5:jlink打包时缺少服务绑定
# 问题:jlink创建自定义运行时镜像时,动态加载的服务提供者可能被遗漏
# 症状:ServiceLoader.load()返回空
# 解决方案:使用 --bind-services 标志
jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.myapp \
--bind-services \ # 包含所有服务实现
--output myapp-runtime
# 或者手动指定需要的服务模块
jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.myapp,com.plugin.impl \
--output myapp-runtime五、总结与延伸
5.1 JPMS的实际价值评估
优势:
✓ 编译期依赖检查(再也不会运行时NoClassDefFoundError)
✓ 强封装(内部API真正隐藏)
✓ jlink可创建最小化JRE(从几百MB压缩到几十MB)
✓ 启动速度提升(模块系统对依赖有更好的缓存)
劣势:
✗ 学习曲线陡(module-info.java语法复杂)
✗ 三方库支持参差不齐
✗ Spring、Hibernate等框架有很多兼容问题
✗ 迁移老项目成本高(特别是split package问题)
实际建议:
- 新建的纯服务端应用:可以考虑用模块系统
- 老项目迁移:优先级不高,收益不明显
- 如果主要目的是jlink裁剪JRE:值得投入
- 如果目的是解决依赖冲突:Maven/Gradle的依赖管理更实用5.2 版本兼容建议
| 版本 | 状态 |
|---|---|
| JDK9 | JPMS正式GA,但JDK8类库的封装还比较宽松 |
| JDK16 | 强封装JDK内部API(--illegal-access移除)升级必须面对 |
| JDK17 | 内部API完全封装,sun.*包无法直接访问 |
| JDK21 | 模块系统稳定成熟,三方库支持大幅改善 |
从JDK8升级到JDK17+,绕不开JPMS的兼容问题,第403期会专门讲迁移指南。
