Spring AOP 自调用、代理与 private 方法引发的诡异 NPE

一、背景介绍

一次真实的线上排查记录。为了给 Agent 调用加日志切面(AOP),我们把内部方法调用改成了走代理,结果报出 chatModel is null 的 NPE。一开始以为是大模型 Bean 没注入,最后发现真正的坑藏在「代理 + private 方法」的组合里。

bash 复制代码
ERROR ArticleAgentService : 阶段1:标题方案生成失败, taskId=...
java.lang.NullPointerException: Cannot invoke
"...DashScopeChatModel.call(...)" because "this.chatModel" is null
    at ArticleAgentService.callLlm(ArticleAgentService.java:437)
    at ArticleAgentService.agent1GenerateTitleOptions(ArticleAgentService.java:193)
    at ArticleAgentService.executePhase1_GenerateTitles(ArticleAgentService.java:100)
    ...
    at ArticleAgentService$$SpringCGLIB$$0.executePhase1_GenerateTitles(<generated>)
    at ArticleAsyncService.executePhase1(ArticleAsyncService.java:106)

关键信息有两条:

  1. chatModel(通过 @Resource 注入的 DashScopeChatModel)是 null
  2. 调用栈里出现了 ArticleAgentService$$SpringCGLIB$$0 ------ 说明这个 Bean 被 Spring AOP 代理了。

第一反应通常是:DashScopeChatModel 是不是没注入成功?API Key 配错?Bean 没注册?

排查了一轮:application.ymlspring.ai.dashscope.api-key 配了、pom 依赖在、项目里也只有一个 ArticleAgentService 的定义...... 都不像是配置问题。最后把注意力放回「代理」这个异常特征上,才挖出真正的原因。

问题诊断摘要

  • Spring AOP 的切面(注解)只在方法调用穿过代理对象时触发
  • 同一个 Bean 内部用 this.method() 直接调用,穿不过代理 → 切面不生效,这是经典的「自调用陷阱」;
  • 解法之一是用 AopContext.currentProxy() 拿到代理再调用(即下文 getProxy()),前提是启动类开启 @EnableAspectJAutoProxy(exposeProxy = true)
  • 但是!被注解的方法如果是 private,代理拦不住 ------private 方法无法被 CGLIB 子类覆盖,调用会直接在代理对象自身上执行;
  • 而 CGLIB 代理对象只是 Spring 额外 new 出来的一个"壳",字段全是 null (依赖注入只发生在真实 Bean 上)→ 于是在代理对象上跑业务方法,读到 chatModel == null → NPE;
  • 解法:把被 AOP 注解的方法改成 public

二、前置知识:Spring AOP 是基于代理的

Spring AOP 和 AspectJ 的编译期织入 不同,它是运行时基于代理的:当某个 Bean 上有切面匹配(比如方法上加了自定义注解)时,Spring 不会把你写的那个对象直接放进容器,而是:

java 复制代码
// 真实业务对象(字段会被 Spring 注入)
ArticleAgentService target = new ArticleAgentService();
target.chatModel = ...;          // 注入发生在 target 上

// 再包一层 CGLIB 代理(继承 target 的类,动态生成子类)
ArticleAgentService proxy = enhancer.create();
// proxy 里的字段...... 全是 null!

最终容器里存的是 proxy,外部拿到的也是 proxy。外部调用 proxy.executePhase1_GenerateTitles() 时,会先进入代理的拦截器链 (方法拦截器),切面逻辑在这里执行,然后再转发给真实的 target

text 复制代码
外部 Bean ──► proxy.method()  ──► 拦截器链(切面) ──► target.method() 真实执行
              (容器里的代理)                                    (字段已注入)

关键点 1 :CGLIB 代理是 target 的一个子类实例 ,和 target 是两个不同的对象。Spring 只往 target 里注入依赖,代理对象上的字段全是 null/默认值

关键点 2:切面只在方法调用"落在代理上"时才生效。谁发起调用、是不是外部 Bean,Spring AOP 根本不关心。

三、经典的「自调用」陷阱

既然外部调用会穿代理、切面能生效,那同一个类内部互相调用呢?

java 复制代码
public void executePhase1_GenerateTitles(ArticleState state) {
    // 直接调用,等价于 this.agent1GenerateTitleOptions(state)
    agent1GenerateTitleOptions(state);   // ❌ 切面不生效
}

这里的 this真实 target ,不是代理。方法调用根本没有经过代理对象,Spring AOP 连"发生了调用"都不知道,自定义注解切面自然不会触发

这就是人尽皆知的 self-invocation(自调用)导致 Spring AOP 失效的问题。

打个比方:把代理想成一道安检门 。方法调用要从正门 过一遍才被安检(记录日志)。

真实 Bean 内部 this.method() 相当于从门内侧 直接走,没出过门,安检形同虚设;

而通过代理调用,等于先把自己带出门外、再从正门进来,安检才会生效。


四、为什么要加 getProxy()

因为要给每个 Agent 调用自动记录执行日志,我们加了一个切面:

java 复制代码
@Around("@annotation(agentExecution)")
public Object aroundAgentExecution(ProceedingJoinPoint pjp, AgentExecution agentExecution) {
    // ... 记录开始、成功、失败、耗时 ...
}

并给 Agent 方法打上注解:

java 复制代码
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
public void agent1GenerateTitleOptions(ArticleState state) { ... }

