JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · MODULE · JEP 261 (JDK 9)

JPMS 迁移:模块路径、未命名模块与强封装

JDK 11 · 深钻主题 03 / 03 · 预计 55 分钟
进阶四步深钻JEP 261
一句话JPMS 在 classpath 之上加了模块图:命名模块、自动模块、未命名模块各有不同的可读性与导出规则。迁移的难点通常不是写 module-info,而是让依赖被解析、让反射有明确的 opens。
本质:模块化迁移的顺序是「先可解析,再可访问,最后才收窄」:模块路径负责解析,exports 负责编译期可见性,opens 负责运行时深反射。

学完你要能自己回答

  1. 命名模块、自动模块、未命名模块有什么区别?
  2. 模块路径与类路径如何共存?为什么类在模块路径上仍然找不到?
  3. exports 与 opens 分别控制什么?强封装的时间线是怎样的?
  4. 迁移到 JPMS 的顺序是什么?拆分包和自动模块名怎么处理?
01 模块化到底改变了什么02 三种模块形态:命名、自动、未命名03 模块路径与类路径的分工

模块化到底改变了什么01

JPMS 在 classpath 之上加了一层模块图。每个命名模块声明自己需要谁(requires)、把哪些包给别人用(exports)、允许谁反射(opens)。JVM 启动时先解析这张图,再加载类。

  • 可靠配置:启动时就能发现缺失的模块,而不是运行到某行才 NoClassDefFoundError。
  • 强封装:未 exports 的包,即使有 public 类,也不能被其他模块编译期访问;未 opens 的包,深反射会被拒绝。
  • 可裁剪:jlink 只打包模块图里的模块,生成更小的运行时镜像。

模块化不是「把 jar 改成 module-info」这么简单。它改变的是可读性、可访问性和启动时的解析规则;迁移的核心是让依赖图闭合。

JPMS 从 JDK 9 开始,不是 JDK 11 的新特性。11 只是第一个长期支持版本,让更多项目不得不面对它。

三种模块形态:命名、自动、未命名02

运行时的每个类型都归属于某个模块。模块路径上的模块有名字;类路径上的所有 jar 合并成一个没有名字的「未命名模块」。

形态位置module-info可读性导出
命名模块模块路径有只读 requires 的模块只导出 exports 的包
自动模块模块路径上的普通 jar无读取所有命名模块导出所有包
未命名模块类路径无读取所有已解析模块所有包都可用

自动模块是迁移的跳板:把老 jar 放到模块路径,它自动获得一个模块名,导出所有包,并读取所有命名模块。这样命名模块可以先 require 它,而不必等它先模块化。

自动模块名来自 MANIFEST 的 Automatic-Module-Name;缺省时由 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-path 上有没有该模块,再看它是否被 --add-modules 或某个 requires 解析。
01 module-info:requires、exports、opens02 解析与可读性:为什么类在模块路径上仍找不到03 强封装与反射:permit、deny、remove

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 常见于编译期注解,运行期不一定要有。

opens 不要求 exports。一个包可以完全不导出,只对特定模块 opens,用于框架反射;这比 exports 更窄。

解析与可读性:为什么类在模块路径上仍找不到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
requires java.base 是隐式的,不需要写;所有模块都依赖 java.base。

强封装与反射: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
--add-opens 是运行时补丁,不是长期方案。它把整个包开放给调用方,范围通常比实际需要大;能改代码时优先改用公开 API。
01 迁移顺序:先可解析,再收窄02 常用命令与真实输出03 拆分包、自动模块名与 opens 过度

迁移顺序:先可解析,再收窄01

不要一上来给所有模块写完整 module-info。先把应用跑在 11 的类路径上,再逐步把依赖搬上模块路径,最后才收紧 exports 和 opens。

  1. 先跑在类路径上,确认应用在 JDK 11 正常。
  2. 用 jdeps 找出 JDK 内部 API、缺失模块和拆分包。
  3. 把第三方 jar 放上模块路径,观察自动模块名;需要固定名字时在 MANIFEST 写 Automatic-Module-Name。
  4. 为自己的代码写 module-info:先只 requires,不急着 exports/opens。
  5. 按编译错误逐步 exports 包;反射失败再定向 opens。
  6. 最后用 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
拆分包是 JPMS 的硬约束:同一个包在同一个层里只能属于一个模块。日志框架、JSON 库的「api + impl 同包」结构最容易踩到。
01 先猜再看:三个常见误解02 费曼自检:能否讲清一次启动

先猜再看:三个常见误解01

先猜再看把 jar 放到 --module-path,它就会自动被加载吗?
不会。没有 module-info 的 jar 会成为自动模块,但自动模块也要被解析才会进入模块图。用 --add-modules 或让某个命名模块 require 它。
先猜再看exports 和 opens 是一回事吗?
不是。exports 控制 public 类型的编译期与运行期访问;opens 控制运行时深反射,允许访问私有成员。只 exports 的包仍然不能深反射。
先猜再看类路径应用能读模块路径上的模块吗?
能,但前提是该模块已被解析。未命名模块读取所有已解析模块;模块路径上没被解析的模块对它不可见。

费曼自检:能否讲清一次启动02

01. JVM 启动时如何确定模块图?
从主模块和 --add-modules 的根集合出发,递归解析 requires,得到可达模块集合;模块路径上未被引用的模块不进入图。
02. 命名模块、自动模块、未命名模块的导出规则分别是什么?
命名模块只导出 exports 的包;自动模块导出所有包并读取所有命名模块;未命名模块包含类路径所有类,读取所有已解析模块。
03. 为什么 JDK 11 的非法反射只是警告?
11 默认 --illegal-access=permit,第一次非法访问警告后放行;16 改为 deny,17 移除选项,之后必须显式 --add-opens。
04. 拆分包为什么不能存在?
同一个包在同一个模块层里只能属于一个模块,否则同一个类可能来自两个模块,解析器无法确定归属。
能把「根集合 → 解析 → 可读性 → exports/opens」这条链讲清楚,并说清 11 的 permit 与 17 的 remove 的区别,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。