当前位置: 首页 > news >正文

Spring开发系列教程(12)——AOP避坑指南

无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。

因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。

我们来看一个实际的例子。

假设我们定义了一个UserService的Bean:

@Component public class UserService { // 成员变量: public final ZoneId zoneId = ZoneId.systemDefault(); // 构造方法: public UserService() { System.out.println("UserService(): init..."); System.out.println("UserService(): zoneId = " + this.zoneId); } // public方法: public ZoneId getZoneId() { return zoneId; } // public final方法: public final ZoneId getFinalZoneId() { return zoneId; } }

再写个MailService,并注入UserService

@Component public class MailService { @Autowired UserService userService; public String sendMail() { ZoneId zoneId = userService.zoneId; String dt = ZonedDateTime.now(zoneId).toString(); return "Hello, it is " + dt; } }

最后用main()方法测试一下:

@Configuration @ComponentScan public class AppConfig { public static void main(String[] args) { ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); MailService mailService = context.getBean(MailService.class); System.out.println(mailService.sendMail()); } }

查看输出,一切正常:

UserService(): init... UserService(): zoneId = Asia/Shanghai Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下一步,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect

@Aspect @Component public class LoggingAspect { @Before("execution(public * com..*.UserService.*(..))") public void doAccessCheck() { System.err.println("[Before] do access check..."); } }

别忘了在AppConfig上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个NullPointerException

Exception in thread "main" java.lang.NullPointerException: zone at java.base/java.util.Objects.requireNonNull(Objects.java:246) at java.base/java.time.Clock.system(Clock.java:203) at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216) at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19) at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:

@Component public class MailService { @Autowired UserService userService; public String sendMail() { ZoneId zoneId = userService.zoneId; System.out.println(zoneId); // null ... } }

我们还故意在UserService中特意用final修饰了一下成员变量:

@Component public class UserService { public final ZoneId zoneId = ZoneId.systemDefault(); ... }

final标注的成员变量为null?逗我呢?

怎么肥四?

为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:

第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;

第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect

public UserService$$EnhancerBySpringCGLIB extends UserService { UserService target; LoggingAspect aspect; public UserService$$EnhancerBySpringCGLIB() { } public ZoneId getZoneId() { aspect.doAccessCheck(); return target.getZoneId(); } }

如果我们观察Spring创建的AOP代理,它的类名总是类似UserService$$EnhancerBySpringCGLIB$$1c76af9d(你没看错,Java的类名实际上允许$字符)。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有publicprotected方法,并在内部将调用委托给原始的UserService实例。

这里出现了两个UserService实例:

一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:

UserService original = new UserService();

第二个UserService实例实际上类型是UserService$$EnhancerBySpringCGLIB,它引用了原始的UserService实例:

UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB(); proxy.target = original; proxy.aspect = ...

注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。

那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null

原因在于,UserService成员变量的初始化:

public class UserService { public final ZoneId zoneId = ZoneId.systemDefault(); ... }

UserService$$EnhancerBySpringCGLIB中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。

实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:

public class UserService { public final ZoneId zoneId = ZoneId.systemDefault(); public UserService() { } }

这是编译器实际编译的代码:

public class UserService { public final ZoneId zoneId; public UserService() { super(); // 构造方法的第一行代码总是调用super() zoneId = ZoneId.systemDefault(); // 继续初始化成员变量 } }

然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。

有的童鞋会问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?

这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:

注意

Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!

再考察MailService的代码:

@Component public class MailService { @Autowired UserService userService; public String sendMail() { ZoneId zoneId = userService.zoneId; System.out.println(zoneId); // null ... } }

如果没有启用AOP,注入的是原始的UserService实例,那么一切正常,因为UserService实例的zoneId字段已经被正确初始化了。

如果启动了AOP,注入的是代理后的UserService$$EnhancerBySpringCGLIB实例,那么问题大了:获取的UserService$$EnhancerBySpringCGLIB实例的zoneId字段,永远为null

那么问题来了:启用了AOP,如何修复?

修复很简单,只需要把直接访问字段的代码,改为通过方法访问:

@Component public class MailService { @Autowired UserService userService; public String sendMail() { // 不要直接访问UserService的字段: ZoneId zoneId = userService.getZoneId(); ... } }

无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:

public UserService$$EnhancerBySpringCGLIB extends UserService { UserService target = ... ... public ZoneId getZoneId() { return target.getZoneId(); } }

注意到我们还给UserService添加了一个public+final的方法:

@Component public class UserService { ... public final ZoneId getFinalZoneId() { return zoneId; } }

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null

实际上,如果我们加上日志,Spring在启动时会打印一个警告:

10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。

因此,正确使用AOP,我们需要一个避坑指南:

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

这样才能保证有没有AOP,代码都能正常工作。

思考

为什么Spring刻意不初始化Proxy继承的字段?

如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?

小结

由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。

遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。

本文转载自廖雪峰老师的官方网站 liaoxuefeng.com 。

http://www.jsqmd.com/news/531791/

相关文章:

