一、承接上篇:087 留下的一个悬念
087 讲 Spring AOP 时反复出现一句话:"生成一个新的代理类,包裹原始对象"——但从没细讲这个代理类到底是怎么生成出来的。Spring 容器面对一个要代理的 Bean 时,实际上有两条完全不同的技术路线可选:JDK 动态代理和CGLIB。它们是两种独立的、Spring 诞生之前就已存在的通用 Java 技术,Spring AOP 只是"借用"了它们来实现"运行期生成代理对象"这件事。搞懂这两者的原理、限制和选型逻辑,才能回答"为什么这个类不能被代理""为什么两个通知之间性能差了一截"这类面试高频追问。
二、JDK 动态代理:基于接口的运行期字节码生成
JDK 动态代理是 JDK 自带的能力(java.lang.reflect.Proxy),核心限制只有一条:目标类必须实现至少一个接口,代理只能针对接口生成。
public interface UserService {
void save(String name);
}
public class UserServiceImpl implements UserService {
public void save(String name) {
System.out.println("保存用户:" + name);
}
}
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("调用前:" + method.getName());
Object result = method.invoke(target, args); // 反射调用原始对象的方法
System.out.println("调用后:" + method.getName());
return result;
}
}
// 使用方式
UserService target = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(), // 关键:拿到的是接口列表,不是类
new LogInvocationHandler(target)
);
proxy.save("张三"); // 实际会先经过 invoke(),再反射调到 target.save()
Proxy.newProxyInstance 背后发生的事情,很多人以为是"反射黑魔法",其实是实打实的字节码生成:JVM 在运行期动态生成一个新的 .class(类名通常形如 $Proxy0),这个类实现了传入的接口列表,每个接口方法的实现都是"把调用转发给 InvocationHandler.invoke()"。这个类和普通手写类一样要经过类加载,只是生成时机在运行期、生成方式是拼字节码指令而不是 javac 编译。正因为生成的类要"实现接口",如果目标对象没有实现任何接口,JDK 动态代理根本无从下手——这是它最本质的限制,不是配置问题,是这套机制的设计前提决定的。

三、CGLIB:基于继承的子类生成方案
如果目标类没有实现任何接口(这在实际业务代码里很常见,很多 @Service 类根本没有对应接口),JDK 动态代理就完全用不上,这时候需要另一套思路:不基于接口,而是基于继承——生成一个目标类的子类,重写所有可以重写的方法。这就是 CGLIB(Code Generation Library):
public class OrderService { // 注意:没有实现任何接口
public void placeOrder(String item) {
System.out.println("下单:" + item);
}
}
public class LogMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[ ] args, MethodProxy proxy) throws Throwable {
System.out.println("调用前:" + method.getName());
Object result = proxy.invokeSuper(obj, args); // 关键:调用父类(原始类)的方法,不是反射
System.out.println("调用后:" + method.getName());
return result;
}
}
// 使用方式
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class); // 关键:把目标类设为父类
enhancer.setCallback(new LogMethodInterceptor());
OrderService proxy = (OrderService) enhancer.create(); // 生成的对象是 OrderService 的子类实例
proxy.placeOrder("荔枝"); // 实际执行的是子类重写后的方法,内部调用 super.placeOrder()
proxy.invokeSuper() 这一行是 CGLIB 相比 JDK 动态代理的一个关键性能差异点:JDK 动态代理每次调用都要经过一次反射(Method.invoke),而 CGLIB 生成的子类内部维护了一套 FastClass 机制——为原始类和代理类各生成一个 FastClass,每个方法在生成时就被分配了一个固定的整数索引,调用时通过索引直接定位方法入口,不经过反射,理论上比反射调用更快(但这个性能差距在现代 JVM 上已经被大幅缩小,不应作为选型的主要依据)。

