AOP 这个词,Spring 开发者几乎天天见,但真到了面试或者排查线上问题时,很多人的理解还停留在“AOP 就是拦截方法打个日志”这个层面。我见过不少简历上写着熟悉 Spring AOP,结果一追问 JDK 动态代理和 CGLIB 有什么区别、静态代理和动态代理到底差在哪,回答就变得支支吾吾。这篇文章不打算讲那种学院派的大道理,就从实际项目里的问题出发,把静态代理、JDK 动态代理、CGLIB、AOP 核心概念、应用场景这整套链路掰开揉碎讲清楚,顺便把我在真实项目里踩过的坑也一并分享出来。
先给这篇文章定个位:如果你刚接触 Spring,想搞明白 AOP 到底在解决什么问题,可以看;如果你有几年经验,但是对代理选择和自调用失效这些细节一直没吃透,这篇文章同样适合你。我会把代码、原理、排查经验混在一起讲,尽量让每种机制都落到真实场景里,看完能直接用得上。
1. 为什么需要 AOP:解决哪些真实的麻烦
1.1 横切关注点是什么
在很多业务系统里,有大量的方法都在做同一类事情:记录日志、校验权限、统计耗时、处理事务。你去翻一套老代码,经常能看到这种模式:
public void createOrder(OrderDTO dto) { log.info("createOrder start, dto = {}", dto); long start = System.currentTimeMillis(); try { // 业务逻辑 orderService.save(dto); } catch (Exception e) { log.error("createOrder error", e); throw e; } finally { log.info("createOrder end, cost = {} ms", System.currentTimeMillis() - start); } }一个两个方法这么写还能忍,当你有几十个上百个接口都重复这段模板代码时,问题就出来了:代码极度冗余,维护成本高,而且很容易写漏。今天你想统一在方法入口加一个操作人信息,就得把所有方法都改一遍,稍不注意就漏掉某个异步线程池里的方法。
这类逻辑有一个共同特点:它们不属于核心业务,却横跨了几乎所有业务方法。日志、安全、事务这些逻辑在垂直方向贴在了业务方法上,所以叫横切关注点。AOP 要解决的核心问题,就是把这类横切关注点从业务代码里剥离出来,用一种统一的方式去处理,让业务方法只关心自己的业务逻辑。
1.2 面向对象解决不了什么
很多人一开始的想法是,那我抽一个工具类,把日志逻辑封装起来,然后每个方法里调用一下不就行了?这确实能减少一部分重复代码,但本质问题还在——你仍然需要在每个业务方法里主动调用这个工具类,调用关系是显式写在业务代码里的。这属于侵入式的方案,业务代码会被非业务逻辑污染。
面向对象的核心封装是纵向的,也就是我们通常说的自上而下的功能分解。但横切关注点是横向贴在很多类和方法上的,OOP 很难优雅地表达“一堆方法在开始之前做 X、在结束之后做 Y”这种抽象。代理模式之所以成为解决方案的起点,是因为它允许我们在不修改目标类源码的情况下,通过一个代理对象把额外的逻辑插入到方法调用链路里。
这也是为什么很多讲解 AOP 的文章都会先从代理模式讲起。因为 AOP 的实现底层,本质上就是在代理对象上做文章,Spring AOP 更是直接建立在动态代理之上的。
2. 静态代理:最笨但最扎实的代理方案
2.1 静态代理的落地方式
静态代理是最容易理解的代理方式:我们手动创建代理类,让代理类和目标类实现同一个接口,代理类持有目标类的引用,然后在代理方法里织入额外逻辑。用一个实际例子来说,假设有一个用户查询接口:
public interface UserService { User getUserById(Long id); } public class UserServiceImpl implements UserService { @Override public User getUserById(Long id) { // 模拟数据库查询 return new User(id, "张三"); } } public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target = target; } @Override public User getUserById(Long id) { long start = System.currentTimeMillis(); try { System.out.println("before: 查询用户,id = " + id); return target.getUserById(id); } finally { System.out.println("after: 耗时 = " + (System.currentTimeMillis() - start) + "ms"); } } }使用的时候,客户端不再直接持有 UserServiceImpl,而是持有 UserServiceProxy。整个过程的精髓在于:调用方感知的是 UserService 接口,目标类的业务逻辑完全不用改,代理类在方法前后插入逻辑。
2.2 静态代理的致命伤
这个方案的问题是显而易见的。第一,每个需要代理的业务类,都要手工创建一个对应的代理类,代码量甚至超过业务类本身。第二,一旦接口新增方法,代理类和目标类都得同步改。第三,如果代理逻辑本身也要扩展,比如今天日志格式变了,明天多了一个权限校验,代理类就会越来越臃肿。
静态代理最大的价值不是让你在生产环境用它,而是帮你建立理解动态代理的必要认知:代理的本质是间接访问,是在调用链路上插入额外处理。没有这个认知,后面看动态代理和 AOP 的时候,容易一头雾水。实际上,Spring AOP 做的事情和静态代理是类似的,只是它把创建代理类的过程自动化了,并且把插入逻辑的部分做成了可配置的切面。
3. JDK 动态代理与 CGLIB:动态代理的两条路线
3.1 JDK 动态代理:基于接口的代理
静态代理要手动写代理类,动态代理则可以运行时生成代理类。JDK 动态代理是 Java 自带的能力,核心是java.lang.reflect.Proxy和InvocationHandler两个角色。它要求目标对象必须实现至少一个接口,代理对象会在运行时动态生成,并且同样实现目标对象的接口。
public class UserServiceInvocationHandler implements InvocationHandler { private final Object target; public UserServiceInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); try { System.out.println("before: " + method.getName()); return method.invoke(target, args); } finally { System.out.println("after: " + method.getName() + " 耗时 = " + (System.currentTimeMillis() - start) + "ms"); } } } UserService proxy = (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new UserServiceInvocationHandler(new UserServiceImpl()) );从代码里可以看到,我们不再需要为每个类手工创建代理类,只需要写一个通用的 InvocationHandler,拦截所有方法调用,再在 invoke 方法里统一加逻辑即可。这就是动态代理比静态代理先进的核心:代理逻辑和业务类解耦,业务类增加方法时不需要同步修改代理类。
3.2 CGLIB:基于继承的代理
JDK 动态代理好用,但限制也很明显:目标类必须实现接口。很多项目里确实存在只有实现类、没有接口的情况,或者你引入了一个第三方 jar 包,里面的类压根没接口。这时候就要上 CGLIB 了。
CGLIB 的原理不是基于接口,而是基于继承。它通过生成目标类的子类来创建代理对象,并且在子类中重写父类的方法,在重写逻辑里加入拦截处理。用代码来表示,大致是这样的思路:
Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { long start = System.currentTimeMillis(); try { System.out.println("before: " + method.getName()); return proxy.invokeSuper(obj, args); } finally { System.out.println("after: " + method.getName() + " 耗时 = " + (System.currentTimeMillis() - start) + "ms"); } }); UserServiceImpl proxy = (UserServiceImpl) enhancer.create();因为 CGLIB 是生成目标类的子类,所以目标类不能是 final 的,被代理的方法也不能是 final 或 private 的,否则无法重写。这是理解 Spring AOP 各种诡异问题的关键前提。
3.3 Spring 如何选择代理方式
Spring AOP 在创建代理时有一套自己的判断逻辑。在 Spring Boot 2.x 及之前,默认策略是:如果目标对象实现了接口,优先使用 JDK 动态代理;如果没有实现接口,才使用 CGLIB。从 Spring Boot 2.x 开始,官方把默认策略改成了强制使用 CGLIB,也就是spring.aop.proxy-target-class=true成为默认。
这里有一个很容易被忽略的知识点:Spring Boot 2.x 之后,即使你的类实现了接口,默认也不再走 JDK 动态代理,而是直接用 CGLIB 生成子类代理。很多人拿老教程里的说法“Spring 默认接口用 JDK 代理”去套 Spring Boot 项目,结果发现调试时代理对象的类型是xxx$$EnhancerBySpringCGLIB,就觉得很奇怪,其实就是默认策略变了。
代理方式的选择还会影响注入方式。如果使用 JDK 动态代理,代理对象是接口类型,那么注入的时候就必须按接口类型注入;如果强行按实现类类型注入,启动时就会报类型不匹配。改用 CGLIB 之后,因为代理对象是目标类的子类,按实现类类型注入倒也能工作。这也是很多项目在迁移 Spring Boot 2.x 时踩到的坑。
4. AOP 核心概念拆解
4.1 连接点、切点、通知、切面
在真正使用 Spring AOP 注解之前,得先把这几个名词搞清楚。这些概念不是拿来背的,它们是理解切面如何生效的地图。
连接点(Join Point)是一个可以被拦截的候选位置。在 Spring AOP 里,连接点通常就是方法调用,比如userService.getUserById(id)这个方法调用。切点(Pointcut)则是一组连接点的集合条件,它告诉 Spring“哪些方法需要被拦截”。通知(Advice)是切点匹配成功后要执行的逻辑,比如打印日志、记录耗时。切面(Aspect)则是切点和通知的组合体,通常一个切面类里定义了多个关注点。
注意:AOP 中的“切点”和“连接点”这个关系,你可以类比成 SQL 里的 WHERE 条件与表记录。连接点是表里的数据,切点是筛选条件,通知是命中后执行的动作。
Spring AOP 默认只能拦截 Spring 容器管理的 bean 的 public 方法。如果你试图对一个 private 方法做切点匹配,或者对 Spring 管理的 bean 内部调用做拦截,大概率会失败。这个限制的根源就在代理机制上:代理只能代理可继承、可重写的方法。
4.2 通知类型和常用注解
Spring AOP 提供了五种通知类型,分别对应方法执行链路的不同位置。实际开发中用的最多的是@Before、@AfterReturning、@AfterThrowing、@Around,@After虽然也能用,但通常能被@Around更精细地替代。
直接写一个完整切面类来展示:
@Aspect @Component public class LogAspect { @Pointcut("execution(* com.example.service..*(..))") public void servicePointcut() {} @Before("servicePointcut()") public void beforeLog(JoinPoint joinPoint) { System.out.println("before: " + joinPoint.getSignature().getName()); } @AfterReturning(value = "servicePointcut()", returning = "result") public void afterReturningLog(Object result) { System.out.println("afterReturning: " + result); } @AfterThrowing(value = "servicePointcut()", throwing = "ex") public void afterThrowingLog(Exception ex) { System.out.println("afterThrowing: " + ex.getMessage()); } @Around("servicePointcut()") public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { System.out.println("cost = " + (System.currentTimeMillis() - start) + "ms"); } } }需要注意,@Around里一定要调用joinPoint.proceed(),否则目标方法根本不会执行,业务会直接短路。我在代码评审里见过有人把 proceed 写在 if 分支里,导致特定条件下方法静默不执行,线上排查了很久才发现是切面逻辑的问题。
4.3 切面执行顺序
当一个方法匹配多个切面时,切面的执行顺序需要明确。Spring 支持通过@Order注解控制顺序,数值越小优先级越高。比如事务切面的 order 通常设置为最高优先级,这样它最先开启事务,也最后提交事务,保证业务切面执行在事务上下文里。
默认情况下,如果多个切面没有指定顺序,Spring 的排序规则是先按 @Order 注解的值排序,如果没有注解则按切面类的名字或 bean 加载顺序来,这个顺序是不稳定的,别依赖它。我的经验是只要涉及事务、锁、多数据源切换这类操作,就显式给切面设置 order,不然不同版本升级后顺序可能悄无声息地变化,产生非常隐蔽的 bug。
5. 常见应用场景和实操案例
5.1 日志记录
日志记录是 AOP 最常见的入门场景。不过生产级日志切面没有上面那个例子那么简单,它需要处理好几个细节:参数序列化、敏感字段脱敏、异常堆栈保留、异步输出。
举个例子,一个健壮的日志切面对返回值和参数要做长度截断,否则一个超大对象被打印出来,日志系统直接被打爆。同时对于密码、手机号等敏感字段,序列化前要做脱敏处理。我一般建议日志切面里不要直接打印整个 request 对象,而是只打印方法名、入参的 hash 或摘要、执行耗时、异常类型这些关键信息。
日志切面还有一个容易忽略的问题:它本身会增加方法调用链的开销。如果你在流量很大的热点方法上也打全量日志,性能损耗会很明显。实际项目中,我会设计一个开关,通过配置中心动态控制某些切点的日志是否输出,而不是一刀切全打或者全不打。
5.2 权限校验
权限校验是 AOP 的另一个经典应用。把权限判断放在切面里,可以避免在业务方法里散落一堆权限判断代码,也能统一处理鉴权失败时的响应。
常见做法是自定义一个注解,比如@RequirePermission("order:create"),然后写一个切面拦截所有标注了该注解的方法。切面里获取当前登录用户信息,检查用户权限集合里是否有对应权限,如果没有就抛出业务异常。这样做的好处是权限规则和业务代码分离,以后权限模型变更,只需要修改切面逻辑,不需要动业务方法。
不过要小心一个问题:切面里获取用户信息通常依赖上下文,比如ThreadLocal。而真正生产环境里会有异步线程池,或者内部调用其他服务,ThreadLocal 里的用户信息可能传递不到子线程。我在设计权限切面的时候,会先确认这些方法是否可能被异步调用,如果会,就需要做上下文传递或者修改检查方式。
5.3 事务管理
Spring 的声明式事务管理是很多开发者最容易忽视的 AOP 应用,其实你天天在用的@Transactional底层就是一个事务切面。你只需要在方法上打一个注解,Spring 就会在方法执行前开启事务,执行成功后提交,出现异常时回滚。
理解这一点对排查问题非常重要。比如事务失效的一个高频原因就是同类内部方法调用,this.save()这种方式不会走代理对象,所以事务切面拦截不到。解决办法通常是注入代理对象,或者拆到另一个 bean 里调用,又或者用AopContext.currentProxy()获取当前代理对象后再调用。
还有一个细节是事务默认只回滚RuntimeException和Error。如果你抛了一个受检异常,比如IOException,事务是不会回滚的,除非在@Transactional里显式指定rollbackFor = Exception.class。这个坑几乎每个人都会踩一次,我建议团队里把rollbackFor = Throwable.class作为默认约定。
5.4 性能监控
性能监控是 AOP 在运维层面的重要用途。利用@Around通知,可以在方法执行前后计算出耗时,同时记录调用次数、异常率,甚至可以配合 Micrometer 把这些指标直接暴露给 Prometheus 等监控系统。
通常业务系统的性能监控分两个层级:接口层监控和关键方法监控。接口层监控可以用拦截器或过滤器实现,而方法级监控就需要 AOP 了。比如某个核心服务的某个方法经常出现慢调用,你就给这个方法定义一个细粒度的切点,单独采集耗时分布。采集到的数据可以打到日志里,也可以直接写入时序数据库。
这里有个实操心得:方法耗时采集本身不能影响业务性能,所以切面里的统计逻辑要尽量轻量。不要在切面里做复杂计算、不要同步发送网络请求、不要在热点切面里频繁创建对象,否则监控逻辑会拖慢业务方法,最终监控到的数据反而是失真的。
6. 常见问题与排查技巧实录
6.1 自调用失效
自调用失效是指在一个类的内部,一个方法调用另一个带事务或带切面的方法时,切面不生效。比如:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { // ... sendMessage(); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendMessage() { // ... } }这里createOrder内部直接调用this.sendMessage(),这实际上调用的是目标对象的原始方法,而不是 Spring 生成的代理对象方法,所以REQUIRES_NEW不会起作用。解决方式有三种:把sendMessage拆到另一个@Service类里;注入ApplicationContext,通过applicationContext.getBean(OrderService.class)获取代理对象再调用;或者配置暴露代理对象,用((OrderService) AopContext.currentProxy()).sendMessage()。我比较推荐拆类,因为它简单清晰,也最容易让其他同事理解。
6.2 代理类型导致的问题
Spring Boot 2.x 默认使用 CGLIB,这本身不是问题,但会带来一些连带影响。比如目标类是 final 类,或者目标方法被 final 修饰,这时代理对象生成会直接报错。另外,如果你在代码里用instanceof判断某个 bean 是否是某个接口的实现,要注意 Spring 容器里的 bean 可能是一个 CGLIB 子类对象,类型判断可能会出现意料之外的结果。
还有一种情况是@Autowired注入接口类型时,如果这个接口有多个实现类,Spring 会因为无法确定具体注入哪个实现类而报错。这个锅不完全是 AOP 的,但是加了代理之后,错误信息会变得更难分析。遇到这类问题,建议先把切面排除掉,看看原始 bean 能否正常注入,再逐步加回切面排查。
6.3 Spring Boot 默认使用 CGLIB 的坑与应对
刚说到 Spring Boot 2.x 默认使用 CGLIB,如果你是老项目升级,以前代码里大量@Autowired注入实现类类型的写法可能在旧版本没暴露问题,升级后突然启动报错。原因是旧版本接口类型用的 JDK 动态代理,代理对象不是实现类的子类,无法按实现类类型注入。
应对方法是两个方向:一是在配置文件里设置spring.aop.proxy-target-class=false,回到 JDK 动态代理模式,但这不是长久之计;二是规范注入类型,尽量都按接口注入。从长远看,按接口编程本来就是更稳的做法,代理模式只是在背后放大了这个规范的价值。
6.4 切点表达式常见错误
切点表达式写错是 AOP 不生效的最常见原因之一。常见错误包括:包路径写错、..用错位置、返回值类型写错等。举个例子,execution(* com.example.service..*(..))表示com.example.service包及其子包下的所有方法,注意是service..*,不是service.*。如果是service.*,就只会匹配该包下的直接类,不会匹配子包。
另一个容易踩的点是:Spring AOP 的切点匹配是基于代理方法的,因此接口上定义的 default 方法、静态方法、构造方法都无法被拦截。如果你确认切面没生效,第一步先确认目标方法是不是 public、目标 bean 是不是 Spring 管理的,第二步再检查切点表达式。如果还不行,就打开 Spring 的 debug 日志,观察切面匹配情况,一般能快速定位。
排查这些问题的时候建议写一个临时测试方法,故意在方法里抛异常,看切面有没有被触发,这样比肉眼盯表达式高效得多。
最后分享一个我在实际项目里的体会:学习 AOP 最忌讳死记硬背概念,最好的方式是亲手在公司代码或者开源项目里找一个现成切面,把@Around里的逻辑一行行拆开来读,弄清楚切点匹配到了哪里、通知什么时候执行、代理对象是怎么生成的。你把它当成一个黑盒子去用,和把它拆开看一遍再装回去,效果完全不一样。遇到@Transactional失效、权限切面不触发这些怪问题时,先冷静判断:现在你这个位置拿到的到底是不是代理对象,这个是所有排查工作的起点。