  • Qwen3-Reranker-0.6B在VSCode中的开发与调试
  • 打破设备壁垒:Win/Mac/Linux三端虚拟局域网组网全攻略,解锁跨设备协作新范式
  • 2026年高新技术企业认定公司推荐:初创公司资质提升口碑机构及用户真实反馈 - 品牌推荐
  • 如何判断Tclsh是32位还是64位的
  • jsDelivr数据库性能优化终极指南:10个提升CDN查询速度的技巧
  • granite-4.0-h-350m中文微调指南:Ollama本地大模型定制化训练教程
  • Vibe Coding,是怎么「玩废」程序员的?
  • OpenClaw配置优化:百川2-13B量化模型推理速度提升30%技巧
  • 从零到一:在Cursor与VSCode中深度集成DeepSeek的实战手册
  • 2026年AI营销智能体公司推荐:企业品牌增长困境下高价值智能决策与内容生成服务商 - 品牌推荐
  • 智能投资决策系统:从市场混沌到决策清晰的技术革命
  • springboot+vue项目如何集成onlyoffice开源文档组件
  • Easy SMS消息模板系统详解:灵活应对不同业务场景
  • 2026年AI营销智能体公司推荐:营销全流程自动化热门服务商与真实反馈对比 - 品牌推荐
  • 前端大文件分线程上传
  • Spring Boot整合指南:用Microsoft Graph实现Outlook邮件自动化处理(含附件下载)
  • Clink Lua API完全指南:打造个性化的命令行环境
  • 终极jsDelivr容器镜像优化指南:减小镜像体积的10个实用技巧
  • 如何快速创建企业级C/C++项目:learning-cmake项目模板完整指南 [特殊字符]
  • 2026年齿轮加工优质厂家推荐指南适配机电装备升级:齿轮加工多少钱、齿轮加工工艺、齿轮加工推荐、齿轮厂家品牌推荐选择指南 - 优质品牌商家
  • 终极用户体验优化指南:如何在Developer Handbook中掌握动画与交互设计
  • Laravel模块管理终极指南:从零掌握laravel-modules文件结构与实战技巧
  • 3大核心算法解密:如何用极简代码打造2048游戏AI
  • Design-Patterns-In-Kotlin终极路线图:23种设计模式的未来发展与社区愿景
  • 基于深度学习yolov11的手语识别 手势识别数据集 手势检测 手语目标检测 手语翻译 yolo26数据集第10605期
  • 深度解读《Mandiant M-Trends 2026》:不再局限于“威胁描述”,而是聚焦“攻防失衡的核心症结”与“可落地的韧性构建路径”
  • 2026年AI营销智能体公司推荐:企业品牌增长困境下高价值智能决策伙伴深度解析 - 品牌推荐
  • Ubuntu18环境下高性能主机运行Lidar SLAM算法时RViz卡顿的优化策略
  • 浏览器中运行AI Agent的完整指南:从安装到高效应用
  • React Native Testing Library Jest 匹配器完整清单:20个实用断言方法