@Transactional 不生效、切面日志时有时无、按实现类注入突然报类型不匹配——这三个现象查到末步,都会落到同一句话上:容器给你的那个对象,早就不是你以为的那个对象了。Spring AOP 不往原类里织代码,它是在容器里把一个 Bean 的实例换成了代理。换法只有两种,选错一种,就撞上它固有的限制。
下面按顺序拆:两条路线的硬限制、默认值的版本变化、构造阶段副作用翻倍、自调用失效的三种解法、切点与通知顺序、代理类型对注入的影响。
1. 两条实现路线:接口委托与继承覆写
JDK 动态代理走 java.lang.reflect.Proxy,运行时造一个实现相同接口的类,调用转发给 InvocationHandler;CGLIB 走字节码生成,造一个目标类的子类,覆写非 final 方法并插回调。
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 底层机制 | Proxy + InvocationHandler |
生成子类 + MethodInterceptor |
| 硬前提 | 目标类至少实现一个接口 | 目标类不能 final |
| 方法限制 | 只能代理接口中声明的方法 | 不能代理 final / private / static 方法 |
| 生成类型 | jdk.proxy2.$ProxyNN,实现同一接口 |
Xxx$$SpringCGLIB$$0,是目标类的子类 |
| 注入方式 | 必须按接口注入 | 接口、实现类都能注入 |
| 构造影响 | 不触碰目标类构造流程 | 会生成子类实例,初始化逻辑多跑一遍 |
| 一次性开销 | 低 | 首次生成字节码有开销,之后接近直接调用 |
@Component
public class OrderService { // 没有实现任何接口
public void place(String sku) { }
}
OrderService bean = ctx.getBean(OrderService.class);
System.out.println(bean.getClass().getName());
// com.example.OrderService$$SpringCGLIB$$0
System.out.println(bean instanceof OrderService); // true,子类当然是
切到 JDK 代理直接失败,Proxy 找不到接口。下面这张表里第三行最阴——final 方法在 CGLIB 下不报错,只是不代理:
| 写法 | JDK 代理 | CGLIB | 结果 |
|---|---|---|---|
| 无接口的普通类 | 失败 | 生效 | Boot 默认走 CGLIB |
final 类 |
生效(代理接口) | 启动失败 | Cannot subclass final class |
final 方法 |
不在接口里,拦不到 | 生成不了覆写 | 静默穿过,切面不执行 |
| 构造器 / 字段访问 | 拦不到 | 拦不到 | Spring AOP 的能力边界 |
2. proxy-target-class 与默认值的版本差异
默认值变过,而且写在两个不同的开关上。
| 环境 | 默认 | 变化点 |
|---|---|---|
Framework 5.x + <aop:aspectj-autoproxy> |
JDK 代理 | 要 CGLIB 必须写 proxy-target-class="true" |
| Boot 2.0 起 | CGLIB | 为了能代理没有接口的 @Configuration 类 |
| Boot 3.x / Framework 6 | 仍是 CGLIB | 按 Boot 3.3.x 的 AopAutoConfiguration 核对 |
显式 @EnableAspectJAutoProxy(proxyTargetClass=false) |
JDK 代理 | 注解值优先于 Boot 默认 |
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = false, exposeProxy = true)
public class AopConfig {
}
spring:
aop:
proxy-target-class: false
auto-proxy: true
别把这几个开关混为一谈:spring.aop.proxy-target-class 管的是 AOP 自动代理;@EnableTransactionManagement 有同名属性 proxyTargetClass,@EnableAsync 又有自己的 mode。切到 false 后,按实现类注入的地方会开始报错,原因见第 7 节那张表。
3. 初始化逻辑为什么会跑两遍
严格说,目标类的构造器体内代码只执行一次:Spring 用 Objenesis 生成 CGLIB 子类实例,跳过了 super() 的显式调用。但字段初始化器和实例初始化块属于子类构造流程的一部分,它们会在代理实例上再执行一遍。表现形式就是"构造器里的计数器翻倍"。
@Service
public class CounterService {
private final Map<String, String> cache = new HashMap<>(); // 初始化器,跑两遍
public CounterService() {
CREATED.incrementAndGet(); // 计数 +1,代理实例不触发
}
{
REGISTRY.add(this); // 实例初始化块,代理实例也会执行
}
}
规避:构造器与初始化块里只做赋值,不做注册、计数、IO、开连接。需要注册的动作挪到 @PostConstruct 或 afterPropertiesSet()——容器对目标实例回调,代理不参与。按 Spring 6.x 核对过。
4. 自调用失效与三种解法
代理拦截的前提是"调用穿过代理"。在类内部用 this.xxx() 调用,this 指向目标对象本身,压根不经过代理。
@Service
public class PayService {
public void pay(Long id) {
this.audit(id); // this 是原始对象,@Transactional 完全不生效
}
@Transactional
public void audit(Long id) { }
}
| 解法 | 写法 | 优点 | 代价 |
|---|---|---|---|
| 暴露当前代理 | AopContext.currentProxy() |
不动类结构 | 需 exposeProxy=true;返回值要按接口强转 |
| 注入自己 | @Lazy @Autowired PayService self |
类型安全、可读 | 引入自引用,等于绕三级缓存 |
| 拆到另一个 Bean | 新建 AuditService |
边界最干净 | 多一个类,且要真的挪走职责 |
// 解法一:开开关
@EnableAspectJAutoProxy(exposeProxy = true)
// 调用处
((PayService) AopContext.currentProxy()).audit(id);
第三种是唯一"顺手把设计也修了"的解法。前两种只是让代码绕过代理,分层边界没变,下次重构还会复发。
5. 切点表达式与多切面的顺序
| 表达式 | 匹配对象 | 典型用途 |
|---|---|---|
execution(* com.example.service.*.*(..)) |
方法签名 | 包内方法,最常用 |
@annotation(...) |
方法上的注解 | 精确到被标注的方法 |
within(com.example.service..*) |
类型 | 整包含子包 |
@within(...) |
类型上的注解 | 类级注解,比 @annotation 快 |
args(java.lang.String) |
运行时参数类型 | 按入参类型切 |
this(...) / target(...) |
代理对象 / 目标对象 | 区分 JDK 与 CGLIB 场景 |
@Aspect
@Component
@Order(1)
public class AuditAspect {
@Pointcut("execution(public * com.example..service..*.*(..))")
public void serviceLayer() { }
@Pointcut("@annotation(com.example.audit.Audited)")
public void audited() { }
@Around("serviceLayer() && audited()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
return pjp.proceed();
}
}
多个切面命中同一方法时,按 @Order / Ordered 升序排成责任链:数值越小越靠外层,先进入、最后收尾。两个切面 @Order 相同则顺序不确定,不要依赖 Bean 名去猜。
6. 通知类型与嵌套关系
| 次序 | 通知 | 相对位置 |
|---|---|---|
| 1 | @Around 的 proceed() 之前 |
最外层(Order 最小) |
| 2 | @Before |
进入目标方法前 |
| 3 | 目标方法 | — |
| 4 | @AfterReturning / @AfterThrowing |
按结果二选一 |
| 5 | @After |
finally 语义,必执行 |
| 6 | @Around 的 proceed() 之后 |
最外层收尾 |
@Around("@annotation(com.example.audit.Audited)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // 只调一次,调两次等于目标方法执行两次
} finally {
log.info("{} cost {} ns", pjp.getSignature().toShortString(),
System.nanoTime() - start);
// 这里绝不能 catch 掉异常后返回 null
}
}
在 @Around 里 try-catch 吞掉异常,是事务不回滚的头号来源:异常没穿出代理,拦截器以为方法正常返回。要重试就显式 proceed() 多次,并自己保证幂等。
7. 代理对象的类型判断与注入选择
boolean a = AopUtils.isAopProxy(bean); // 是否代理
boolean b = AopUtils.isJdkDynamicProxy(bean); // JDK 代理
boolean c = AopUtils.isCglibProxy(bean); // CGLIB
Class<?> target = AopUtils.getTargetClass(bean); // 真实目标类
AopProxyUtils.ultimateTargetClass(bean); // 穿透多层代理
| 判断方式 | JDK 代理 | CGLIB |
|---|---|---|
bean instanceof 接口 |
true | true |
bean instanceof 实现类 |
false | true |
AopUtils.getTargetClass(bean) == X.class |
true | true |
@Autowired X(按实现类) |
启动失败 | 正常 |
结论:面向接口注入;真要用实现类,先确认代理方式。"是不是代理"用 AopUtils 判断,别拿类名字符串猜。
8. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 切面只对部分方法生效 | final 方法或自调用 |
去 final;按第 4 节改调用路径 |
启动报 Cannot subclass final class |
目标类是 final 且走 CGLIB | 抽接口走 JDK 代理 |
@Transactional 完全不生效 |
方法 private,或类不在扫描范围 | 改 public;检查 @SpringBootApplication 包路径 |
| 按实现类注入报类型不匹配 | 默认 JDK 代理 | 改接口注入,或 proxyTargetClass=true |
| 构造期计数器/注册表数量翻倍 | 初始化块在代理实例上重跑 | 挪到 @PostConstruct |
AopContext.currentProxy() 抛 IllegalStateException |
没开 exposeProxy |
@EnableAspectJAutoProxy(exposeProxy = true) |
| 异常打印了但事务没回滚 | 切面里 catch 后没重新抛出 | throw 原异常或包装成 RuntimeException |
9. 边界:什么情况本来就该换工具
| 场景 | Spring AOP | 替代 |
|---|---|---|
| 日志、耗时、审计、幂等 | 适合 | — |
| 事务、缓存、异步 | 适合,框架已内建 | — |
| 拦截构造器、字段读写 | 做不到 | AspectJ + LTW |
第三方 final 类的 final 方法 |
做不到 | 装饰器 / 包装类 |
| 极高频路径上的细切面 | 谨慎 | 手工埋点 |
| 需要拿到目标对象内部私有状态 | 不适合 | 回到代码本身 |
一句话:有接口、要控制类型就 JDK 代理;没接口、要代理类自身方法就 CGLIB;要拦构造器和字段,Spring AOP 到此为止。
10. 自测
- 目标类是
final、方法也是final,Boot 3 默认代理方式下会看到什么现象? spring.aop.proxy-target-class=false之后,哪些原本能跑的注入会开始报错?- 为什么说构造器"被调用两次"这个说法不准确,实际发生的是什么?
- 三种破解自调用的方式里,哪一种顺带修了设计问题?
- 两个切面
@Order相同,谁先进入目标方法?为什么不能靠 Bean 名判断? bean.getClass()和AopUtils.getTargetClass(bean)在 CGLIB 场景下差在哪?