JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · MODULE · JEP 403

强封装:模块边界与反射

JDK 17 · 深钻主题 02 / 03 · 预计 50 分钟
进阶四步深钻JEP 403JEP 261
一句话JDK 17 的 JEP 403 把「默认拒绝」变成「一律拒绝」:classpath 上的代码不能再靠 --illegal-access=permit 反射进 JDK 内部包。理解 exports 与 opens 的差别,才能把 --add-opens 用在最小范围。
本质:模块系统把「public」拆成两层:public 只对本模块可见,导出(exports)才对外可见;反射还要再打开(opens)。强封装就是把第三层默认关死。

学完你要能自己回答

  1. exports 与 opens 的区别是什么?各自影响编译期还是运行期?
  2. JEP 403 在 16 与 17 之间改变了什么?--illegal-access 为什么失效?
  3. InaccessibleObjectException 的报错应该怎么读?先升级库还是先加参数?
  4. --add-opens 与 --add-exports 分别适用于什么场景?ALL-UNNAMED 指什么?
  5. jdeps 能发现哪些问题,不能发现哪些问题?
01 三层可见性02 classpath 仍在,但不再是例外

三层可见性01

模块系统在 public 之上又加了一层边界:一个 public 类只有在所属包被 exports 时,才对其他模块可见;要让反射做深度访问,还需要 opens。

层级声明影响典型用途
public类/成员修饰符模块内可见模块内部实现
exportsmodule-info 或 --add-exports其他模块可编译、可访问 public 类型公开 API
opensmodule-info 或 --add-opens其他模块可反射访问非 public 成员框架绑定、序列化
强封装JDK 内部包默认不 exports / opensclasspath 代码无法反射进入保护 JDK 实现
module com.example.app {
    requires java.base;
    exports com.example.api;          // 编译期可见
    opens com.example.model to com.fasterxml.jackson.databind; // 反射可见
}

exports 是编译期契约,opens 是运行期反射契约。一个包可以只 exports 不 opens:public 类型能编译访问,但 setAccessible(true) 会被拒绝。

classpath 上的代码属于「未命名模块」,它读取所有模块,但不会自动获得 opens。这就是为什么很多老库在 17 上突然反射失败。

classpath 仍在,但不再是例外02

JDK 9 之后,classpath 上的代码被归入未命名模块。未命名模块读取所有命名模块,也能访问被 exports 的包;但它不能访问未导出的包,也不能对未 opens 的包做深度反射。

  • 未命名模块没有 module-info,无法声明 requires;它默认读取全部模块。
  • 命名模块不能读取未命名模块,除非通过 --add-reads 等参数显式放行。
  • ALL-UNNAMED 是未命名模块的占位名,用于把包打开/导出给 classpath 代码。
  • 把库放到 classpath 不会绕过强封装;要绕过只能加 --add-opens,且只对指定包生效。

因此,升级到 17 时看到 module java.base does not "opens java.lang" to unnamed module,说的就是:调用方在 classpath 上,目标包没有向未命名模块 opens。

先猜再看把应用打成非模块化 jar,是不是就不会受强封装影响?
不是。非模块化 jar 在 classpath 上属于未命名模块,同样受 JEP 403 约束。强封装保护的是 JDK 内部包,不区分调用方是否模块化。
01 JEP 403 改变了什么02 反射检查发生在哪里03 add-opens 如何放行

JEP 403 改变了什么01

版本默认策略--illegal-access=permit结果
JDK 9–15permit允许第一次反射访问打印警告,之后放行
JDK 16deny(JEP 396)仍可显式放行默认拒绝,可用开关退回
JDK 17deny(JEP 403)选项被忽略一律强封装,无法退回
JDK 21deny选项不存在行为与 17 一致

JEP 403 的关键不是「默认值又改了」,而是「退路被删除」。依赖 sun.*、jdk.internal.* 或对 java.lang 等包做 setAccessible 的库,必须在 17 上显式适配。