四、两者的限制:为什么有的类死活代理不了
// 场景一:CGLIB 无法代理 final 类
public final class ImmutableConfig {
public void load() { }
}
// Enhancer.setSuperclass(ImmutableConfig.class) 会直接抛异常:
// 因为 CGLIB 靠"继承+重写"实现代理,final 类不允许被继承,这条路直接堵死
// 场景二:CGLIB 无法代理 final 方法(类本身可以代理,但这个方法会被"漏掉")
public class PaymentService {
public final void validate() { } // final 方法不能被子类重写
public void pay() { } // 这个方法可以正常被代理
}
// 生成的代理对象上,validate() 调用的还是原始逻辑,但不会经过 MethodInterceptor,
// 也就是说这个方法上的任何切面(日志、事务)都不会生效,且没有任何报错提示
// 场景三:JDK 动态代理只能代理接口方法,类上独有的方法在代理对象上不可见
public interface PayApi { void pay(); }
public class AlipayService implements PayApi {
public void pay() { }
public void refund() { } // 接口里没有这个方法
}
PayApi proxy = (PayApi) Proxy.newProxyInstance(...);
// proxy.refund() 编译都通不过——因为 proxy 的静态类型是 PayApi,
// 生成的代理对象确实只实现了 PayApi 接口,refund() 根本不存在于代理对象的可见方法集合里
三个场景对应三类完全不同性质的"限制":场景一是硬限制(直接抛异常,代理创建失败);场景二是静默失效(代理创建成功,但这个方法上挂的任何逻辑都不会触发,且没有任何警告,排查成本极高,这也是很多"事务突然不生效"排查到最后发现是某个方法被写成了 final);场景三不是代理机制的缺陷,而是面向接口编程的自然结果——你拿到的变量类型是接口,调用不了接口之外的方法,这跟代理没关系,跟 Java 的静态类型系统有关。

五、Spring 如何选择用哪一种:源码里的决策逻辑
// DefaultAopProxyFactory#createAopProxy 简化后的核心判断逻辑
public AopProxy createAopProxy(AdvisedSupport config) {
if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) {
Class<?> targetClass = config.getTargetClass();
if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
return new JdkDynamicAopProxy(config); // 目标类本身就是接口,只能走 JDK 方式
}
return new ObjenesisCglibAopProxy(config); // 其他情况走 CGLIB
}
return new JdkDynamicAopProxy(config); // 默认:目标类实现了接口,优先走 JDK 动态代理
}
判断逻辑翻译成一句话:Spring 默认优先用 JDK 动态代理(前提是目标类实现了接口),只有在满足以下任一条件时才切到 CGLIB——① 显式配置了 proxyTargetClass=true(Spring Boot 从 2.x 开始把 AOP 相关的自动配置默认值改成了 proxyTargetClass=true,也就是说现在的 Spring Boot 项目默认全部走 CGLIB,这是一个经常被面试问到但很多人答错的细节);② 目标类没有实现任何接口,这种情况下 Spring 会自动切到 CGLIB,不需要手动配置,因为 JDK 方式在这种场景下根本不可用。
这里的 ObjenesisCglibAopProxy 名字里的 Objenesis 值得单独展开:普通 CGLIB 生成子类实例时,需要调用父类的构造器(这是 Java 继承的基本规则),但代理场景下我们不希望目标类的构造器逻辑被无意义地执行第二次(原始对象已经构造过一次了)——Objenesis 是一个专门用来"绕过构造器直接创建对象实例"的库,Spring 借助它创建 CGLIB 代理对象,避免了构造器被重复调用带来的副作用(比如构造器里有日志打印、计数器自增等逻辑,会被无端触发两次)。

