强封装:模块边界与反射
学完你要能自己回答
- exports 与 opens 的区别是什么?各自影响编译期还是运行期?
- JEP 403 在 16 与 17 之间改变了什么?--illegal-access 为什么失效?
- InaccessibleObjectException 的报错应该怎么读?先升级库还是先加参数?
- --add-opens 与 --add-exports 分别适用于什么场景?ALL-UNNAMED 指什么?
- jdeps 能发现哪些问题,不能发现哪些问题?
三层可见性01
模块系统在 public 之上又加了一层边界:一个 public 类只有在所属包被 exports 时,才对其他模块可见;要让反射做深度访问,还需要 opens。
| 层级 | 声明 | 影响 | 典型用途 |
|---|---|---|---|
| public | 类/成员修饰符 | 模块内可见 | 模块内部实现 |
| exports | module-info 或 --add-exports | 其他模块可编译、可访问 public 类型 | 公开 API |
| opens | module-info 或 --add-opens | 其他模块可反射访问非 public 成员 | 框架绑定、序列化 |
| 强封装 | JDK 内部包默认不 exports / opens | classpath 代码无法反射进入 | 保护 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 仍在,但不再是例外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,是不是就不会受强封装影响?
JEP 403 改变了什么01
| 版本 | 默认策略 | --illegal-access=permit | 结果 |
|---|---|---|---|
| JDK 9–15 | permit | 允许 | 第一次反射访问打印警告,之后放行 |
| JDK 16 | deny(JEP 396) | 仍可显式放行 | 默认拒绝,可用开关退回 |
| JDK 17 | deny(JEP 403) | 选项被忽略 | 一律强封装,无法退回 |
| JDK 21 | deny | 选项不存在 | 行为与 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 @...
String.class.getDeclaredField("value")本身可以成功:拿到 Field 对象不等于获得访问权。setAccessible(true)触发模块访问检查;java.base没有向调用方 opensjava.lang。- 异常消息给出目标成员、所属模块/包,以及「没有向谁 opens」。
- 调用方是 classpath 代码时,目标模块名显示为
unnamed module;命名模块则显示模块名。
注意:getDeclaredField 能拿到私有字段,是反射 API 的设计;真正被强封装拦住的是「使用」它。所以不要把「能拿到 Field」误判为「封装没生效」。
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 警告消失;依赖仍然存在,只是被临时放行。定位: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会直接给出模块与包,是最可靠的反射线索。
最小化兼容参数02
- 确认报错的目标模块与包,记录调用方是 classpath 还是命名模块。
- 先查该库是否有支持 17 的新版本;能升级就不要加参数。
- 如果必须兼容,只打开报错的那个包,不要打开整个 java.base。
- 同时检查编译期是否需要
--add-exports;运行时--add-opens不解决编译错误。 - 为每条参数记录原因、责任人和移除计划,纳入升级看板。
| 参数 | 作用阶段 | 解决什么 | 不能解决什么 |
|---|---|---|---|
--add-opens | 运行期 | 反射 setAccessible 被拒 | 编译期访问未导出类型 |
--add-exports | 编译期 + 运行期 | 访问未导出的 public 类型 | 反射访问非 public 成员 |
--add-reads | 编译期 + 运行期 | 命名模块读取另一个模块 | 包未导出/未打开 |
--illegal-access | 17 已无效 | 无 | 任何问题 |
# 只打开需要的包,并限定目标模块
java --add-opens java.base/java.lang=com.example.app \
--add-opens java.base/java.util=com.example.app \
-jar app.jar
验证与回归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 或启动日志)
强封装问题通常在启动或首次反射时暴露,但有些路径只在特定业务分支执行。测试覆盖要包含序列化、动态代理、模板引擎和依赖注入的真实使用场景。