qinghuan
← Java SE / 全部笔记

单元 56 / Java SE

P1 · 重点提升L2 · 进阶

反射、注解与动态代理如何连起来

从运行时查看方法开始,理解框架怎样读取注解,再通过代理在调用前后添加处理。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能写出基于接口注解的代理示例,并识别运行时增强的调用边界。

学习理由、准备要求与知识点

为什么学它

反射和代理解释框架怎样读取元数据并增强调用,也帮助定位增强未生效。

先读这些内容

知识点清单

  • 公开成员反射访问
  • RetentionPolicy.RUNTIME
  • Target 使用位置
  • 接口动态代理
  • InvocationHandler
  • InvocationTargetException 解包
  • 接口注解查找
  • 代理调用与内部调用

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

框架怎样在运行时知道一个类有哪些方法?反射提供了查看类型、构造器、字段和方法信息的入口,也能在允许访问时调用它们。Class 表示类型信息,Method 表示方法信息,拿到它并不等于已经调用。getMethods 主要获取包括继承方法在内的公开方法;getDeclaredMethods 看当前类声明的方法,但不会自动绕过访问限制。

注解是在代码上附加的说明数据,本身不会自动开启事务、注册接口或做校验。编译器、处理器或框架必须读取它,再执行相应规则。Retention 决定它保留到哪个阶段,需要反射读取时应明确使用 RUNTIME;Target 决定可以写在哪里。Inherited 也有自己的适用范围,不表示所有方法或字段注解都会自动继承。

JDK 动态代理会在运行时创建一个实现指定接口的对象。调用者调用这个代理的方法时,调用会交给 InvocationHandler,由它决定转给真实对象、记录日志还是拒绝。它不是把任意普通类自动变成子类代理,而且只有经过代理入口的调用才会触发处理器。

处理器还要按接口要求返回正确类型、处理异常,并考虑 equals、hashCode、toString 等方法,不能统一返回 null。框架 AOP 可能采用其他代理方式,还要单独查看框架规则。

文中这几个词,先认一认

运行时元数据
程序运行时还能读取的声明信息,例如类名、方法参数和保留下来的注解。
InvocationHandler
JDK 动态代理收到方法调用后,把代理、方法和参数交给这个处理器执行相应逻辑。
保留策略
规定注解是否保留在源码、class 文件或运行阶段,以及何时能被读取。
反射访问检查
找到字段或方法后,仍要按语言和模块规则判断是否有权访问或调用。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

反射框架通常分为发现、验证、缓存和调用四步。启动时扫描明确范围,检查注解与方法签名,建立可用映射;请求时只通过已验证映射定位方法。若每次请求都重新扫描,成本与错误暴露都更晚;若允许用户任意提交类名和方法名,则把过大的执行能力交到了输入边界。

Method.invoke 可能把目标方法抛出的异常包装在 InvocationTargetException 中。调用层应保留实际 cause 并按接口约定转换,而不是只显示反射包装异常。目标异常、访问失败和参数不匹配是不同问题,错误分类能帮助区分业务失败与注册表配置错误。

代理调用经过生成的接口实现进入处理器,处理器再决定委托、记录或拒绝。一个普通目标对象内部直接调用自身方法,未必再次经过外部代理入口。图中显式标出代理引用和目标对象,帮助解释为什么只在类上写注解,不保证所有内部调用都具有相同拦截效果。

正在加载流程图…

元数据需要明确消费者;代理、目标与反射访问各有独立职责和限制。
查看流程图源码(Mermaid)
flowchart TD
 A["声明 RUNTIME 注解"] --> B["启动阶段扫描与签名验证"]
 B --> C["构建允许的调用映射"]
 D["调用者持有接口代理"] --> E["InvocationHandler"]
 E --> F["委托目标公开方法"]
 C --> F
 F --> G["返回结果或保留目标异常原因"]

按顺序看,每一步做了什么

  1. 限定要扫描的类型与成员范围。
  2. 验证注解保留和目标位置。
  3. 检查公开签名及重复注册。
  4. 缓存合法方法映射。
  5. 调用时保留目标异常原因并遵守模块边界。

03对照实现代码