六、调用路径全景:一次代理方法调用到底经过了什么
// 以 CGLIB 代理 + 一个 @Around 通知为例,简化调用路径
proxy.pay()
→ CGLIB 生成的子类重写方法,内部调用 CglibMethodInterceptor.intercept()
→ 构造 CglibMethodInvocation(内部维护拦截器链的游标)
→ 遍历 Advisor 链,逐个执行匹配的 MethodInterceptor(也就是各个通知)
→ 通知内部调用 invocation.proceed(),游标前进一位
→ 链走完后,最终调用 methodProxy.invokeSuper(target, args)
→ 真正执行 OrderService 原始的 pay() 逻辑
这条链路和 087 第七节讲的"洋葱模型/拦截器链"是完全同一套机制,只是这里补上了"链的最后一环具体是怎么落地到真实方法"这个细节——JDK 代理场景下最后一环是反射 Method.invoke(target, args),CGLIB 场景下最后一环是 methodProxy.invokeSuper(target, args)(走 FastClass,不经过反射)。通知链的执行机制(拦截器+游标)与代理生成方式(JDK/CGLIB)是两个独立的维度——不管选哪种代理方式,多个通知的洋葱模型执行顺序、@Order 控制的层级关系完全不受影响,这是很多人容易混淆的一点:代理方式决定的是"最后怎么调到原始方法",不影响"通知之间谁先谁后"。

七、常见误区与实测性能差异
// 误区一:以为 CGLIB 代理对象和原始对象是同一个类型,可以直接用 instanceof 原始类判断
OrderService proxy = ...; // CGLIB 代理
proxy instanceof OrderService; // true,因为代理是子类
proxy.getClass() == OrderService.class; // false,代理类名类似 OrderService$$EnhancerByCGLIB$$xxx
// 误区二:用 == 比较代理对象和从容器里另一处拿到的"看起来一样"的对象
@Autowired private OrderService service; // 拿到的是代理对象
OrderService raw = new OrderService(); // 手动 new 出来的原始类型对象
service.equals(raw); // 除非重写了 equals,否则为 false,两者不是同一个对象也不是同一个类
// 误区三:认为 JDK 动态代理性能天然差、CGLIB 天然快,选型时盲目偏好 CGLIB
// 实测上(JDK 8+,JIT 预热充分之后),两者在大多数场景下的性能差距已经缩小到几乎可以忽略,
// 反射调用的开销在现代 JVM 上远小于早期版本,CGLIB 的"免反射"优势在方法调用量不是极端量级时不构成决定性因素
性能不该是选型的第一考量因素,真正该纳入决策的是:目标类有没有接口(没有接口只能选 CGLIB)、类或关键方法是否被**final**修饰(是则 CGLIB 这条路可能受阻,需要检查)、团队是否统一用了某个默认强制 CGLIB 的框架配置(比如 Spring Boot 默认的 proxyTargetClass=true,这意味着即使目标类有接口,实际生成的也是 CGLIB 代理,这一点在排查"代理类型不符合预期"问题时是第一个该检查的地方)。

八、选型速查:面试和实践都用得上的判断顺序
目标类是否实现了接口?
├─ 否 → 只能用 CGLIB(没有第二个选择)
└─ 是 → 检查项目/框架是否配置了 proxyTargetClass=true
├─ 是(Spring Boot 2.x+ 默认如此)→ 实际用的是 CGLIB
└─ 否 → 默认用 JDK 动态代理
再叠加限制检查:如果最终确定要用 CGLIB,还要确认目标类不是 final、关键需要被代理的方法不是 final——这两条检查经常被忽略,直到线上某个"看起来应该生效的切面"突然不生效才被追溯到这里。

