final 方法为何在两种代理中得到不同结果

订单接口声明了 fixed(),实现类把它声明成 final。同样一个计数通知,通过 JDK 代理调用时计数增加,通过 CGLIB 代理调用时计数不增加。不能据此判断 Spring 偶尔忽略了 final,也不能把“final 不能被代理”当成不带条件的规则。

JDK 代理实现接口,接住接口调用以后委托目标对象;它不需要覆盖实现类的 final 方法。类代理依赖子类覆盖可以覆盖的方法,把调用转入拦截器。final 限制覆盖,private 方法不被子类继承,这两种限制与选用的代理入口直接相关。JLS 21 方法覆盖与 final

本文要验证的问题是:改变代理策略后,哪些调用确实经过通知,哪些只在类型上看似相同?实验固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。完整入口为 examples/spring-framework-lab/src/main/java/blog/spring/Chapter14.java。

接口代理与子类代理的调用边界

把接口、代理实例和目标实例分开

一个变量的静态类型只能约束编译期允许的方法,不能证明运行时对象就是实现类。实验中 Orders proxy 的对象可能是 JDK 生成的代理类,也可能是 OrderTarget 的 CGLIB 子类;两者都实现 Orders。单独持有的 OrderTarget target 是业务字段实际所在的原对象。

1
2
3
4
5
6
7
8
var target = new OrderTarget();
var factory = new ProxyFactory(target);
factory.setProxyTargetClass(classProxy);
factory.addAdvice((MethodInterceptor) invocation -> {
calls.add(invocation.getMethod().getName());
return invocation.proceed();
});
Orders proxy = (Orders) factory.getProxy();

ProxyFactory(Object) 会识别目标接口。这里特意提供同一个实现类,并显式设置 proxyTargetClass,把“目标有没有接口”与“是否强制类代理”两个变量分开。实验断言 proxy != target,再通过 Advised.getTargetSource().getTarget() 断言得到的正是 target,而不只是比较类名。identityHashCode 仅用于日志定位,判等使用引用运算符。

JDK 代理不能转换成 OrderTarget;可以转换成 Orders。类代理能够通过 instanceof OrderTarget,但这不意味着代理对象与目标对象合并了。强制转换能力与业务执行位置是两件事。错误地把类代理上的字段视为目标字段,尤其在不能拦截的方法中读取状态,会引出与本次计数实验不同的问题。

工厂分支决定生成机制

DefaultAopProxyFactory.createAopProxy() 检查 optimize、proxyTargetClass 和是否存在用户提供的接口。普通接口路径返回 JdkDynamicAopProxy。进入类代理候选路径后,还要检查目标类是否为空、是否本身为接口、是否为已有 JDK 代理或 lambda 类;这些条件可能仍选择 JDK。其他可用目标类使用 ObjenesisCglibAopProxy。固定版本工厂源码

因此 proxyTargetClass=true 不宜被解释为任何输入都能得到一个有效子类。本实验的 final 类有可用接口,默认接口路径能够生成代理;切换强制类代理后,生成阶段抛出 AopConfigException。失败发生在 getProxy(),尚未进入订单方法。

状态变化可以表示为:ProxyFactory 收集配置 → 工厂选择 AopProxy 实现 → getProxy 生成可调用对象。这个阶段没有数据库提交、HTTP 请求或异步线程。把代理生成成功误当成通知已经执行,会漏掉后续方法匹配和调用入口。

public、final 与 private 的实际对照

同一方法名的通知记录按每次调用清空,避免上一个场景的计数污染下一个场景。place() 是普通 public 方法;fixed() 是接口中的方法,但目标实现标成 final;secretEntry() 是 public 方法,其内部调用 private secret()。

场景 JDK 代理 类代理
proxy.place() 记录 place 一次 记录 place 一次
proxy.fixed() 记录 fixed 一次 没有通知记录
proxy.secretEntry() 仅记录 secretEntry 仅记录 secretEntry
target.place() 没有通知记录 没有通知记录
proxy instanceof OrderTarget false true

private secret() 的返回值仍是 secret。这证明方法执行了,却没有证明它被单独拦截。外层 secretEntry 的一次通知不能归因给内部 private 方法。定位问题时应同时记录通知匹配的方法名和目标方法产生的业务结果。

final 方法示例只返回常量,刻意避免把代理实例字段和目标实例字段的差异混入这个实验。真实类代理上的 final 方法无法经覆盖转发,直接读取字段时可能触及代理实例自身的状态。不能因为这个常量示例返回正确,就把 final 业务方法视为安全的统一拦截入口。Framework 代理限制

两种入口最终怎样调用目标

JDK 路径进入 JdkDynamicAopProxy.invoke()。它取得 TargetSource 中的目标,按方法和目标类取得拦截器链。链为空时直接反射调用目标;链非空时构造 ReflectiveMethodInvocation 并调用 proceed。无论哪条路径,直接拿原 target 调用都不会先经过这个 invoke。JDK 代理源码

CGLIB 的 DynamicAdvisedInterceptor.intercept() 同样取得目标与调用链,再在目标上执行。子类方法可拦截,是进入该回调的条件;它并不把目标对象内部所有调用重新转入代理。第 18 篇用两种代理的相同自调用反例验证这一点。CGLIB 回调源码

上游 CglibProxyTests.proxyCanBeClassNotInterface 检验不依赖接口的代理能力,proxyAProxy 检验再次包装已有代理的路径。它们说明类型与包装层数需要分别检查,不替代本文 final/private 的应用断言。上游测试源码已经阅读,没有在此执行完整 Spring Gradle 测试集。相关上游测试

原生 Framework 与 Boot 默认值分别记录

本文运行的是原生 ProxyFactory,没有启动 Boot。给定实现接口的目标且未强制类代理,实验观察到 JDK 代理;无接口目标则需要另看工厂的分支。不能由一个具体输入推出 Framework 中所有代理都使用某一种技术。

Boot 3.5 的官方 AOP 文档说明,其自动配置默认启用类代理,可通过 spring.aop.proxy-target-class=false 调整。这是 Boot 装配层的文档事实,本文没有据此声称运行了 Boot 对照工程,也没有把在线文档展示的补丁号当作本实验版本。Boot 3.5 AOP 配置

复现、反例与练习

在实验工程目录指定 JDK 21 后运行:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter14

程序逐项断言代理种类、目标引用、三个方法的通知计数、原对象绕过和 final 类生成失败。任意结果偏离即抛出 AssertionError;只有全部通过才输出 RESULT Chapter14 PASS。原始输出位于 evidence/14/local-20261002/run.txt,退出码另存 exit-code.txt。实验仅分配进程内对象,没有创建执行器或外部资源。

反例题:实现类方法加上 final,是否一定能阻止该方法的所有 AOP 通知?用上述接口调用场景即可否定。真正要问的是调用经过接口代理还是依赖子类覆盖。

可执行练习:删掉 OrderTarget 的 Orders 接口,并把接收变量改成 Object。保留默认配置重新运行,通过 AopUtils.isCglibProxy() 观察工厂选择;再把目标类声明成 final,确认失败发生在 getProxy,而不是业务方法。修改后应把原来的 JDK 断言同步改为这一明确的新预期。

排查时先确认引用身份,再确认实际代理类型,最后核对方法可见性与调用路径。类型转换成功只能回答“这个引用暴露了哪些方法”,不能回答“这次方法执行了哪些通知”。

系列入口:深入 Spring(00):从手动组装到可验证的容器实验。