动态代理如何把接口调用送入处理器 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/reflect/Proxy.java · java.lang.reflect.Proxy#newProxyInstance(ClassLoader,Class[],InvocationHandler)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.UndeclaredThrowableException;
public final class SourceModel {
    public interface Service { String call(String input); }
    public static Service proxy(InvocationHandler handler) {
        return new Service() {
            @Override public String call(String input) {
                try {
                    // 手写一个固定接口的代理入口,展示方法与参数转发。
                    return (String) handler.invoke(this,
                            Service.class.getMethod("call",String.class), new Object[]{input});
                } catch (RuntimeException | Error e) { throw e; }
                catch (Throwable checked) { throw new UndeclaredThrowableException(checked); }
            }
        };
    }
}

真实 Proxy.newProxyInstance 校验处理器并取得适合接口组合的代理构造器,然后创建实例。代理类的生成与缓存还涉及类加载器、接口可见性和模块规则。模型手写单个固定接口实现来展示转发形状,不生成字节码,也不复制真实缓存、访问检查或全部方法处理。

调用处理器时传入的是代理对象、对应接口 Method 和实参数组。模型中的强转说明返回值必须符合接口返回类型;基本类型返回还涉及装箱拆箱,错误返回可能造成类型或空值异常。接口签名并不会因为经过通用 Object 返回值的处理器就失效。

接口没声明的受检异常,代理不能想抛什么就原样抛给调用者,需要按规则包装。模型保留 RuntimeException 和 Error,并包装其他 Throwable,只展示这个受限接口的简单情况。如果接口声明了受检异常,真实代理还会进一步判断,所以不能把模型的统一包装照搬到所有接口。

Object 方法、默认方法、重复接口方法与类加载器边界都属于更完整代理语义,模型没有涵盖。反射与代理应使用公开支持的入口,遇到模块强封装失败时应检查设计与依赖版本,而不是默认 setAccessible 或添加开放所有包的参数来掩盖边界。

代码先调用谁,再调用谁

  1. newProxyInstance 验证处理器与接口组合
  2. 查询或建立代理构造路径
  3. 创建实现接口的代理对象
  4. 代理方法将 Method 与实参交给 InvocationHandler
  5. 处理器返回值或异常按接口契约传递

04动手写一个例子

构建只允许公开注解方法的路由注册表

扫描处理器当前类声明的 @Route 方法,只接受 public 非 static 的 String method(String) 签名。路由名唯一;调用时只允许已注册名称,保留目标异常原因,不开放私有访问。

输入 1
@Route(up) 对应公开up方法,输入ab
输出 1
AB
输入 2
两个方法同名路由dup
输出 2
启动注册时拒绝
输入 3
未知路由
输出 3
拒绝调用

先扫描全部声明方法,筛出运行时注解,再验证可见性、静态属性、参数和返回类型。发现注解却签名不符合时立即失败,比静默忽略更容易定位配置错误。只扫描当前声明类是明确范围,不自动继承父类路由;若要支持继承,应另行定义覆盖与冲突规则。

用 Map 保存路由名到 Method,putIfAbsent 检测重复。反射返回方法的枚举次序没有稳定业务保证,因此不能靠“后扫描覆盖先扫描”决定冲突赢家。注册完成后保存不可修改映射,请求阶段只做名称查询与已验证调用,不根据外部输入重新查任意方法。

调用失败时区分未知路由、反射访问问题和目标方法异常。InvocationTargetException 的 cause 才是目标抛出的实际错误,包装时保留它。示例没有 HTTP 服务、鉴权或序列化,只研究本地受限映射机制;成为 Web 路由还需更完整的协议与权限层。

为什么这样写是对的注册表中每个名字唯一,并且只对应已验证公开、非静态且签名准确的方法。

时间 注册 O(m) 期望映射成本,m 为声明方法数;调用查找期望 O(1) 加反射及目标方法成本。额外空间 注册时 O(m+r) 临时与结果空间,m 为声明方法数、r 为合法路由数;构建后持久路由表 O(r)。

