深入 Spring 18:自调用、目标引用与方法元数据
注解没变,调用经过的对象变了
订单服务的 inner 方法有边界注解,outer 方法内部执行 this.inner()。外部调用 proxy.inner() 能进入通知,调用 proxy.outer() 却不会为内部 inner 再执行一次通知。两个路径执行同一个业务方法,得到同一个 order 字符串,但代理边界不同。
这里不能用“注解失效”代替原因。注解只是方法元数据;只有相应基础设施读取它、生成通知,并且调用进入这个通知所在的代理,元数据才会影响运行。本实验使用自定义 @Boundary 与计数拦截器把问题缩到一个变量:调用经过哪个对象。
固定环境为 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。代码位于 examples/spring-framework-lab/src/main/java/blog/spring/Chapter18.java。JDK 与 CGLIB 各执行相同六项调用断言,另有泛型桥接解析实验。
从代理入口追到目标上的 this
实验中的服务只有两个方法,outer 没有注解,inner 带 Boundary:
1 | |
外部代码持有的是 proxy,ProxyFactory 的 TargetSource 保存的是 target。进入 JdkDynamicAopProxy.invoke() 后,框架取得目标与当前方法的拦截器链;最终 AopUtils.invokeJoinpointUsingReflection(target, method, args) 在目标对象上执行方法。进入 outer 方法体时,this 因而指向 target。JDK 代理调用路径
outer 内部的 this.inner() 是 target 对自身的方法调用,没有重新访问外部变量 proxy。框架没有获得第二次代理入口的调用,自然没有机会为 inner 重新查找通知。方法仍正常返回,这也是只检查业务结果最容易漏掉边界错误的原因。
链为空不代表第一次代理入口不存在。本文 proxy.outer() 确实进入代理,只是 outer 没有匹配通知,之后在 target 上执行。应区分“外层调用经过代理但未匹配”与“内层调用根本未重新进入代理”。把两者混写,会错误地建议给 outer 再添加某个无关切点。
为什么强制 CGLIB 也没有修复
类代理是目标类的子类,但常规 Spring AOP 仍把代理实例与 target 分开。CGLIB 的拦截回调获得 TargetSource 中的 target,再在那里执行方法;它并不把目标对象内部所有 invokevirtual 都自动改写成对代理的调用。CGLIB 执行路径
实验对两个模式分别记录代理与目标的身份,并用引用相等断言确认 TargetSource 保存的就是原 target。即使 CGLIB 代理能够转换成 OrderTarget,执行 outer 时的接收者仍是另一个 target。类型继承关系没有消除委托关系。
完整的通知计数如下。每种模式使用新 AtomicInteger,避免前一个模式的累计结果混入后一个模式。
| 调用 | 返回结果 | 调用后的累计通知数 |
|---|---|---|
proxy.inner() |
order | 1 |
proxy.outer() 内部 this.inner |
order | 仍为 1 |
target.inner() |
order | 仍为 1 |
| 拆分服务通过依赖 proxy 调 inner | order | 2 |
| 显式 callback 边界调用 target.inner | order | 3 |
两个代理模式都满足同一张表。因此把 proxyTargetClass 从 false 改成 true,不是这个故障的修复。它改变代理的类型与可覆盖方法范围,没有改变目标体内这条自调用的接收者。
原对象泄漏是另一条绕过路径
创建代理后,局部变量 target 仍然能直接调用 inner。框架没有撤销原 Java 引用,也不会把它原地变成代理。工厂外自行 new 的对象、业务代码缓存的原实例、初始化阶段对外传递的 this,都可能造成类似绕过。
本文只证明保留原 target 的直接调用不会进入通知,没有据此断言任何生命周期回调一定泄漏原对象。诊断真实项目时要追踪引用是从 getBean 得到、从构造器得到,还是从某个回调参数得到。对象来源与调用栈比类名后缀更有解释力。
日志中的 identityHashCode 为观察引用提供线索,真正验证仍使用 ==。两个相同业务字段的对象可能 equals 为 true,代理对 equals 的处理又有自身规则;这些都不能代替引用身份断言。
拆分服务把边界放在依赖上
实验加入一个 Checkout,构造时传入 Orders proxy。Checkout.place() 调用依赖的 inner。它与 outer 的业务效果一样,却从 Checkout 跨到另一条可替换的对象引用,因而进入 inner 的代理通知。
1 | |
拆分类本身不是充分条件。如果传入 new OrderTarget() 或保留的 target,依然绕过通知。关键验收是依赖字段保存了哪一个引用。本文用 new Checkout(proxy) 做真实正例,前面的 target 调用提供真实反例;容器环境中则需要检查注入候选与实际暴露对象。
边界通常应该对应明确的业务职责。若只是为触发 AOP 把同一职责切成大量空转服务,调用复杂度可能超过收益。可以先确定希望在哪个操作整体上拥有事务、缓存或异步执行,再决定是否存在自然的独立组件;不必为了每个私有辅助方法都制造一个代理入口。
显式编程入口让边界不依赖注解扫描
另一个对照是 explicitBoundary(calls, target::inner)。它在执行 callback 前增加一次边界计数,随后直接调用业务。它没有创建代理,却能使调用位置显式表达“执行前进入这项机制”。
1 | |
这只是用于比较入口的计数函数,不是事务管理器、缓存实现或异步执行器。它验证的是“显式编程边界不要求调用先经过代理”的事实。后续 TransactionTemplate、Cache API 与执行器提交各有资源和异常契约,不能用本篇一个计数冒充那些能力已验收。
显式入口也不会天然修复错误边界:把 callback 范围选得过大,会把多项业务放进同一个范围;选得过小,外层操作仍可能只完成一半。代理或编程式只是如何进入机制,业务原子性与资源终态需要独立定义。
方法解析错误与自调用是两类故障
即使调用已进入代理,自定义通知仍可能漏读注解。JDK 接口方法、实现方法和泛型桥接方法不是同一个 Method 对象。本文 Pointcut 先调用 AopUtils.getMostSpecificMethod(method, targetClass),再检查具体方法上的 Boundary,避免只读取接口注解。
泛型实验中 Store<T>.save(T) 擦除后的接口签名是 save(Object),StringStore.save(String) 是具体实现,编译器还生成桥接方法。实验通过 Method.isBridge() 找到桥接方法,再断言 BridgeMethodResolver.findBridgedMethod() 得到 save(String)。接着从接口 Method 出发调用 getMostSpecificMethod,也必须得到带注解的具体实现。桥接解析工具
最后通过 Store<String> 接口代理实际调用 save(“order”),断言结果正确且通知恰好执行一次。这项运行对照证明解析不是孤立的反射练习:正确解析得到的注解确实参与了代理匹配。
但正确解析元数据不能改变 this 调用的入口。自调用中连代理匹配阶段都没有再次到达,改成更复杂的注解查找算法仍无济于事。排查可以先问“通知链是否获得这次方法调用”,再问“获得后解析成哪个 Method”。AopUtils 方法解析
上游 BridgeMethodResolverTests 覆盖泛型继承与桥接解析,AspectJExpressionPointcutTests 则对实现方法上的注解进行匹配测试。它们作为源码阅读证据;本文实际执行的是自己的 Store 代理与四项解析断言,没有运行上游全部测试。上游桥接测试
事务、缓存与异步的预览边界
代理模式下,自调用不会重新触发内层事务拦截器,不等于执行时必然“没有事务”:如果外层已经建立事务,内层普通调用可能仍在同一个线程绑定事务中。内层 REQUIRES_NEW 等声明未获得独立解释,与完全没有事务是不同结果,必须用管理器与连接证据区分。
类似地,自调用绕过缓存拦截器,不代表目标业务不执行;相反,原本期待命中缓存的路径可能继续执行计算。自调用绕过异步拦截器,则没有通过该拦截器提交执行器任务。本文没有执行这些扩展的真实资源实验,只以共同的代理入口解释待验证方向。事务、缓存、异步分别在对应章节提供独立断言。
AopContext.currentProxy() 和注入 self 引用也是可能的入口选择,但需要额外前提。currentProxy 依赖 exposeProxy 和当前调用上下文,并增加业务代码对 Spring AOP 的耦合;跨线程后不能假设仍有同一个上下文。本篇没有把这两种方式作为已运行修复。官方文档更倾向通过重构避开自调用,并指出 AspectJ weaving 在字节码中织入通知,具有不同边界。官方自调用说明
复现、反例与练习
1 | |
原始运行证据在 evidence/18/local-20261002/run.txt,程序分别验证两种代理的六项路径,再验证四项泛型方法解析与真实代理调用。退出码单独记录,只有全部满足才输出 RESULT Chapter18 PASS。实验没有创建 Context、线程池或外部资源。
反例题:给 inner 加更多注解,或换成类代理,能否使原有 this.inner 自动重新进入边界?本篇两种模式的实际计数已经否定后者;前者仍需经过同一代理入口才有机会被读取。
可执行练习:给 outer 也加 Boundary。重新运行时,proxy.outer 会额外增加一次通知,但内部 inner 仍不会产生第二次通知。把预期计数写成“外层一次、内层零次”后复跑;再把 Checkout 的依赖从 proxy 换成 target,观察拆分服务场景退回绕过。两项修改分别隔离通知匹配与引用来源。

