反射、注解与动态代理:元数据入口、访问边界与拦截范围
结论基线:语言规则以 Java SE 17(JLS 17 与
java.lang.reflect规范)为准;实现源码以 OpenJDK 17ujdk-17.0.12+7为准;实验运行构建由本批证据单独登记。反射、注解与代理是三个相邻但独立的机制:注解决定"元数据写在哪、谁能读到",反射提供"成员观察和调用入口,能否访问另看权限",动态代理决定"接口调用可以被谁看到"。把它们混成一个"框架魔法"会在排障时失去定位能力。
问题:注解读不到、代理拦不住自调用
两个高频故障先放在开头;以下注解片段省略 java.lang.annotation 导入:
@Retention(RetentionPolicy.CLASS) // 编译进 class 文件,Java反射API不暴露
@interface Marker {}
@Retention(RetentionPolicy.RUNTIME)
@interface RuntimeMarker {}
@Marker @RuntimeMarker class UseSite {}
Marker m = UseSite.class.getAnnotation(Marker.class); // null
RuntimeMarker r = UseSite.class.getAnnotation(RuntimeMarker.class); // 非 null注解确实写上了,但 RetentionPolicy.CLASS 的注解只给编译器与字节码工具读,运行时反射拿到的就是 null。这不是"注解失效",是保留策略决定了它存在的位置。
interface OrderService { void cancel(String id); void cancelAll(List<String> ids); }
class OrderServiceImpl implements OrderService {
public void cancel(String id) { audit(id); }
public void cancelAll(List<String> ids) { for (String id : ids) cancel(id); } // 自调用
void audit(String id) { /* 记录 */ }
}给 OrderService 建 JDK 动态代理:若计所有经代理调用,外部调 cancelAll 计 1 次;若只统计方法名 cancel,本次为 0。里面 for 循环里的 cancel 不会再经过 handler——this.cancel(id) 走的是实现类内部的普通虚调用,不经过代理对象。Spring AOP 的自调用失效与它是同一个结构原因(代理拦截的是"经过代理对象的调用",JDK代理对外暴露接口,不是"目标对象内部的方法调用")。
这两个例子定义了本文的问题:元数据放在哪(注解)、怎么拿到(反射)、拦截覆盖到哪(代理)。
反射:入口、查找与调用
完整例子保存为 ReflectionUse.java,JDK17 执行 javac --release 17 ReflectionUse.java && java ReflectionUse。类名、二进制名与公有构造器一致:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ReflectionUse {
public static class Order {
public Order() {}
public String read(String id) {
if (id.isBlank()) throw new IllegalArgumentException("blank id");
return "order:" + id;
}
}
public static void main(String[] args) throws Exception {
Class<?> type = Class.forName(Order.class.getName());
Object target = type.getConstructor().newInstance();
Method read = type.getMethod("read", String.class);
System.out.println(read.invoke(target, "o-1"));
try { read.invoke(target, ""); }
catch (InvocationTargetException e) { System.out.println(e.getCause().getMessage()); }
}
}预期为两行 order:o-1、blank id;这份例子的单独执行状态见实验记录,不把预期当日志。查找按声明参数类型,read(String) 不能用 Integer.class 查;查到后传 Integer 给 invoke 也不合法。基本类型签名用 int.class 等,不按本次参数对象猜重载。
使用要点与失败输入:
| 操作 | 失败输入 | 抛出 | 定位 |
|---|---|---|---|
getMethod("cancel", String.class) | 方法不存在或参数类型不匹配 | NoSuchMethodException | 先打印 getMethods() 对照签名 |
getDeclaredMethod | 只查本类,不查父类;签名也须匹配 | NoSuchMethodException | 明确声明层;上例 audit(String) 不能省参数查找 |
invoke(target, args) | 目标方法体内抛异常 | InvocationTargetException,getCause() 才是真实异常 | 读 cause,不要按外层异常排障 |
invoke 参数装箱 | 参数个数/类型不符 | IllegalArgumentException | 按方法形参逐个核对 |
getConstructor() | 没有公有无参构造 | NoSuchMethodException | 查实际公有构造签名,或在允许访问时查 declared 构造 |
Constructor.newInstance() | 对抽象类构造器执行实例化 | InstantiationException | 改为可实例化实现类,不混淆查找与实例化 |
invoke 接收者 | 实例方法传 null/错误类型目标 | NullPointerException/IllegalArgumentException | 校验目标实例与 Method 的声明类 |
性能不是使用反射的第一障碍,权限才是;性能只在与调用频次挂钩时讨论。缓存 Method/Field 可以减少业务层重复查找,但不保证调用零开销或必然JIT内联;setAccessible(true)只在权限允许时抑制语言访问检查,不能当未经测量的性能开关。任何"反射很慢"的结论都必须绑定频次、缓存策略与 JIT 是否内联,脱离条件的笼统断言不成立。
固定实现的调用链并不是“查到Method即可执行”。Method.java L555–569先看 override 状态决定访问检查,再取或复用 MethodAccessor,最终转交:
return ma.invoke(obj, args);这里的Accessor是固定实现细节,不能据这行推导所有JDK都用同一后端。Method SE17与Constructor SE17给出调用契约;查找、访问、参数转换和目标方法抛出属于不同阶段。
访问边界:setAccessible 与模块封装
AccessibleObject SE17 区分成员查找与访问抑制:同模块、已导出包公有类的公有成员、开放包等分别有条件。exports 暴露公开API,opens 面向深反射,两者不能混同:
- 同一模块(含无名模块/类路径代码)内访问自己的私有成员:允许;
- JDK 16 起(JEP 396)强封装默认生效,JDK 17(JEP 403)继续:默认未向调用方开放的包中,抑制非公开成员访问检查会抛
InaccessibleObjectException,例如对String.value字段setAccessible(true); - 需要在受控环境(本地实验、诊断工具)访问时,启动参数
--add-opens java.base/java.lang=ALL-UNNAMED是显式开孔,不是生产建议。
Field f = String.class.getDeclaredField("value");
f.setAccessible(true); // JDK17 默认抛 InaccessibleObjectException固定 AccessibleObject.java L310的同模块早返回原码:
if (callerModule == declaringModule) return true;随后检查无名模块、公开导出成员等情况;L338–350检查是否向调用模块开放,拒绝时构造包含模块/包的错误。由此可见“同在JVM”不等于“同模块许可”。
排障顺序:先确认阶段和类型:调用时语言访问检查失败可能是 IllegalAccessException;模块拒绝抑制访问检查时是 InaccessibleObjectException。trySetAccessible 在无法启用时返回 false(安全管理器拒绝等仍可抛异常)。优先公开API和合理模块边界;MethodHandles/VarHandle 也受 lookup/模块权限约束,不能绕过封装。把 InaccessibleObjectException 当成"权限 bug"去绕,会把库升级后随时消失的隐式依赖埋进系统。
注解:保留策略与继承规则
注解是带 RetentionPolicy 的元数据声明,三条保留策略各自面向不同读者:
| 策略 | 存在位置 | 谁能读 | 典型用途 |
|---|---|---|---|
SOURCE | 只在源码,不进 class | 编译器/静态检查 | @Override、@SuppressWarnings |
CLASS | 默认值;进 class 文件,反射不暴露,非删除文件字节 | 字节码工具 | 字节码分析 |
RUNTIME | 运行时经反射可读 | 框架 | Spring/MyBatis/JUnit 的映射与装配 |
继承与查找规则容易踩错:
@Inherited只对类继承有效:子类能查到父类的类级注解;接口上的注解不继承,方法的@Inherited也不存在该效果。getAnnotation(X.class)找不到返回null,不是空集合也不是异常;直接.annotationType()会 NPE。@Repeatable需要容器。两个重复项经容器存储时,getAnnotation(Tag.class)为null,getAnnotation(Tags.class)可得容器,by-type展开;只有一个直接项时 getAnnotation(Tag.class)可以有值,不能说永远只查到容器。@Target控制合法标注位置;未写时允许声明上下文(不含类型参数声明),不自动允许所有类型使用位置。框架配置建议显式给TYPE/METHOD/PARAMETER等。- 未写
@Retention默认CLASS。重复注解与容器的保留期限、Target、Inherited有一致性限制,不能只给重复类型加RUNTIME却漏容器。 - 注解元素返回类型限于基本类型、String、Class、枚举、注解及这些类型的一维数组;值受注解元素规则限制,不能为null或任意运行时对象。默认值在声明处,使用处可覆盖。
编译期注解处理器(javax.annotation.processing.Processor)与运行时反射是两条不同的链路:编译期处理器或特定AST工具的效果可能已经进入编译产物,运行时无需再次消费该注解;CLASS注解不由Java反射暴露。不要把某工具的实现当全部注解的规则。判断"注解为什么没生效",先问它在哪条链路上被谁消费。
最小重复注解声明(置于同一个类内,java.lang.annotation导入略)与实验一致:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@Repeatable(Tags.class) @interface Tag { String value() default "read"; }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Tags { Tag[] value(); }
@Tag @Tag("write") static class ReadWrite {}
// ReadWrite.class.getAnnotationsByType(Tag.class) 返回read、write两项:默认值与使用处覆盖JLS17 §9.6约束声明与元素;AnnotatedElement SE17明确直接、间接、继承、associated查找的区别。需要只查当前位置时用declared类API,不把框架组合注解搜索当Java原生规则。
原码:代理类怎么生成与分派
JDK 动态代理的公开入口只做三件事:判空 handler、查找或生成代理类、用 handler 构造实例。
固定原码关键两步:Proxy.java,jdk-17.0.12+7,L1037–L1039。前面先拒绝空handler,随后取得代理构造器并创建实例:
Constructor<?> cons = getProxyConstructor(caller, loader, interfaces);
return newProxyInstance(caller, cons, h);getProxyConstructor 内部经 proxyCache 按类加载器与有序接口列表缓存构造器;缓存有效期间重复创建可复用代理类,不缓存业务目标对象。传入非接口类型会在这里得到 IllegalArgumentException。代理生成的接口转发方法会转调 h.invoke(proxy, method, args),包括从 Object 继承的 hashCode/equals/toString(即使接口重新声明,传来的相应Method仍以Object为声明类)。
handler 抛出的异常怎么处理,由生成代理类时的 catch 列表决定:
异常集合起点原码:ProxyGenerator.java,jdk-17.0.12+7,L343–L344。
uniqueList.add(Error.class); // always catch/rethrow these
uniqueList.add(RuntimeException.class);L339–386随后按异常继承关系消除冗余。如果 throws 包含 Throwable,可直接传播全部类型;否则生成方法按集合放行,余下Throwable走包装路径。
规则因此可以写成三条:handler 抛出的、接口方法声明过的受检异常原样抛出;Error/RuntimeException 原样抛出;其余(接口没声明的受检异常)包成 UndeclaredThrowableException。排障时看到 UndeclaredThrowableException,就是 handler 抛了接口签名没承诺的受检异常——先看cause,统一接口异常协议或明确转译并保留原因,不把失败吞成成功。多接口重名方法的受检异常须同时符合相关声明,不能只看handler拿到的一个Method。
动态代理:能拦截什么、不能拦截什么
完整示例保存为 ProxyUse.java,用JDK17编译执行;没有第三方库或远程依赖:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
public class ProxyUse {
public interface Audit {
void one(); void all();
default String label() { return "audit"; }
}
public static class Target implements Audit {
int work;
public void one() { work++; }
public void all() { one(); one(); }
}
public static void main(String[] args) {
Target target = new Target();
int[] entered = {0};
Audit proxy = (Audit) Proxy.newProxyInstance(
Audit.class.getClassLoader(), new Class<?>[]{Audit.class},
(p, method, values) -> {
if (method.getDeclaringClass() == Object.class) {
return switch (method.getName()) {
case "equals" -> p == values[0];
case "hashCode" -> System.identityHashCode(p);
case "toString" -> "AuditProxy";
default -> throw new AssertionError(method);
};
}
if (method.isDefault())
return InvocationHandler.invokeDefault(p, method, values);
entered[0]++;
try { return method.invoke(target, values); }
catch (InvocationTargetException e) { throw e.getCause(); }
});
proxy.all();
System.out.println(entered[0] + ":" + target.work + ":" + proxy.label());
}
}预期 1:2:audit:all进入handler一次,目标内部工作两次;default也先到handler,本例显式用Java17可用的invokeDefault选择实现。Object三方法也要处理:这里选择代理身份相等;直接把equals交目标可能令proxy.equals(proxy)为false,handler里打印proxy又可递归toString。不要把所有方法一律反射转调目标当通用模板。
边界清单:
- 按Proxy SE17,代理输入须为合规的接口(如sealed接口不合规),且类加载器能见同一接口Class,非公有接口有同包/模块要求;
newProxyInstance传入类对象抛IllegalArgumentException。要代理无接口的类需要子类化方案(CGLIB/ByteBuddy),且受 final 类/方法与构造器语义限制。 - 拦截的是"经代理对象的调用";目标对象内部的
this调用、静态调用、private方法都不经过 handler。 - handler 里
method.invoke(target, ...)的target若是实现类实例,返回的代理对象与target是两个对象;proxy == target恒为false。 - 反射执行的目标方法自身抛异常时包装InvocationTargetException(cause为目标异常);若handler未剥此受检包装且接口不允许它,代理再包UndeclaredThrowableException,两层可叠加。参数/权限失败并非都会包装成这两类。
- handler返回值须兼容接口返回类型,原始类型返回null可在拆箱NPE,类型不符可CCE;default继承发生演进时,先前的Method也可能不再是合法invokeDefault目标。
- Spring AOP还可能采用子类代理,不能把全部Spring/MyBatis行为都套成JDK接口代理。Spring 侧的装配与失效场景以AOP 正文为权威,本文只定 Java SE 机制。
错误输入与排障链
| 症状 | 直接原因 | 验证 | 处置 |
|---|---|---|---|
getAnnotation 返回 null | 保留策略非 RUNTIME;查错层级(接口不继承) | javap -v 看 RuntimeVisibleAnnotations | 改策略或改用编译期链路 |
InaccessibleObjectException | 所需模块/包未开放,无法抑制访问检查 | 读调用模块、目标模块与包 | 公开API或修自身模块;定向开孔须受控,MethodHandles不绕权 |
InvocationTargetException | 反射调用的目标方法抛异常 | getCause() | 按 cause 排障,不要修 invoke 外层 |
UndeclaredThrowableException | handler抛出不合接口契约的受检异常,可能多包了反射层 | 对照cause与所有同签名throws | 统一异常协议,剥反射包装/明确转译保留原因 |
| int接口返回时NPE/CCE | handler返回null或不兼容包装类型 | 记录接口返回类型和实际值类型 | 改为兼容Integer再调用,确认返回7 |
| 代理上统计少一次 | 目标内部 this.xxx() 自调用 | 在 handler 计数比对调用次数 | 经代理引用调用或拆分对象 |
ClassCastException: $ProxyN cannot be cast to X | X 是实现类,代理只实现接口 | 看代理的 getInterfaces() | 面向接口声明类型 |
| 重复注解读不全 | @Repeatable 容器包裹 | getAnnotationsByType | 按容器类型解析 |
架构师变化条件:扫描、缓存和审计覆盖预算
这是可计算的教学评审案例,不是压测结论。设一次发布有2,000个类型、每类12个候选方法,则每次发布24,000次候选检查。若每秒5,000个请求都重复整个扫描,则每秒120,000,000次候选检查。时点和单位不同,不能只说“反射很慢”。接口p99预算设为50ms,但业务/网络/反射耗时未知,不虚构节省毫秒数。
方案A在启动阶段扫描、校验并按应用生命周期缓存已验证Method:请求路径简单,缺配置可在接流量前失败;代价是启动时长、缓存内存和热加载失效责任。方案B用手写适配器或编译期生成绑定:签名错误更早暴露、运行时发现减少,但增加生成器与构建产物维护;动态插件直到运行时才知道类型时不能全静态。短命工具只调用一次,也可能直接反射更合适。
审计不变量若是“每个已提交操作有可追溯记录”,就不能用入口计数替代。每秒100个all,每个all含20个操作,对应2,000个操作/s;只记录100次入口无法证明2,000个操作均被审计。应先定义入口审计还是操作审计,并把持久化原子性放到所属事务/消息课程中验证,不把代理日志当outbox。
评审产物应列扫描候选数/时长、缓存条目/卸载生命周期、缺配置是否拒绝启动、正确输入恢复、目标内部操作覆盖,以及接口升级的回归结果。两种方案都由应用维护者负责修绑定和发布恢复;不能通过忽略cause或全局开放包让服务“启动成功”。本节简单API的架构取舍均落在这个案例,代码探针不提供生产容量保证。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| JDK 8 → 模块化JDK → 17 | 8无模块系统;9–15有过渡性宽松访问,16起默认收紧,17不能靠旧illegal-access恢复整个旧模式;仍需看包与启动参数 |
| GraalVM Native Image / AOT | 动态路径能否被构建分析识别;不能识别的可能需metadata或构建期生成,按具体版本/工具查证;本文未实跑 |
| 换字节码增强库(CGLIB/ByteBuddy) | 生成策略、final 限制、异常包装、类加载器行为不同;结论不通用 |
| 跨类加载器 | 接口 Class 不同则代理失败;Class.forName 双亲委派语义按容器复核 |
| Lombok / 编译期处理器 | 效果在编译期,运行时注解与反射不参与;升级 JDK 与处理器版本需重验 |
| 虚拟线程(JDK21+) | 不改变本节任何代理/反射语义,只改变线程调度 |
实验:输入、步骤、预期与边界
实验目录:labs/java-senior(ReflectionProbe.java,用 run.sh reflect 运行,JDK 17)。
cd <仓库根>
JAVA_HOME=$(/usr/libexec/java_home -v 17) bash labs/java-senior/run.sh reflect| 场景 | 输入 | 预期 |
|---|---|---|
| 注解可见性 | CLASS 与 RUNTIME 注解各标一处 | 前者反射读 null,后者非 null |
@Inherited 范围 | 注解标父类与标接口,子类/实现类分别查 | 父类可查到,接口查不到 |
| 越权字段 | 对 String.value setAccessible(true) | JDK17 抛 InaccessibleObjectException |
| 反射异常包装 | Method.invoke 调一个会抛业务异常的方法 | 外层 InvocationTargetException,cause 为业务异常 |
| 代理未声明受检异常 | handler 抛接口未声明的受检异常 | UndeclaredThrowableException,cause 保留 |
| 自调用统计 | 代理外层方法内部 this 调另一方法 | handler 只计外层 1 次 |
| 无Retention及重复注解 | 默认CLASS注解;两个Tag | 默认反射不可见;容器存在,by-type得read/write |
| 错签名/参数与恢复 | 查read(Integer)、read(String)传Integer,再改字符串 | 依次拒绝查找/调用,正确调用返回o-1:ok |
| 非接口代理输入 | 把Order.class交给Proxy | IllegalArgumentException;改接口后default/Object场景成功 |
| default与Object | 显式选default、身份equals/hash/toString | 返回hello-default,身份契约成立 |
| 接口声明/非受检异常 | 声明IOException;handler另抛RuntimeException/Error | 三类都保留同一异常对象;与未声明受检异常包装对照 |
| 原始返回类型错误与恢复 | int接口handler依次返回null、String、Integer(7) | NPE、CCE;正确Integer返回拆箱7 |
本批实际输入、stdout/stderr、退出状态和环境由U04统一实验记录登记,不能仅据表格宣布实跑。未覆盖:其他 JDK 行为差异、MethodHandles 与 VarHandle 对照、Native Image 反射配置、跨类加载器代理。
追问链
第一层:为什么注解不设计成都保留到运行时?
保留策略是成本与受众的分离:绝大多数注解(@Override)只在编译期有意义,写进运行时元数据是无谓的 class 体积与加载开销;而框架装配必须在运行时读到,才需要 RUNTIME。策略让注解作者显式声明读者群,而不是让所有注解承担所有成本。
第二层:反射的访问检查为什么不能干脆全部放行?
访问检查是封装的运行时兑现。放行等于宣布 Java 的 private/包私有只是注释:任何依赖都可以反射改写 String.value、Integer cache 这类库内部状态,库作者失去了在不破坏外部的前提下修改实现的自由。强封装(JEP 396/403)把这条边界从"默认可越权"改为"默认拒绝、显式开孔",是恢复封装语义而不是新增限制。
第三层:自调用问题为什么无法在代理层"修好"?
代理对象与目标对象是两个实例。目标方法里的 this 是目标实例,编译成 invokevirtual this.doWork,与代理实例没有任何关系——代理层根本看不见这次调用。可选方案都改变了别的东西:注入自身代理引用改变了对象拓扑(且容易循环依赖);拆分对象改变了职责边界;字节码改写 this 调用改变了语义假设。这是"拦截"与"面向对象封装"的结构冲突,不是实现缺陷。
第四层:框架选型评审时,如何审查一个反射/代理方案?
- 元数据链路:注解保留策略与消费者是否一致?注解查不到时失败模式是什么(null/异常/静默跳过)?
- 访问边界:是否依赖
setAccessible或--add-opens?升级 JDK 或模块化封装收紧时谁负责修? - 拦截范围:哪些调用经过代理(接口方法?public?protected?)?自调用与私有方法的语义是否向使用者说清?
- 异常协议:包装层次(InvocationTargetException/UndeclaredThrowableException/框架再包一层)是否统一剥壳?日志是否会打出外层异常而丢 cause?
- 运行形态:Native Image/AOT 下反射与动态代理是否需要配置?配置由谁维护、何时校验?
技术案例
- 注解驱动的配置校验:
@Retention(RUNTIME)+ 启动时反射扫描,缺失/非法时 fail-fast;不要让 null 注解静默落到默认值。 - Mapper 接口绑定:MyBatis 用 JDK 动态代理把接口方法转成 SQL 执行;理解
method.invoke的异常包装才能解释 Mapper 抛出的框架异常结构。 - 审计切面:需要覆盖"目标内部调用"的审计不要建在代理上,改在接口边界或事件源(业务提交后事件)上,代理只做经它进出的调用。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java Language Specification, SE 17(§9.6.4.2注解保留) | Java SE 17 | 保留策略、@Inherited、可重复注解规则 |
| java.lang.reflect API | Java SE 17 | Proxy/InvocationHandler/Method/Field 契约与异常 |
| JEP 403: Strongly Encapsulate JDK Internals | JDK 17 | InaccessibleObjectException 与 --add-opens 边界 |
| OpenJDK 17u Proxy.java | jdk-17.0.12+7 | 代理生成入口与缓存(L1024–L1040) |
| OpenJDK 17u ProxyGenerator.java | jdk-17.0.12+7 | 未声明受检异常集合归并(L339–L386) |
本地 ReflectionProbe | Liberica JDK 17.0.19 | 注解可见性、越权、错误输入/恢复、包装、自调用、default/Object边界 |
访问日期:2026-10-24。源码行号与整文件哈希按 jdk-17.0.12+7 固定;实验实际构建以统一证据为准,与源码tag分开记录,不互相冒充。本文覆盖java.base的java.lang.annotation与java.lang.reflect相关行为,不是整个java.base逐API完成声明。异常与资源关闭以类型与异常正文为权威,本probe不重复该实验。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。