可这些 Agent 方法是被同一个类executePhase1_GenerateTitles 内部调用的 ------ 直接调就是自调用,切面永远不会触发,日志等于白写。

于是改成"先拿代理、再通过代理调用",让内部调用重新穿过代理:

java 复制代码
private ArticleAgentService getProxy() {
    return (ArticleAgentService) AopContext.currentProxy();  // 取出当前代理
}

public void executePhase1_GenerateTitles(ArticleState state) {
    getProxy().agent1GenerateTitleOptions(state);   // ✅ 穿过代理 → 切面触发
}

AopContext.currentProxy() 能取出"当前正在被代理执行的方法所对应的代理对象",前提是启动类开启:

java 复制代码
@EnableAspectJAutoProxy(exposeProxy = true)   // 关键!把当前代理放进 AopContext

这样内部调用就从:

text 复制代码
target.executePhase1_GenerateTitles()
   └── this.agent1()        // 穿不过代理,切面不触发

变成了:

text 复制代码
target.executePhase1_GenerateTitles()          // 外层方法本身已穿过代理,AopContext 有值
   └── proxy.agent1()  ──► 拦截器链(切面) ──►  target.agent1() 真实执行

从切面的视角看,这和方法"被外部 Bean 调用"完全一致 ------ AOP 不看调用者是谁,只看调用有没有落在代理上

五、真正的坑:private 方法 + CGLIB 代理 = 在"空壳"上执行

按上面的改法,似乎已经解决了。但运行时还是 NPE:chatModel is null。为什么?

因为被 @AgentExecution 注解的那些方法,当初声明的是 private

java 复制代码
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
private void agent1GenerateTitleOptions(ArticleState state) {   // private!
    String content = callLlm(prompt);   // 内部再调一个 private callLlm
    ...
}

private String callLlm(String prompt) {
    ChatResponse response = chatModel.call(...);   // this.chatModel → NPE
    ...
}

问题出在 CGLIB 代理的一个硬限制上:

  1. private 方法无法被 CGLIB 子类覆盖 。代理 proxyArticleAgentService 的子类,但子类不可能 override 父类的 private 方法------private 方法对子类都不可见。
  2. 当你在 ArticleAgentService 内部写 proxy.agent1GenerateTitleOptions(...) 时,编译器生成的是 invokespecial (直接调用,不走虚分派),意思是"我就是在自己类内部调这个 private 方法",JVM 会直接把它当作在当前对象上执行
  3. 于是这段代码在 proxy 这个对象上执行了。而前面说过,proxy 是一个字段全为 null 的"空壳" ------ chatModelimageServiceStrategycosService 全都没注入。
  4. callLlm 里访问 this.chatModelthisproxy → 字段是 null → NPE

一句话概括这次 Bug:

想让 private 方法穿过代理触发切面,结果 private 方法穿不过代理,反而直接在字段全空的代理对象上执行了,于是所有 @Resource 字段都是 null。


六、修复:把注解方法改成 public

把 6 个被 @AgentExecution 注解的 private 方法改成 public

java 复制代码
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
public void agent1GenerateTitleOptions(ArticleState state) {   // public ✅
    ...
}

原理:

  • public 方法可以被 CGLIB 子类覆盖,调用会走虚分派进入代理拦截器 → 切面触发;
  • 拦截器 proceed() 后,由 Spring 反射到真实的 target 上执行 → 字段完整 → 不再 NPE。

顺带,日志切面也终于真正开始工作了(改之前因为 private 无法被切,日志从没写过一条)。


七、横向对比:三种做法的取舍

方案 切面是否触发 是否 NPE 说明
this.method() 直接自调用 简单,但 AOP 日志形同虚设
getProxy().method(),方法 private private 拦不住,且在代理空壳上执行
getProxy().method(),方法 public 本次修复方案,内部调用也能记日志

八、复盘清单

  1. 切面不生效 → 先看是不是同类自调用(this.method())。是的话,要么拆独立 Bean,要么 getProxy()
  2. getProxy() 拿不到代理 → 检查启动类是否开了 @EnableAspectJAutoProxy(exposeProxy = true)
  3. 被代理后字段全 null / NPE → 检查被 @Around 注解匹配的方法是不是 private。Spring AOP 只能代理 public(及 CGLIB 下的 protected)方法
  4. 怀疑注入失败前 ,先看调用栈里有没有 $$SpringCGLIB$$ / $Proxy ------ 有代理参与时,"字段为 null"的第一嫌疑是代码跑在了代理空壳上,而不是 Bean 没注入。
相关推荐
码艺-Alimjan1 小时前
维吾尔语数字转文本
java·javascript·python·算法·go
事圆则缓1 小时前
Java 常见数据结构与 Android 使用场景
android·java·数据结构
IT_陈寒1 小时前
JavaScript的隐式转换太坑了,我的==比较怎么就炸了?
前端·人工智能·后端
xieliyu.1 小时前
计算机网络:Fiddler 抓包工具使用教程 + HTTP 协议报文格式详解
java·笔记·计算机网络·测试工具·http·java-ee·fiddler
步行cgn1 小时前
@Value 详解:Spring 属性注入的核心注解
java·python·spring
2401_850481171 小时前
UVA815
java
霸道流氓气质1 小时前
Spring AI 多租户隔离方案
java·人工智能·spring
不会就选b1 小时前
Linux之socket编程(七)----序列化与反序列化(1)
java·linux·服务器
秋名RG2 小时前
Java 工程师学习路线
java·学习方法