模块系统
这一课你将能做到
- 区分 classpath 与 module-path,说清 JPMS 解决的三个问题(强封装、显式依赖、可裁剪运行时)
- 读懂并亲手写出含
exports / requires 的 module-info.java,编译运行一个双模块工程
- 分辨 Maven 多模块与 JPMS 模块是两个层面的概念,避免面试时混为一谈
为什么要学
Spring Boot 应用多数仍跑在 classpath 上,日常写业务几乎碰不到 module-info.java。但三类场景绕不开它:读 JDK 源码与文档(JDK 9 后 JDK 本身就是模块化的)、排查 InaccessibleObjectException/IllegalAccessError(框架反射访问 JDK 内部 API)、理解云原生时代 jlink 裁剪小体积运行时的思路。
面试角度:「classpath 和 module-path 的区别」「exports 和 opens 的区别」「为什么 Spring Boot 项目一般不写 module-info」是中频题,答出概念分层即可拉开差距。
先建立图像
Java 9 之前的世界:public 类对整个 classpath 全局可见——你的包结构只是「君子协定」,谁都能 import,JDK 内部 API(如 sun.misc.Unsafe)也能被误用。JPMS 加了一层 JVM 认可的边界:
┌─ module com.example.app ─┐
requires ───→ │ 只 import exports 过的包 │
└───────────────────────────┘
┌─ module com.example.greeting ─┐
│ exports com.example.greeting │ ← 对外可见
│ com.example.internal │ ← 对外不可见,javac 直接拒绝
└───────────────────────────────┘
两个「模块」不要混:
| 层面 | 是什么 | 单位 |
|---|
| Maven 多模块 | 构建层:代码组织与依赖管理 | pom.xml 的 <modules> |
| JPMS | JVM 层:运行时可见性与封装边界 | module-info.java |
跟着做
步骤 1:建一个双模块工程目录
jmods-demo/
src/
com.example.greeting/
module-info.java
com/example/greeting/Greeting.java
com.example.app/
module-info.java
com/example/app/App.java
src 下每个「模块名命名的目录」就是一个模块根,这是 JPMS 的标准源码布局。
步骤 2:编写两个模块
src/com.example.greeting/module-info.java:
module com.example.greeting {
exports com.example.greeting;
}
src/com.example.greeting/com/example/greeting/Greeting.java:
package com.example.greeting;
public class Greeting {
public static String hello(String name) {
return "Hello, " + name + "!";
}
}
src/com.example.app/module-info.java:
module com.example.app {
requires com.example.greeting;
}
src/com.example.app/com/example/app/App.java:
package com.example.app;
import com.example.greeting.Greeting;
public class App {
public static void main(String[] args) {
System.out.println(Greeting.hello("JPMS"));
}
}
步骤 3:模块化编译与运行
在 jmods-demo 目录执行(单行命令,Windows PowerShell 与 bash 通用):
javac -d out --module-source-path src src/com.example.greeting/module-info.java src/com.example.greeting/com/example/greeting/Greeting.java src/com.example.app/module-info.java src/com.example.app/com/example/app/App.java
java --module-path out --module com.example.app/com.example.app.App
输出:Hello, JPMS!。注意 --module 模块名/主类全名 的写法——module-path 世界里,主类靠「模块名 + 类名」定位,不再全 classpath 扫描。
步骤 4:体验强封装
在 Greeting.java 旁边加一个不导出的类 src/com.example.greeting/com/example/greeting/internal/Secret.java(package com.example.greeting.internal;,内容随意一个 public 类),然后回到 module-info.java——不要 export 这个包,直接在 App.java 里写 import com.example.greeting.internal.Secret; 再编译。
javac 直接报错:package com.example.greeting.internal is not visible。这就是编译期的边界强制,classpath 时代做不到。
步骤 5:查看 JDK 自身模块
java --list-modules
java --describe-module java.base
你会看到 java.base、java.sql、jdk.httpserver 等几十个 JDK 模块——JDK 9 后它自己是模块化的,这也是第 10 课能直接 import com.sun.net.httpserver 的原因。
讲清楚要点
- JPMS 的三个目标:强封装(未 export 的包对外不可见)、可靠配置(requires 显式声明依赖图,缺依赖启动即报错)、可定制运行时(jlink 按需裁剪出带应用的最小 JRE)。
- exports vs opens:exports 允许编译期 + 运行期直接访问;opens 只放开运行期反射访问(框架依赖注入、JSON 序列化需要),
opens ... to xxx 还能限定只对指定模块开放。
- classpath 与 module-path 的关系:classpath 上的 jar 不感知模块边界(自动模块除外),行为与 Java 8 相同;module-path 上的 jar 必须有模块描述。两者可以共存,Boot 项目正是这么跑的。
- Spring Boot 为什么通常不写 module-info:生态内大量 jar 未模块化;Spring 的反射、动态代理、可选依赖在 module-path 下需要大量 opens/uses 配置,收益小于成本。全 classpath 是务实选择。
- 学习优先级:企业开发先掌握 Maven 多模块(构建层)与分层包结构;JPMS 是 JVM 层概念,能读懂、能排查反射报错即可,不必强行模块化业务项目。
你可能会懵的地方
- requires static 和 requires transitive 是什么?
requires static 是编译期需要、运行期可选(如 Lombok);requires transitive 是传递依赖——我依赖你时,也自动允许我的使用者依赖它指向的模块(如所有模块都该传递依赖 java.base)。
- 没有 module-info 的 jar 放到 module-path 上会怎样?
成为「自动模块」:JDK 按文件名推导模块名并 export 所有包。名字不稳定、全导出弱化封装,实践中不推荐依赖这个行为。
- 反射报 InaccessibleObjectException 怎么解?
典型原因是框架反射访问未 opens 的 JDK 包。启动参数加
--add-opens java.base/java.lang=ALL-UNNAMED 应急;正规解法等库本身适配模块化。
- jlink 和 Docker 多阶段构建冲突吗?
不冲突:jlink 产出裁剪后的自定义运行时,放进更小的基础镜像(如 alpine/debian-slim),体积能从数百 MB 压到几十 MB;与 jva-quarkus 的 Native 思路殊途同归。
- 包和模块什么关系?
模块是一组包的容器 + 可见性声明单元;一个包只能属于一个模块。包名重复但分属两个模块会直接冲突。
错法对照
| 错法 | 问题 | 正解 |
|---|
把 Maven <modules> 当成 JPMS | 两个层面,面试混淆会露馅 | Maven 管构建,JPMS 管运行时边界 |
| 给 Spring Boot 项目强上 module-info | 反射/可选依赖坑多,收益低 | 全 classpath,理解概念即可 |
| 只 exports 不 opens 还抱怨框架反射失败 | 框架靠运行期反射 | 对反射方 opens ... to 目标模块 |
| 以为 public 就全局可见(Java 9+) | module-path 下未导出包不可见 | 记住两层可见性:public + exported |
| module-info 里忘写 requires 编译不过还删类 | 是依赖声明缺失 | 补 requires 而不是绕路 |
卡点急救
- 编译报「module not found」:
--module-source-path 路径不对,或模块根目录名与 module 声明不一致;目录名必须精确等于模块名。
- 运行报「module xxx not found」:用了 classpath 的
java -cp 跑模块化应用;改 --module-path + --module。
- 想看某 jar 是不是模块:
jar --describe-module --file xxx.jar;有 module-info 即显式模块,否则是普通/自动模块。
面试常问
- classpath 与 module-path 的区别?
可背版:classpath 是 Java 8 以前的平坦搜索路径,public 即全局可见;module-path 是 Java 9+ 的模块容器,模块靠 module-info 声明 exports/requires,JVM 强制可见性边界,未导出的包即使 public 也访问不到;两者可共存,多数 Boot 应用跑在 classpath 上。
- exports 与 opens 的区别?
exports 放开的是「编译期 + 运行期的直接访问」;opens 放开的是「运行期的深度反射访问」(setAccessible、动态代理)。框架类库(Spring、Jackson)通常需要 opens;普通 API 依赖只需要 exports。
- 为什么 Spring Boot 生态迟迟不全面模块化?
三点:海量第三方 jar 未模块化;框架核心机制是运行期反射与可选依赖,模块边界的严格性与之冲突;全 classpath 在工程上没有实际痛点。结论是「掌握概念、按需使用」,这正是技术选型题的标准答法。
自测三问
- JPMS 解决了哪三个问题?分别对应哪个指令?
- 框架反射访问 JDK 内部类报 InaccessibleObjectException,应急与正规解法各是什么?
- Maven 多模块和 JPMS 模块的核心区别是什么?
小结一句话
JPMS 是 JVM 层的封装与依赖边界,Boot 项目先全 classpath,但概念要懂、反射报错要会排查。
延伸