九、常见误区补充
- "JDK 动态代理生成的类和目标类是同一个类"——不对,是运行期生成的一个全新类(如
$Proxy0),只是实现了相同的接口,getClass()结果与目标类完全不同。 - "CGLIB 代理可以代理私有方法"——不对,私有方法不能被子类重写,CGLIB 基于继承的机制对私有方法完全无效,这和 087 里讲的"AOP 拦不到 private 方法"是同一个根因在两种代理方式上的共同体现。
- "只要目标类有接口,Spring 就一定用 JDK 动态代理"——不对,Spring Boot 2.x 起默认
proxyTargetClass=true,即使有接口也默认走 CGLIB,除非显式改回配置。 - "代理对象和原始对象是同一个 Bean,容器里只存了一份"——需要精确表达:容器里对外暴露、注入给其他 Bean 的确实只有代理对象这一份引用,原始对象作为代理内部持有的
target存在,两者是两个不同的 Java 对象实例,但语义上代表"同一个业务 Bean"。
十、总结
- JDK 动态代理基于接口,运行期生成新类实现接口方法,调用时反射转发给
InvocationHandler,前提是目标类必须实现接口。 - CGLIB 基于继承,生成目标类的子类重写方法,通过
FastClass索引免反射调用,前提是类和关键方法不能是final,且借助Objenesis避免构造器被重复调用。 - 限制的性质不同:无接口是硬约束(JDK 代理不可用);
final方法是静默失效(CGLIB 场景下代理创建成功但该方法完全不被拦截,排查成本最高)。 - Spring 的选择逻辑:默认优先 JDK 方式,但
proxyTargetClass=true(Spring Boot 2.x+ 的默认值)或目标类无接口时切到 CGLIB。 - 拦截器链的执行机制与代理生成方式是两个独立维度,通知顺序不受代理方式影响,代理方式只决定"最终调用如何落地到原始方法"这一环。
延伸阅读
- 相关源码:
java.lang.reflect.Proxy、InvocationHandler、net.sf.cglib.proxy.Enhancer、DefaultAopProxyFactory#createAopProxy、ObjenesisCglibAopProxy - 本系列相关篇目:081(Spring IoC 与 DI)、086(循环依赖)、087(Spring AOP 原理)、089-092(Spring 事务系列)
提问回答模块(面试官视角,不计入阅读时长)
以下问题不是对文章内容的复述,而是模拟面试官会如何在这个话题上继续追问、绕开常见答案去考察真正的理解深度。
Q1:如果目标类没有实现任何接口,但项目里显式配置了**proxy-target-class=false**,Spring 会怎么处理?
不会报错,也不会强行使用 JDK 动态代理——DefaultAopProxyFactory 的判断逻辑里,isProxyTargetClass() 只是"优先级更高的一个触发条件",即便它为 false,只要 hasNoUserSuppliedProxyInterfaces(config) 判断出目标类没有可用接口,还是会自动切换到 CGLIB。也就是说 proxyTargetClass=false 表达的是"如果可以选,我倾向于 JDK 方式",而不是"禁止使用 CGLIB"——Spring 不会因为一个配置项而让代理彻底失败,proxyTargetClass 更准确的语义是"优先策略"而非"硬性开关"。这个细节容易被想当然地答错为"配置了 false 就一定用 JDK 代理"。
Q2:CGLIB 生成的代理类默认放在与目标类相同的包下,这个设计带来了什么排查上的坑?
CGLIB 生成的子类要访问目标类里可能是 protected/包私有(package-private)的方法和字段(比如目标类自身调用了一些非 public 的辅助方法),要做到这一点,生成的代理类必须和目标类处于同一个包名下才能满足 Java 的访问控制规则。但代理类是在运行期由 CGLIB 使用的 ClassLoader 加载的,如果这个加载过程发生在一个和目标类不同的、更严格隔离的类加载器环境里(常见于某些插件化、多模块热部署框架,或者对 sun.misc.Unsafe/模块化 JDK 有额外限制的场景),就可能出现 IllegalAccessError 或 NoSuchMethodError——这类错误初看和"代理"完全不相关,但根因其实是"同包但跨 ClassLoader"导致的可见性冲突,是 CGLIB 在复杂类加载体系下的一个真实痛点。
Q3:手写一个**@Bean**方法内部手动**new Enhancer()**返回 CGLIB 代理对象,和让 Spring AOP 自动生成代理,本质区别是什么?
表面上看最终都拿到一个 CGLIB 生成的子类实例,但语义完全不同:手动生成的代理,MethodInterceptor 的行为是开发者自己写死的固定逻辑,且这个代理对象一旦创建就不会再变化;而 Spring AOP 生成的代理,其背后的 Advised/AdvisedSupport 是可以在运行期动态查询、甚至(在部分场景下)动态调整通知链的——比如通过 Advised.addAdvice() 可以在代理创建后追加新的通知。更重要的一点是,Spring 生成的代理会被容器统一管理生命周期、参与依赖注入、被其他 Bean 通过接口/类型自动注入引用,而手动 new 出来的 CGLIB 代理对象是一个"游离"对象,不受容器管理,不会经历 Bean 的完整生命周期(比如 @PostConstruct、Aware 回调等都不会对它生效,除非手动调用)。
Q4:如果一个方法同时匹配多个**Advisor**(比如**@Transactional**和自定义**@Aspect**都要拦截同一个方法),最终生成的字节码里,是分层代理还是单层代理?
是单层代理。不管有多少个 Advisor 匹配同一个方法,Spring 只会为这个 Bean 生成一个代理对象(一次字节码生成动作),所有匹配的 Advisor 会被 getInterceptorsAndDynamicInterceptionAdvice() 收集进同一条拦截器链里,按顺序(受 @Order/Ordered 影响)排布。代理对象内部的方法实现,逻辑上就是"构造一次 MethodInvocation,把整条链交给它去 proceed()"——这也是为什么 086/087 反复强调"代理生成方式和拦截器链是两个独立维度":无论链上挂了 1 个还是 10 个通知,代理本身永远只生成一次、只有一层。误答"每多一个 Advisor 就多一层代理"是这个话题上最常见的错误直觉。
Q5:Mockito 等 Mock 框架在 mock**final**类时会遇到和 CGLIB 一样的限制,这说明了什么共同的技术原理?
因为 Mockito(在其默认 Mock 引擎实现里)底层同样依赖"生成目标类的子类"这套思路来创建 Mock 对象——不管是 CGLIB 还是 Mockito 现在默认使用的 ByteBuddy,本质上都要面对 Java 语言层面"final 类不可继承、final 方法不可覆写"这条铁律,没有任何字节码生成技术能绕开这条 JVM/语言规范级别的限制(除非像某些高级 Mock 方案那样用 Instrumentation API 在类加载时直接修改字节码本身,那是另一条更底层、更有侵入性的路线)。这说明"基于继承的运行期代码增强"是一整类技术方案(AOP 代理、Mock 框架、部分 ORM 的懒加载代理)共享的同一个技术基座,也共享同一组限制——理解了 CGLIB 的限制,等于同时理解了这一整类技术为什么在 final 类/方法上力不从心。
Q6:如果要设计一个完全不依赖运行期字节码生成(比如目标运行环境彻底禁止动态生成类)的 AOP 方案,思路会是什么?
核心思路要从"运行期生成代理"转向"编译期/构建期织入",也就是 AspectJ 的编译期织入(CTW)和加载期织入(LTW)模式——用一个专门的编译器(ajc)或者类加载阶段的字节码转换器(-javaagent 挂载的 LoadTimeWeavingAgent),直接在 .class 文件生成或加载的那一刻把切面逻辑"焊进"目标方法体内部,而不是包一层代理对象。这种方式不需要在运行期额外生成任何新类,天然没有"目标类是否有接口""是否是 final"这些限制(因为它直接改写的是目标类自己的字节码,不需要继承或实现接口),代价是需要额外的编译步骤或类加载器改造,部署复杂度更高,这也是为什么大多数 Spring 项目仍然默认选择更轻量、侵入性更小的运行期代理方案,只有对性能和拦截完整性要求极高的场景才会引入 AspectJ 织入。