2516 字
约 8 分钟
17
S07-X05 · Spring AOP 的实现与代理选择:JDK 代理还是 CGLIB

@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. 自测

  1. 目标类是 final、方法也是 final,Boot 3 默认代理方式下会看到什么现象?
  2. spring.aop.proxy-target-class=false 之后,哪些原本能跑的注入会开始报错?
  3. 为什么说构造器"被调用两次"这个说法不准确,实际发生的是什么?
  4. 三种破解自调用的方式里,哪一种顺带修了设计问题?
  5. 两个切面 @Order 相同,谁先进入目标方法?为什么不能靠 Bean 名判断?
  6. bean.getClass() 和 AopUtils.getTargetClass(bean) 在 CGLIB 场景下差在哪?
S07-X05 · Spring AOP 的实现与代理选择:JDK 代理还是 CGLIB
https://www.clxhxhhr.top/posts/5852/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。