$ java --illegal-access=permit -cp out ReflectDemo
OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0
type=InaccessibleObjectException
msg=Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @...
--illegal-access=deny 在 17 同样被忽略并警告。不要以为换成 deny 就能「显式确认」;这个选项整体已经不存在。

反射检查发生在哪里02

反射访问的检查发生在 AccessibleObject.setAccessible 与成员访问时:运行时先确认目标包是否对调用方模块 opens,再决定是否允许。失败抛 InaccessibleObjectException。

import java.lang.reflect.Field;

Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
type=InaccessibleObjectException
msg=Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @...
  1. String.class.getDeclaredField("value") 本身可以成功:拿到 Field 对象不等于获得访问权。
  2. setAccessible(true) 触发模块访问检查;java.base 没有向调用方 opens java.lang。
  3. 异常消息给出目标成员、所属模块/包,以及「没有向谁 opens」。
  4. 调用方是 classpath 代码时,目标模块名显示为 unnamed module;命名模块则显示模块名。

注意:getDeclaredField 能拿到私有字段,是反射 API 的设计;真正被强封装拦住的是「使用」它。所以不要把「能拿到 Field」误判为「封装没生效」。

异常消息里的模块哈希(@76ccd017)每次启动可能不同,排查时看包名和模块名,不要复制哈希去搜索。

add-opens 如何放行03

--add-opens <module>/<package>=<target> 在运行时把指定包打开给目标模块。它只影响反射的深度访问,不会让代码在编译期看到该包。

# 把 java.base/java.lang 打开给 classpath 代码
java --add-opens java.base/java.lang=ALL-UNNAMED -cp out ReflectDemo
# opened=true
  • 目标写 ALL-UNNAMED 表示 classpath 上的未命名模块;命名模块要写具体模块名。
  • 可以重复传多个 --add-opens,每个包一条;不要用一个宽泛的包覆盖所有需求。
  • --add-opens 是运行时参数;编译期访问未导出类型要用 --add-exports。
  • 模块化应用可以在 module-info 里用 opens ... to ...,比命令行参数更可审计。
# --add-exports:让未导出的 public 类型在编译期可见
javac --add-exports java.base/sun.nio.ch=ALL-UNNAMED -d out ExportDemo.java
java --add-exports java.base/sun.nio.ch=ALL-UNNAMED -cp out ExportDemo
# sun.nio.ch.DirectBuffer
--add-opens 不是「关闭强封装」的总开关。它只对列出的包生效,而且不会让 jdeps 的内部 API 警告消失;依赖仍然存在,只是被临时放行。
01 定位:jdeps 与运行日志02 最小化兼容参数03 验证与回归

定位:jdeps 与运行日志01

先用 jdeps --jdk-internals 找静态引用,再用运行日志找反射访问。前者能列出编译进字节码的内部 API,后者只能在真实执行路径上暴露。

$ jdeps -J-Duser.language=en --jdk-internals out/ExportDemo.class
ExportDemo.class -> java.base
   ExportDemo -> sun.nio.ch.DirectBuffer   JDK internal API (java.base)

Warning: JDK internal APIs are unsupported and private to JDK implementation that are
subject to be removed or changed incompatibly and could break your application.
  • jdeps --jdk-internals -R 递归扫描目录或 jar,适合在升级前做全量盘点。
  • jdeprscan --release 17 --for-removal 找使用了待移除 API 的类,和内部 API 检查互补。
  • jdeps 看不到纯反射字符串(如 Class.forName("sun.misc.Unsafe")),需要结合运行日志和库文档。
  • 运行日志里的 InaccessibleObjectException 会直接给出模块与包,是最可靠的反射线索。
不要只扫自己的代码。大部分强封装问题来自第三方库:序列化、字节码增强、Mock 框架、旧版连接池。升级依赖往往比加参数更有效。