启动阶段每个方法检查一次,调用阶段不重复扫描;名称长度、反射开销和目标业务耗时需按实际情况额外计入。 getDeclaredMethods() 返回长度为 m 的方法数组,因此注册期间不能只计算最终映射的 r 项。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 72 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.HashMap;
import java.util.Map;
public class Main {
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    @interface Route { String value(); }
    static final class Registry {
        private final Object target;
        private final Map<String,Method> routes;
        Registry(Object target) {
            if (target == null) throw new IllegalArgumentException("target");
            this.target = target;
            Map<String,Method> found = new HashMap<>();
            for (Method method : target.getClass().getDeclaredMethods()) {
                Route route = method.getAnnotation(Route.class);
                if (route == null) continue;
                int flags = method.getModifiers();
                // 只注册公开、非静态且精确签名的方法,不开启私有访问。
                if (!Modifier.isPublic(flags) || Modifier.isStatic(flags)
                        || method.getReturnType() != String.class
                        || method.getParameterCount() != 1
                        || method.getParameterTypes()[0] != String.class
                        || !route.value().matches("[a-z]+"))
                    throw new IllegalArgumentException("invalid route");
                if (found.putIfAbsent(route.value(),method) != null)
                    throw new IllegalArgumentException("duplicate route");
            }
            routes = Map.copyOf(found);
        }
        String call(String name, String input) {
            Method method = routes.get(name);
            if (method == null) throw new IllegalArgumentException("unknown route");
            try { return (String) method.invoke(target,input); }
            catch (InvocationTargetException e) {
                throw new IllegalStateException("handler failed",e.getCause());
            } catch (ReflectiveOperationException e) {
                throw new IllegalStateException("reflection configuration",e);
            }
        }
    }
    public static final class Handler {
        @Route("up") public String up(String s) { return s.toUpperCase(java.util.Locale.ROOT); }
        @Route("fail") public String fail(String s) { throw new IllegalArgumentException("business"); }
    }
    public static final class Duplicate {
        @Route("dup") public String a(String s) { return s; }
        @Route("dup") public String b(String s) { return s; }
    }
    public static final class Hidden {
        @Route("hidden") private String secret(String s) { return s; }
    }
    static void reject(Runnable action) {
        try { action.run(); } catch (IllegalArgumentException e) { return; }
        throw new AssertionError("must reject");
    }
    public static void main(String[] args) {
        Registry registry = new Registry(new Handler());
        if (!registry.call("up","ab").equals("AB")) throw new AssertionError();
        // 未知、重复和私有路由分别在应有边界失败。
        reject(() -> registry.call("missing","x"));
        reject(() -> new Registry(new Duplicate()));
        reject(() -> new Registry(new Hidden()));
        reject(() -> new Registry(new Object()).call("up","x"));
        // 目标异常原因必须保留,不能只剩反射包装层。
        boolean preserved = false;
        try { registry.call("fail","x"); }
        catch (IllegalStateException e) { preserved = e.getCause() instanceof IllegalArgumentException; }
        if (!preserved) throw new AssertionError("cause");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 没有路由的方法被忽略,带注解但非法的方法拒绝。
  • 重复名字不依赖扫描顺序覆盖。
  • 私有方法不通过 setAccessible 开放。
  • 目标异常原因保留为 cause。
进阶选读 · 可选深入:类型级缓存与类加载器生命周期

场景框架重复扫描很多对象,插件还可能卸载或热更新。

做法按真实类型缓存已验证元数据,考虑 ClassValue 或明确生命周期清理;缓存键同时关注类加载器身份,避免全局强引用阻止卸载。

复杂度首次扫描 O(m),复用降低发现成本,但调用与缓存维护仍有成本。

好处与代价缓存提高效率却增加失效和生命周期复杂性,不能按类名字符串把不同加载器的类型混合。

05项目里怎么用

反射框架应尽早验证配置,让重复路由、错误签名和缺失注解在启动阶段暴露。运行阶段只接受受限映射的逻辑名称,不让用户任意指定类和方法。注解是声明意图,不是授权凭证;真正权限控制应在业务和协议边界明确实施。

异常与性能观察要分层。反射调用开销可能在高频微操作中可见,也可能被数据库和网络远远覆盖;先缓存可复用元数据,再测实际路径,不必为了猜测性能提前采用不稳定内部接口。方法句柄等替代方案也有自己的访问和适配成本。

模块、代理方式和框架版本都会影响哪些成员可访问、代理怎样生成。小例子能在未命名模块运行,不代表各种部署都兼容。优先使用公开 API、明确注册方式和版本要求;动态加载和扫描越多,越要说明扫哪些类、缓存何时清理、错误怎样定位。

容易踩的坑

  • 没有 RUNTIME 保留却期待反射读到注解。
  • 把 getDeclaredMethods 理解为自动允许调用私有方法。
  • 忽略 InvocationTargetException 的实际 cause。
  • 认为目标对象内部调用必然再次经过外部代理。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
显式注册接口类型检查与调用路径清楚需要手工配置更多连接稳定核心业务和小型框架
反射注解发现减少重复注册并可扩展启动验证与访问边界更复杂受控框架基础设施
JDK 接口代理可集中处理接口调用受接口、异常和类加载边界约束装饰、拦截与适配

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01注解为什么不会自动执行功能?

展开参考答案与推导

它只是元数据,需要编译器、处理器或框架读取后实施行为。没有消费者时,写一个注解不会注册路由、开启事务或校验字段,保留策略也决定消费者能否看到它。

02发现私有 Method 是否表示可以 invoke?

展开参考答案与推导

不表示。反射发现与访问权限检查不同,Java17 模块边界还可能增加限制。应使用公开支持的访问路径,不把开放全部封装当作默认解决方式。

03JDK 动态代理为什么通常围绕接口?

展开参考答案与推导

它创建实现指定接口的代理类,并把接口调用送到 InvocationHandler。它不是任意具体类的通用子类生成器;其它框架代理机制可能不同,需要分开说明。

换个情况,还会处理吗

01路由名字重复时有时调用A有时调用B,应该怎么修?

展开参考答案与推导

不要依赖反射扫描顺序覆盖。注册阶段检测重复并明确拒绝或采用显式优先级,只有规则确定后才建立映射;运行时猜测赢家会让配置错误难以复现。

02日志只有 InvocationTargetException,业务原因在哪?

展开参考答案与推导

查看 getCause,它通常保存被调用方法抛出的目标异常。调用边界应保留或转换这个原因,同时区分访问失败和参数错误,避免所有反射失败都显示同一包装名称。

03插件卸载后内存仍保留,反射缓存可能哪里有问题?

展开参考答案与推导

全局强引用缓存可能持有 Class、Method 或处理器对象,从而连带保留类加载器。检查缓存键值生命周期并设计清理或适当类型级缓存,不能仅用类名字符串混同不同加载实例。

写代码试一试

01增加错误返回类型的注册测试

输入 / 条件
@Route方法返回int
输出 / 预期
拒绝注册

提示 签名验证应发生在调用前。

展开参考答案与推导

添加公开 int method(String) 的处理器并断言构造 Registry 失败。若等调用后强转才失败,说明启动验证遗漏了返回类型,错误边界过晚。

02允许继承的公开路由

输入 / 条件
父类公开带注解方法
输出 / 预期
按新覆盖规则注册

提示 先定义同名与重写语义。

展开参考答案与推导

可以从 getMethods 获取公开继承方法,但必须明确重写后注解是否存在、同路由冲突如何处理及桥接方法是否参与。不能只换一个反射 API 就声称继承规则完整。

03验证普通未注解方法不会开放

输入 / 条件
公开helper方法无Route
输出 / 预期
未知路由

提示 白名单来自显式注解。

展开参考答案与推导

向 Handler 增加 public helper(String),调用同名逻辑路由应仍被拒绝。这个测试确认注册范围不是所有公开方法,避免辅助能力意外进入执行入口。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能写出基于接口注解的代理示例,并识别运行时增强的调用边界。

本篇重点加练

重点练代理调用与异常传播,检查基础设施包装之后业务失败能否被准确识别。

  • InvocationHandler
  • InvocationTargetException 解包

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    设计用于公开计算方法的 @Operation 注解,声明允许标注位置与运行时读取要求;说明普通调用、反射发现和真正执行方法分别需要哪些条件。

    提交内容
    注解定义、发现步骤及访问边界说明。
    怎样算完成
    • 声明元数据与执行行为分开
    • 访问权限要求明确

    练到的知识点公开成员反射访问 · RetentionPolicy.RUNTIME · Target 使用位置

  2. 作业 02代码推演

    接口 Calculator 有 add 和 divide,InvocationHandler 记录方法名后转发。画出代理、处理器、目标对象的关系,预测直接调用目标与经代理调用的事件差异。

    提交内容
    对象图、调用样例与验证记录。
    怎样算完成
    • 区分接口调用入口与目标对象
    • 处理器职责不混入接口定义

    练到的知识点接口动态代理 · InvocationHandler

综合应用

  1. 作业 03排错实验

    分别把注解放在接口方法和实现方法上,通过两种 Method 获取方式读取;让目标方法抛出自定义异常,记录反射调用的异常结构。

    提交内容
    最小 Java 实验、自写发现/异常测试与实际记录。
    怎样算完成
    • 注解来源可定位
    • 保留真正业务失败原因

    练到的知识点InvocationTargetException 解包 · 接口注解查找

  2. 作业 04面试场景

    代理计时覆盖 process 方法,但 process 内部调用本对象 save。业务报告要求单独统计 save,设计三种调用路径的观测实验,说明报告应如何避免误计。

    提交内容
    调用路径图、日志字段和验收矩阵。
    怎样算完成
    • 内部调用与代理调用有独立证据
    • 计时范围与名称一致

    练到的知识点代理调用与内部调用

  3. 作业 05编程实践

    实现只登记公开、带 @Operation 且单个 String 参数的方法目录,输入操作名重复、返回类型不符合约定和未注解方法时要有明确政策;禁止任意类名输入执行。

    提交内容
    Java 实现、自写注册/调用/边界测试和运行证据。
    怎样算完成
    • 注册合同逐项验证
    • 暴露范围可审计
    • 异常信息不丢失原因

    练到的知识点公开成员反射访问 · RetentionPolicy.RUNTIME · Target 使用位置 · InvocationTargetException 解包 · 接口注解查找

  4. 作业 06编程实践

    为计算接口实现记录调用次数的代理,目标可能返回 null 或抛出业务异常;要求审计记录与调用者看到的结果符合预先声明的合同。

    提交内容
    Java 实现、自写成功/null/异常测试和运行证据。
    怎样算完成
    • 包装前后行为逐类比较
    • 记录次数可被独立验证

    练到的知识点InvocationHandler · InvocationTargetException 解包

  5. 作业 07代码推演

    代理接收业务方法调用和 toString、equals、hashCode 调用。为这些入口制定处理合同,记录 Method 来源与参数,禁止默认所有方法都是业务操作。

    提交内容
    入口矩阵和最小验证记录。
    怎样算完成
    • 对象基础方法被纳入检查
    • 日志本身没有制造无界调用

    练到的知识点InvocationHandler

  6. 作业 08排错实验

    让目标方法抛出带 cause 的异常,经直接调用、Method.invoke 和代理调用三条路径观察异常类型与原因链;只使用公开成员。

    提交内容
    Java 实验、自写原因保留检查及路径对照。
    怎样算完成
    • 同一业务故障能跨路径追踪
    • 包装结构与业务类别分开

    练到的知识点InvocationTargetException 解包

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成模块系统和类加载基础后:插件重新加载时代理与 Method 缓存仍存在,设计缓存生命周期与公开接口兼容性验收,说明哪些访问限制不应靠绕过来解决。

    提交内容
    生命周期图、释放检查和兼容性矩阵。
    怎样算完成
    • 缓存所有者明确
    • 不把反射当作无限权限入口

    练到的知识点公开成员反射访问 · 接口动态代理 · InvocationHandler · 代理调用与内部调用

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    接口从同步返回值改为返回稍后完成的任务对象。计时和错误统计应以哪一刻为边界?比较“方法调用完成”与“业务任务完成”两种监测需求。

    提交内容
    双层时序图和统计合同。
    怎样算完成
    • 失败发生时点可区分
    • 结论注明异步能力前置

    练到的知识点InvocationHandler · InvocationTargetException 解包

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