做Java开发这些年,AOP是我在Spring里用得最多、也是面试时最常被问到的核心机制之一。很多人觉得AOP就是加一个@Aspect注解、写几个@Around,方法调用时日志就出来了,事务就生效了,限流就拦上了。但真要问一句"AOP的原理是什么?"或者"为什么同一个类里两个方法互相调用时AOP不生效?",不少人就卡住了。这篇文章我把AOP从概念到源码、从入门实战到面试高频坑,一条线讲透,读完你不仅能熟练使用,还能在面试时把底层逻辑说清楚。
1. 先搞清楚AOP到底在解决什么问题
1.1 一段重复代码引发的思考
我先抛一个特别常见的场景。假设你要给一批业务方法加操作日志,第一版代码可能是这样的:
public Order createOrder(OrderDTO dto) { log.info("创建订单开始, 参数:{}", dto); long start = System.currentTimeMillis(); try { // 核心业务逻辑 Order order = orderService.create(dto); log.info("创建订单成功, 订单号:{}", order.getOrderNo()); return order; } catch (Exception e) { log.error("创建订单失败, 参数:{}", dto, e); throw e; } finally { log.info("创建订单耗时:{}ms", System.currentTimeMillis() - start); } }再加一个取消订单方法,同样的日志逻辑再复制一遍。然后还有改订单、查询订单、批量关闭订单……十几个方法下来,日志代码量已经超过业务代码了。更麻烦的是,如果产品经理提了个需求:"所有接口的日志里都要加一个traceId,方便排查问题",你得把所有方法全部改一遍。
这种存在于多个业务方法中、和核心业务逻辑无关、但又必须执行的逻辑,在AOP里被称为横切关注点。日志、事务、鉴权、限流、异常处理,全是典型的横切关注点。而AOP(Aspect Oriented Programming,面向切面编程)就是专门把这类逻辑集中管理、统一织入的编程范式。
1.2 AOP核心术语:切面、切点、通知、连接点
AOP里有几个概念,我建议你用"安检通道"来做类比,会好理解很多。
- 连接点(JoinPoint):程序执行过程中的某个位置,比如方法调用前、调用后、抛出异常时。类比就是每一个进站的旅客,理论上每个旅客(每个方法)都可以被检查。
- 切点(Pointcut):真正要执行增强逻辑的连接点集合,通常用表达式来匹配,比如"所有带有@RateLimit注解的方法"或者"com.example.service包下的所有public方法"。类比就是安检人员决定"穿深色衣服的旅客要抽查",这就是筛选条件。
- 通知(Advice):要织入的具体逻辑,比如"打印日志"这个动作本身,分为@Before(前置通知)、@AfterReturning(返回通知)、@AfterThrowing(异常通知)、@After(最终通知)、@Around(环绕通知)。
- 切面(Aspect):切点+通知的组合体,一个@Aspect类就是一个切面模块。类比就是完整的安检流程。
- 织入(Weaving):把增强逻辑应用到目标对象的连接点上的过程。Spring AOP的织入发生在运行时,通过动态代理实现。
用一个最简单的代码示例来对照:
@Aspect @Component public class LogAspect { // 切点:匹配controller包下所有类的所有方法 @Pointcut("execution(* com.example.controller..*.*(..))") public void controllerPointcut() {} // 通知:环绕通知,打印接口耗时 @Around("controllerPointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; System.out.println("方法 " + joinPoint.getSignature() + " 耗时 " + cost + "ms"); return result; } }1.3 为什么不用继承或者工具类来解决
这时候可能有朋友要问:既然日志逻辑要复用,我抽一个LogUtil工具类,每个方法里调用一下不行吗?行,但这是侵入式的——业务代码里仍然需要手动调用工具方法,忘记调了就没有日志,改动时还是要一个方法一个方法去动。用继承解决更别扭,总不能为了记日志让所有业务类都继承同一个LogBase类,这会把类继承关系搅得一团糟,Java又是单继承,为了日志把唯一的继承机会用掉,太不划算。
AOP把横切逻辑和业务逻辑在源代码层面完全分离,业务方法只需要写自己的订单逻辑,AOP在运行时通过代理机制把日志逻辑"织入"进去。业务代码里看不到任何日志调用的影子,但日志确实执行了。这才是"面向切面"的核心价值所在。
2. 动态代理:Spring AOP的底层地基
2.1 JDK动态代理:基于接口的实现
AOP听起来玄乎,但Spring AOP的底层其实就两样东西:JDK动态代理和CGLIB。先说JDK动态代理。它要求目标类必须实现接口,运行时通过java.lang.reflect.Proxy为接口生成一个代理类,这个代理类实现了同样的接口,并在调用方法时把请求转发给一个InvocationHandler。
public interface OrderService { void createOrder(String orderNo); } public class OrderServiceImpl implements OrderService { @Override public void createOrder(String orderNo) { System.out.println("创建订单: " + orderNo); } } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("【JDK代理】方法前置逻辑: " + method.getName()); Object result = method.invoke(target, args); System.out.println("【JDK代理】方法后置逻辑"); return result; } } // 生成代理对象 OrderService proxy = (OrderService) Proxy.newProxyInstance( OrderService.class.getClassLoader(), new Class[]{OrderService.class}, new LogInvocationHandler(new OrderServiceImpl()) ); proxy.createOrder("NO.001");运行结果会依次打印前置逻辑、创建订单、后置逻辑。这里的proxy根本不是OrderServiceImpl的实例,而是JVM动态生成的一个Proxy子类实例。所有调用都会进入invoke方法,由我们决定什么时候去调用真实目标对象的方法。
2.2 CGLIB代理:基于继承的实现
JDK动态代理有个硬约束:目标类必须实现接口。那没有接口的类怎么办?Spring引入了CGLIB(Code Generation Library),它通过生成目标类的子类来实现代理,这个子类会重写目标类的非final方法,在重写方法里完成增强逻辑。CGLIB底层用的是ASM字节码操作技术,生成字节码的能力非常强,性能上也不差。
// 没有实现任何接口的类 public class PaymentService { public void pay(double amount) { System.out.println("支付金额: " + amount); } } // 使用CGLIB的Enhancer创建代理 Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(PaymentService.class); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("【CGLIB代理】支付前校验"); Object result = proxy.invokeSuper(obj, args); System.out.println("【CGLIB代理】支付后记录"); return result; } }); PaymentService proxy = (PaymentService) enhancer.create(); proxy.pay(99.9);CGLIB之所以能拦截没有实现接口的类,就是因为它直接造了一个子类,把目标方法给"覆盖"了。因此被代理的类和方法不能是final的,否则CGLIB会报错或者静默失效。
2.3 Spring怎么选代理方式,为什么Spring Boot默认用CGLIB
很多人背过这个结论:Spring优先用JDK动态代理,如果目标类没有实现接口,就退化为CGLIB。但这个说法在Spring Boot 2.x之后已经不准确了。
Spring Boot从2.0开始默认把spring.aop.proxy-target-class设置为true,也就是说默认优先使用CGLIB。哪怕目标类实现了接口,默认也是用CGLIB生成子类代理,而不是JDK动态代理。想切回JDK代理,在配置里改一下即可:
spring.aop.proxy-target-class=false为什么Spring Boot要这么做?直接原因是JDK动态代理有个特别坑的限制:只能代理接口中声明的方法。假如你的实现类里有一个接口上没有的扩展方法,而这个方法又被加上了@Transactional注解,用JDK代理时这个事务注解不会生效,因为代理类只知道接口里的方法。为了减少这类"代理不生效"的诡异问题,默认换成CGLIB更省心。
2.4 多个切面怎么生效:拦截器链与@Order
一个方法可能同时被日志切面、限流切面、事务切面命中,那多个切面的执行顺序是什么?Spring把每个切面封装成一个MethodInterceptor,然后用一个拦截器链把这些增强逻辑串联起来。调用时从链头依次执行,每个切面在proceed()时把调用传递给链条上的下一个切面,直到最后一个切面真正调用目标方法。
切面顺序可以用@Order注解控制,数值越小优先级越高。比如:
@Aspect @Component @Order(1) public class RateLimitAspect { ... } @Aspect @Component @Order(2) public class LogAspect { ... }这样限流逻辑会先执行,限流不通过就直接抛异常,根本不会走到日志切面去打印业务日志,更不会执行目标方法。设计切面时一定要考虑执行顺序,比如开启事务和操作日志,通常是先开事务再记日志,还是先记日志再开事务,这个顺序直接决定了日志里能不能拿到事务回滚后的数据。
注意:Spring AOP基于代理实现,代理对象只能拦截通过代理对象发起的调用。如果切面顺序搞错了,或者代码里直接new目标对象,AOP都会静默失效,这是后面第五章要重点展开的坑。
3. 实战:基于注解的AOP接口限流
3.1 为什么接口限流适合用AOP做
网上搜"aop基于注解的接口限流"的文章特别多,因为它确实是AOP的最佳实践之一。限流逻辑本身就是典型的横切关注点——它和业务逻辑无关,但每个接口都想加,如果每个接口里手写限流代码,那代码会被污染得非常严重。用AOP做限流,业务代码上只加一个注解,干净整洁;限流规则改了只动切面,不需要动任何业务类。
我曾在一个活动秒杀项目里给20多个接口加过限流,如果不用注解+切面,每个接口里加一段Redis计数代码,光是重复代码就能把人写吐,而且特别容易漏掉try-catch导致Redis异常直接打挂业务。
3.2 定义一个可扩展的限流注解
先定义一个注解,字段设计得灵活一点,支持接口级限流、用户级限流、IP级限流:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 限流key前缀,一般是接口语义名,比如"order:create" String key() default ""; // 时间窗口,单位秒,默认1秒 int window() default 1; // 窗口内最大请求数 int limit() default 100; // 限流后的提示信息 String message() default "系统繁忙,请稍后再试"; }@Retention(RetentionPolicy.RUNTIME)这行非常关键,因为AOP是在运行时通过反射读取注解的,如果保留策略是SOURCE或者CLASS,运行时就拿不到注解了。
3.3 编写限流切面配合Redis实现
限流算法我推荐先用固定窗口计数,实现简单、够用。核心思路:用Redis的INCR命令给每个限流key计数,如果是第一次请求(计数为1),同时设置过期时间等于窗口大小;如果计数超过limit,直接拒绝。
@Aspect @Component public class RateLimitAspect { private static final String KEY_PREFIX = "rate:limit:"; @Autowired private StringRedisTemplate redisTemplate; @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { // 1. 组装限流key String methodName = joinPoint.getSignature().toShortString(); String key = KEY_PREFIX + (rateLimit.key().isEmpty() ? methodName : rateLimit.key()); // 2. 使用Redis计数,加上Lua保证原子性 Long current = executeLimitScript(key, rateLimit.window(), rateLimit.limit()); if (current != null && current > rateLimit.limit()) { throw new RuntimeException(rateLimit.message()); } // 3. 限流通过,执行目标方法 return joinPoint.proceed(); } private Long executeLimitScript(String key, int window, int limit) { // Lua脚本:如果key不存在,设置值并设置过期时间;否则自增并返回当前计数 String lua = "local c = redis.call('get', KEYS[1]) " + "if c == false then " + " redis.call('set', KEYS[1], 1) " + " redis.call('expire', KEYS[1], ARGV[1]) " + " return 1 " + "else " + " local n = tonumber(c) " + " if n < tonumber(ARGV[2]) then " + " redis.call('incr', KEYS[1]) " + " return n + 1 " + " else " + " return n " + " end " + "end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class); return redisTemplate.execute(script, Collections.singletonList(key), String.valueOf(window), String.valueOf(limit)); } }这里一定要把"判断+自增"放进Lua脚本,保证原子性。如果不这么做,两个并发请求同时读到count=99、limit=100,然后同时放行,最终放行量会超过限流值。这在秒杀场景就是超卖事故。
3.4 限流参数设置与关键细节
限流切面里有三个细节值得单独说。
第一,key的设计要包含维度。接口级限流用方法名就行,但要按用户控制频率,比如"一个用户一秒最多请求一次",key就必须加上userId或IP:
String userId = UserContext.getUserId(); String key = KEY_PREFIX + rateLimit.key() + ":" + userId;第二,Redis不可用时的降级策略。我在生产环境一般会把Redis异常catch住,打印告警日志,然后放行请求。限流本质是"保护系统"的手段,如果限流组件本身挂了导致全部业务不可用,那就本末倒置了。降级的代价是可能短暂超卖,但比全站宕机好得多。
第三,本地限流还是分布式限流。如果是单机应用,用Guava的RateLimiter更轻量,不依赖Redis,性能也更好。一旦服务多实例部署,本地限流就失效了——每个节点各放行100个,加起来可能是300个,必须改用Redis等分布式限流方案。选型表可以参考:
| 维度 | 本地限流(Guava) | 分布式限流(Redis) |
|---|---|---|
| 适用场景 | 单机部署、单实例内部保护 | 多实例集群、接口网关层 |
| 依赖 | 无外部依赖 | 依赖Redis,存在网络开销 |
| 数据一致性 | 节点独立,各限制各的 | 全局统一计数 |
| 性能 | 毫秒级,无网络IO | 有网络IO,慢于本地但可接受 |
| 典型实现 | RateLimiter、Semaphore | Lua脚本+INCR、固定窗口、滑动窗口 |
使用限流注解后,业务代码干净得像这样:
@RateLimit(key = "order:create", window = 1, limit = 10) @PostMapping("/order/create") public Result createOrder(@RequestBody OrderDTO dto) { // 核心业务,无需关心限流逻辑 return orderService.create(dto); }4. AOP五大高频使用场景
4.1 操作日志与审计日志:自动记录调用轨迹
操作日志是AOP最常见的使用场景之一。业务系统里"谁在什么时间调用了什么接口、传了什么参数、返回了什么结果",这类审计诉求用一个AOP切面就能全局搞定:
@Aspect @Component public class OperationLogAspect { @Autowired private OperationLogMapper logMapper; @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); Object result = null; Throwable error = null; try { result = joinPoint.proceed(); return result; } catch (Throwable t) { error = t; throw t; } finally { OperationLogEntity entity = new OperationLogEntity(); entity.setMethod(joinPoint.getSignature().toShortString()); entity.setArgs(JSON.toJSONString(joinPoint.getArgs())); entity.setResult(error != null ? error.getMessage() : JSON.toJSONString(result)); entity.setCost(System.currentTimeMillis() - start); entity.setOperator(UserContext.getUserId()); entity.setOperateTime(new Date()); logMapper.insert(entity); } } }这个切面有两个实操心得:一是在finally里记录日志,这样成功和失败都能记录下来;二是记录操作人要从UserContext里取,不能从前端传的参数里取,否则用户可以伪造。这是审计系统最基本的防篡改要求。
4.2 Spring事务的本质就是AOP
很多人天天用@Transactional,但没想过它的底层是什么。Spring声明式事务本质就是一个事务切面:方法执行前开启事务,方法正常返回后提交事务,方法抛出RuntimeException时回滚事务。这个事务切面是Spring框架内置的,由TransactionInterceptor实现。
@Transactional(rollbackFor = Exception.class) public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) { accountMapper.decrease(fromUserId, amount); accountMapper.increase(toUserId, amount); // 如果这里抛出异常,前面两步都会回滚 }注意rollbackFor = Exception.class这个细节,这也是Spring事务最经典的坑。默认情况下,Spring只对运行时异常(RuntimeException)和Error回滚,对受检异常(Checked Exception)不回滚。如果业务代码里抛了个IOException,事务照样提交,数据就错了。用AOP思维理解事务:只有通过代理对象调用的方法才走事务切面,同类内直接调用(this调用)时@Transactional会失效,这也是AOP代理生效范围的经典问题。
4.3 登录态校验与权限控制
很多团队用拦截器(HandlerInterceptor)做登录校验,但在非Web层或者更细粒度的方法上,拦截器就管不了。用AOP实现权限校验可以精确到类、方法甚至参数。比如基于自定义注解控制接口权限:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); } @Aspect @Component public class PermissionAspect { @Before("@annotation(requirePermission)") public void checkPermission(JoinPoint joinPoint, RequirePermission requirePermission) { String permission = requirePermission.value(); if (!PermissionChecker.hasPermission(permission)) { throw new BusinessException("无权限访问"); } } }实际项目里,可以用AOP实现"越权校验"——通过解析方法参数里的userId,判断当前登录用户是否有权操作该数据。这类逻辑写在每个接口里会疯掉的,但放切面里就是一次配置,全部接口统一生效。
4.4 性能监控与慢接口告警
做性能优化时,需要统计每个接口的耗时、吞吐量、错误率。手动在每个方法里埋点太累了,AOP一发入魂:
@Aspect @Component public class PerformanceMonitorAspect { @Around("execution(* com.example.service..*.*(..))") public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().toShortString(); long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 500) { System.err.println("慢接口告警: " + methodName + " 耗时 " + cost + "ms"); } MetricsCollector.record(methodName, cost); } } }配合Micrometer或者Prometheus,可以把耗时指标上报到监控系统,接口一慢就能自动告警。线上排查性能问题的时候,这份从切面拿到的全局方法论据最管用。
4.5 幂等控制与缓存穿透防护
接口幂等也可以让AOP兜底。比如"重复提交"问题,用一个防重注解,切面里基于Redis的SETNX实现分布式锁:
@Aspect @Component public class IdempotentAspect { @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String key = buildKey(idempotent, joinPoint.getArgs()); // SETNX 加锁,key不存在才能加锁成功 Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofSeconds(idempotent.expire())); if (!Boolean.TRUE.equals(success)) { throw new BusinessException("请勿重复提交"); } try { return joinPoint.proceed(); } finally { redisTemplate.delete(key); } } }这样压测和并发环境下的重复提交问题就自动解决了。AOP在这类场景中的价值是一样的:业务接口零入侵,公共逻辑统一控制。
5. 面试八股与实战避坑:AOP失效场景大盘点
5.1 同类内部调用导致AOP失效
这个坑我估计十个新手九个踩。看下面这个例子:
@Service public class OrderService { @Transactional public void createOrder() { // 业务逻辑... updateStock(); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateStock() { // 扣减库存 } }从外部调用orderService.createOrder()时,事务切面确实生效了。但createOrder()方法内部调用updateStock()时,注意这个this调用——它调用的是目标对象的真实方法,不是代理对象的方法。而AOP增强是织在代理对象上的,所以updateStock()上的REQUIRES_NEW事务根本不会生效,它会被并入createOrder()这个方法的事务中。
解决办法很简单:从容器中直接注入自身代理,或者用AopContext.currentProxy():
((OrderService) AopContext.currentProxy()).updateStock();注意使用AopContext.currentProxy()需要先配置@EnableAspectJAutoProxy(exposeProxy = true),否则取不到当前代理对象。这个坑在@Async注解、@Cacheable注解上同样存在,本质都是"代理对象方法调用链"问题。
5.2 切点表达式匹配不到导致全部失效
一个切面配好了,但就是不生效,最常见的原因是execution表达式写错。execution(* com.example.service..*.*(..))这串表达式很多人只是死记硬背,不清楚每个部分的含义:
- 第一个
*:方法返回任意类型 com.example.service..*:匹配service包及其子包下的所有类- 最后一个
*:匹配任意方法名 (..):匹配任意参数列表
注意..的含义是"包及其子包",如果你写com.example.service.*,就只匹配service包下的类,子包里的不匹配。还有,代理只能拦Spring容器管理的bean,自己new出来的对象不可能被AOP拦截,因为它根本不是Spring容器里的代理对象。
5.3 @Async注解的代理失效与线程边界问题
AOP还容易和异步场景打架。比如一个方法标了@Async,但它是从同类其他方法里调用的,那异步效果不会体现——因为走的是this调用,不是代理对象调用,切面没机会介入。即使是通过代理调用的,也要注意异步线程里的AOP上下文问题,比如RequestContextHolder取不到当前请求信息,因为子线程没有继承父线程的请求上下文。
这类问题我建议从设计上规避:异步方法单独提取成一个独立Bean,拆分时把代理调用链理顺。这也是为什么我写代码时习惯把"异步子任务"放到单独类里的原因。
5.4 高频面试问法拆解
结合上面这些内容,我整理一个面试中经常出现的问题清单,每个问题其实就是对你AOP掌握程度的测验:
| 面试问题 | 回答要点 |
|---|---|
| AOP是什么?解决了什么问题? | 面向切面编程,把日志、事务、鉴权等横切逻辑与业务逻辑解耦,动静分离 |
| 什么是JoinPoint、Pointcut、Advice? | 连接点是被拦截的方法,切点是筛选连接点的表达式,通知是织入的具体逻辑 |
| JDK动态代理和CGLIB区别? | JDK代理基于接口,CGLIB基于继承;JDK代理只能代理接口方法,CGLIB可以代理普通类,但final类/方法不能被代理 |
| Spring AOP默认用哪种代理? | Spring Boot 2.x之后默认CGLIB,通过spring.aop.proxy-target-class可以切换 |
| AOP和拦截器、过滤器有什么区别? | 过滤器在Servlet层,拦截器在Handler层,AOP可以到任意Spring Bean的方法粒度,三者层级不同、使用场景不同 |
| Spring事务失效的常见原因? | 同类调用不经过代理、异常被吞没、方法不是public、rollbackFor没配受检异常、多线程调用等 |
| 多个切面执行顺序如何控制? | 通过@Order注解,值越小优先级越高,内部通过拦截器链串联 |
这些问题串起来看,你会发现AOP的面试内核其实就一条主线:代理机制。理解了"Spring AOP全靠代理对象转发方法调用"这一点,所有失效场景都能自己推导出来。
最后分享一个我在实际排查问题时的小技巧。当你怀疑某个切面不生效时,直接在应用启动时打印所有代理类的类名,看一眼目标Bean到底是Proxy还是原始的xxxServiceImpl:
ApplicationContext ctx = SpringApplication.run(App.class, args); Object bean = ctx.getBean("orderService"); System.out.println(bean.getClass().getName()); // jdk代理:com.sun.proxy.$Proxy72 // cglib代理:com.example.service.OrderService$$EnhancerBySpringCGLIB$$1类名直接暴露代理类型,这个信息能帮你快速定位是JDK代理的接口方法问题,还是同类调用的失效问题。AOP这东西,原理通了,写起来和排查起来都会顺手很多。