最小化兼容参数02

  1. 确认报错的目标模块与包,记录调用方是 classpath 还是命名模块。
  2. 先查该库是否有支持 17 的新版本;能升级就不要加参数。
  3. 如果必须兼容,只打开报错的那个包,不要打开整个 java.base。
  4. 同时检查编译期是否需要 --add-exports;运行时 --add-opens 不解决编译错误。
  5. 为每条参数记录原因、责任人和移除计划,纳入升级看板。
参数作用阶段解决什么不能解决什么
--add-opens运行期反射 setAccessible 被拒编译期访问未导出类型
--add-exports编译期 + 运行期访问未导出的 public 类型反射访问非 public 成员
--add-reads编译期 + 运行期命名模块读取另一个模块包未导出/未打开
--illegal-access17 已无效无任何问题
# 只打开需要的包,并限定目标模块
java --add-opens java.base/java.lang=com.example.app \
     --add-opens java.base/java.util=com.example.app \
     -jar app.jar
参数越少,升级越可控。每多一个 --add-opens,未来 JDK 版本收紧实现时,你就多一个潜在的故障点。

验证与回归03

  • 在 CI 里用目标 JDK 跑完整测试,不要只在 IDE 里切换 JDK。
  • 打开 -Xlog:class+load 或依赖检查,确认没有旧版库被间接引入。
  • 对序列化、ORM、Mock、字节码增强框架做专项回归,它们最容易反射 JDK 内部。
  • 把 --add-opens 作为告警项:新增一条就要求评审,避免无声累积。
  • 升级后重新跑 jdeps,确认内部 API 引用没有增加。
# 启动时确认模块与封装状态
java --show-module-resolution -version 2>&1 | head -20

# 列出运行时新增的 opens(需要 --show-module-resolution 或启动日志)

强封装问题通常在启动或首次反射时暴露,但有些路径只在特定业务分支执行。测试覆盖要包含序列化、动态代理、模板引擎和依赖注入的真实使用场景。

如果一条 --add-opens 已经存在很久,仍然要定期复查:库可能早已升级,参数只是历史遗留。删除它比新增它更有价值。
01 先猜再看02 费曼自检:能否讲清因果

先猜再看01

先猜再看public 类在未导出的包里,classpath 代码能直接 import 吗?
不能。public 只保证模块内可见;包没有 exports 时,其他模块(包括未命名模块)在编译期就看不到它。运行期反射还要再看 opens。
先猜再看--add-opens 能让编译通过吗?
不能。--add-opens 是运行期参数,只影响反射的深度访问。编译期要访问未导出的 public 类型,需要 --add-exports 或 module-info 的 exports。
先猜再看JEP 403 之后,sun.misc.Unsafe 完全不能用了?
不是。Unsafe 等少数 API 仍由 jdk.unsupported 模块导出,属于兼容通道。但其他 sun.* / jdk.internal.* 包默认不再开放,不能把 Unsafe 当作普遍可用的例外。

费曼自检:能否讲清因果02

01. exports 与 opens 的区别是什么?
exports 让其他模块在编译期和运行期访问包中的 public 类型;opens 允许运行期反射访问包中的非 public 成员,不改变编译期可见性。
02. JEP 403 与 JEP 396 的区别是什么?
JEP 396 在 16 把默认策略改为 deny,但保留 --illegal-access=permit 退路;JEP 403 在 17 移除该选项,强封装成为唯一行为。
03. InaccessibleObjectException 的消息应该怎么读?
看三件事:哪个类的哪个成员、属于哪个模块的哪个包、没有向哪个调用方模块 opens。classpath 调用方显示为 unnamed module。
04. 为什么先升级库,而不是先加 --add-opens?
加参数只是绕过检查,依赖仍然存在,未来升级还会再次失败。升级库能真正移除对内部 API 的依赖,参数只应作为有期限的兼容手段。
05. jdeps 找不到反射访问,怎么办?
jdeps 只能分析静态引用。反射访问要靠运行日志中的 InaccessibleObjectException、JFR/字节码分析或库文档定位;必要时在测试环境打开模块访问日志。
能把「public → exports → opens」三层讲清楚,并说出 16 与 17 的差别,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

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

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