JPMS 迁移:模块路径、未命名模块与强封装
学完你要能自己回答
- 命名模块、自动模块、未命名模块有什么区别?
- 模块路径与类路径如何共存?为什么类在模块路径上仍然找不到?
- exports 与 opens 分别控制什么?强封装的时间线是怎样的?
- 迁移到 JPMS 的顺序是什么?拆分包和自动模块名怎么处理?
模块化到底改变了什么01
JPMS 在 classpath 之上加了一层模块图。每个命名模块声明自己需要谁(requires)、把哪些包给别人用(exports)、允许谁反射(opens)。JVM 启动时先解析这张图,再加载类。
- 可靠配置:启动时就能发现缺失的模块,而不是运行到某行才 NoClassDefFoundError。
- 强封装:未 exports 的包,即使有 public 类,也不能被其他模块编译期访问;未 opens 的包,深反射会被拒绝。
- 可裁剪:jlink 只打包模块图里的模块,生成更小的运行时镜像。
模块化不是「把 jar 改成 module-info」这么简单。它改变的是可读性、可访问性和启动时的解析规则;迁移的核心是让依赖图闭合。
三种模块形态:命名、自动、未命名02
运行时的每个类型都归属于某个模块。模块路径上的模块有名字;类路径上的所有 jar 合并成一个没有名字的「未命名模块」。
| 形态 | 位置 | module-info | 可读性 | 导出 |
|---|---|---|---|---|
| 命名模块 | 模块路径 | 有 | 只读 requires 的模块 | 只导出 exports 的包 |
| 自动模块 | 模块路径上的普通 jar | 无 | 读取所有命名模块 | 导出所有包 |
| 未命名模块 | 类路径 | 无 | 读取所有已解析模块 | 所有包都可用 |
自动模块是迁移的跳板:把老 jar 放到模块路径,它自动获得一个模块名,导出所有包,并读取所有命名模块。这样命名模块可以先 require 它,而不必等它先模块化。
模块路径与类路径的分工03
模块路径和类路径可以同时使用,但规则不同。模块路径上的命名模块参与解析;类路径上的所有 jar 合并成一个未命名模块,不参与模块图。
一个类路径应用要访问模块路径上的模块,前提是该模块被解析。只把 jar 放到 --module-path 并不会自动解析它;需要用 --add-modules 把它加入根集合,或让一个命名模块 require 它。
java -cp cpapp CpMain
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/lib/Greeter at CpMain.main(CpMain.java:4) Caused by: java.lang.ClassNotFoundException: com.example.lib.Greeter at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581) ...
加上 --add-modules 后,com.example.lib 被解析,未命名模块就能读取它:
java --module-path mods --add-modules com.example.lib -cp cpapp CpMain
classpath hello reads modules
module-info:requires、exports、opens01
module-info.java 是模块的声明文件,编译后生成 module-info.class。它不写业务代码,只描述模块的身份、依赖和边界。
module com.example.app {
requires com.example.lib;
}
module com.example.lib {
exports com.example.lib;
}
| 指令 | 作用 | 迁移注意 |
|---|---|---|
| requires m | 依赖 m,编译与运行都需要 | 缺少模块时启动即失败 |
| requires transitive m | 把 m 的读权限传给下游 | API 暴露 m 的类型时使用 |
| requires static m | 编译期需要,运行期可选 | 运行时缺失由代码自行兜底 |
| exports p | 把包 p 的 public 类型暴露给所有模块 | 未导出的包不是 API |
| exports p to m | 只暴露给 m | 定向导出,适合内部协作 |
| opens p | 允许所有模块深反射 p | 比 exports 更宽松 |
| opens p to m | 只允许 m 深反射 | 优先使用定向 opens |
| uses / provides | 声明或提供 ServiceLoader 服务 | 跨模块加载服务时要显式声明 |
requires transitive 常见于「API 返回值类型来自另一个模块」的场景:下游只 require 你,也应该能访问那个类型。requires static 常见于编译期注解,运行期不一定要有。
解析与可读性:为什么类在模块路径上仍找不到02
解析从根模块开始:java -m 的主模块、--add-modules 指定的模块,以及它们递归 requires 的模块。没有被解析的模块,即使躺在模块路径上,也不会进入模块图。
模块路径上的模块不会自动全部加载;这一点和类路径「扫到就用」不同。好处是启动快、边界清楚,代价是迁移时经常遇到「类明明在,却说找不到」。
java --module-path mods -m com.example.app/com.example.app.Main
hello modules
模块解析成功后,可以用 --describe-module 检查依赖与导出:
java --module-path mods --describe-module com.example.app
com.example.app file:///.../mods/com.example.app/ requires java.base mandated requires com.example.lib contains com.example.app
强封装与反射:permit、deny、remove03
强封装限制的是「非导出包里的类型」和「未开放包的深反射」。public 不等于对所有模块可见;没有 exports,其他命名模块连编译都过不去。
JDK 9–15 默认 --illegal-access=permit:第一次非法反射只给警告,之后放行。JDK 16 默认改为 deny(JEP 396),JDK 17 移除该选项(JEP 403),非法访问直接失败。
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
System.out.println("opened=" + (f.get("abc") != null));
WARNING: An illegal reflective access operation has occurred WARNING: Illegal reflective access by Reflect (file:/.../) to field java.lang.String.value WARNING: Please consider reporting this to the maintainers of Reflect WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations WARNING: All illegal access operations will be denied in a future release opened=true
把策略改成 deny,同一个程序直接抛异常:
java --illegal-access=deny Reflect
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @36baf30c at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:340) ...
显式开放后恢复:
java --add-opens java.base/java.lang=ALL-UNNAMED Reflect
opened=true
迁移顺序:先可解析,再收窄01
不要一上来给所有模块写完整 module-info。先把应用跑在 11 的类路径上,再逐步把依赖搬上模块路径,最后才收紧 exports 和 opens。
- 先跑在类路径上,确认应用在 JDK 11 正常。
- 用 jdeps 找出 JDK 内部 API、缺失模块和拆分包。
- 把第三方 jar 放上模块路径,观察自动模块名;需要固定名字时在 MANIFEST 写 Automatic-Module-Name。
- 为自己的代码写 module-info:先只 requires,不急着 exports/opens。
- 按编译错误逐步 exports 包;反射失败再定向 opens。
- 最后用 jlink 裁剪运行时,验证模块图闭合。
这个顺序的好处是每一步都有可回退的边界:解析失败只影响启动,导出失败只影响编译,opens 失败只影响反射。
常用命令与真实输出02
下面是一个两模块项目的最小闭环:lib 导出包,app requires lib。编译时用 --module-source-path 按模块目录组织源码。
javac -d mods --module-source-path src $(find src -name "*.java")
java --module-path mods -m com.example.app/com.example.app.Main
hello modules
检查模块描述,确认 requires 与 exports 是否符合预期:
java --module-path mods --describe-module com.example.lib
com.example.lib file:///.../mods/com.example.lib/ exports com.example.lib requires java.base mandated
用 jdeps 看依赖方向,确认没有意外的 JDK 内部 API:
jdeps --module-path mods --module com.example.app
com.example.app -> com.example.lib com.example.app -> java.base
自动模块的名字可以直接查,不要靠猜:
jar --describe-module --file auto-lib.jar
No module descriptor found. Derived automatic module. auto.lib automatic requires java.base mandated contains com.example.auto
拆分包、自动模块名与 opens 过度03
迁移中最常见的不是语法错误,而是模块图无法闭合:两个模块包含同一个包、自动模块名不稳定、类路径与模块路径混用。
| 问题 | 现象 | 处理 |
|---|---|---|
| 拆分包 | LayerInstantiationException: Package p in both module m1 and module m2 | 合并 jar,或把冲突包移到单独模块 |
| 自动模块名不稳定 | 下游 require 的名字随 jar 文件名变化 | 在 MANIFEST 固定 Automatic-Module-Name |
| opens 过宽 | 整个包对未命名模块开放 | 用 opens ... to 限定模块 |
| 类路径依赖模块路径 | NoClassDefFoundError | --add-modules 或把依赖放回类路径 |
| 循环依赖 | 模块图无法解析 | 提取公共模块或改用服务 |
| 反射失败 | InaccessibleObjectException | 优先改公开 API,其次 --add-opens |
拆分包在编译期不一定报错,但启动解析一定失败。两个模块都声明包 p 时,真实结果是:
module m1 { exports p; }
module m2 { exports p; }
Error occurred during initialization of boot layer java.lang.LayerInstantiationException: Package p in both module m1 and module m2