静态代理和动态代理这两个词,凡是写过 Java 的都听过,面试也常被问到。可你要是真去翻老项目里的代码,会发现很多人对代理的理解停留在“会用 Proxy.newProxyInstance”这一层:能写出两段示例,换个场景照样翻车。这个系列的第二篇,我不打算再讲一遍“什么是代理模式”这种入门内容,而是想聊聊静态代理和动态代理在真实工程里的分水岭——为什么静态代理会让项目越来越难维护?JDK 动态代理到底在底层做了什么?CGLIB 又是怎么绕开接口限制的?这些机制背后有哪些坑,以及我们选型时到底该看什么。
如果你还没看过第一篇,不用回头补课,接下来我会用自己的视角重新梳理一遍。只是和普通教程不同,这篇文章会更赶“现场”:每一步都会告诉你为什么这么做,踩过的坑和排查思路也会一并列出来。
1. 从“会写”到“真懂”,静态代理的三种硬伤
1.1 一个很标准的静态代理示例
先说静态代理。大多数人对它的印象是一个接口、一个目标类、一个代理类,代码大概是这个样式:
public interface UserService { void save(String name); } public class UserServiceImpl implements UserService { @Override public void save(String name) { System.out.println("保存用户: " + name); } } public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target = target; } @Override public void save(String name) { System.out.println("开始保存用户..."); target.save(name); System.out.println("保存用户结束..."); } }这种写法的核心思想是“组合优于继承”:代理类和目标类实现同一个接口,代理类内部持有目标对象,调用时在前后插入增强逻辑。接口对外暴露的是同一个类型,调用方感知不到代理的存在。
1.2 静态代理的三种硬伤
这段代码看着干净,但放进大型项目里,问题很快暴露。第一种是接口膨胀:每增加一个业务接口,你就得手写一个对应的代理类。如果系统里有二十个接口,就可能同时存在二十个代理类,而且这些代理类里大多是一样的日志、事务、权限逻辑,只是目标类型和方法不同。改一个公共逻辑,所有代理类都得跟着动,漏掉一个就会出问题。
第二种是“职责固化”:静态代理的增强逻辑是写死在代理类里的。今天要在 save 前面加缓存,明天要在 delete 后面做审计,每次都要改代理类的代码。代理类代码越多,偏离“代理”这个词本身就越远,最终沦为一座写满业务杂项的垃圾堆。
第三种更隐蔽——静态代理对“非接口类型”几乎无计可施。如果目标类没有实现任何接口,你想给它做代理,静态代理这条路直接被堵死。要么给目标类硬塞一个接口,要么就得在代理类里继承目标类,然后重写方法。这样做的耦合度比接口代理还要高,还要防止目标类方法被 final 修饰。
所以我一直有个观点:静态代理不是不能用,而是它更适合在规模可控、接口稳定、增强点非常有限的场景下使用。一旦系统进入快速迭代期,静态代理的维护成本会指数级上升。这也是动态代理真正被需要的起点。
2. JDK 动态代理:反射只是入门,重点是“生成”
2.1 三行代码里藏着一个“方法调用转发器”
动态代理和静态代理相比,最大的变化是不需要为每个接口手写代理类。以 JDK 动态代理为例,核心就三要素:一个 InvocationHandler、一个接口数组、一个类加载器。
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(), new Class<?>[]{UserService.class}, new LogInvocationHandler(target) ); proxy.save("张三");从调用方视角来看,proxy.save("张三") 执行后控制台先打印“前置通知”,再打印“保存用户”,再打印“后置通知”。这个效果和静态代理一模一样,区别在于:你不需要再写 UserServiceProxy 这个文件了,任何接口都可以用同一个 LogInvocationHandler 来做日志增强。
这里的核心原理是方法调用转发:代理对象上的方法调用,最终全部被转发到 InvocationHandler.invoke()。你在代理对象上调用的方法名、参数列表、目标方法反射信息,都通过 Method 对象暴露给了 Handler,于是 Handler 可以在调用目标方法前后做任何事。
2.2 为什么 JDK 代理一定要面对接口?
很多人被 JDK 动态代理的第一个限制卡住:目标类没有实现接口,直接用 Proxy.newProxyInstance 就会报错。原因需要看 JDK 生成的代理类结构。
Proxy.newProxyInstance 在运行时动态生成一个代理类,这个代理类会继承 java.lang.reflect.Proxy,同时实现你传入的接口。Java 是单继承的,代理类已经继承了 Proxy,就不可能再继承你的目标类,所以它只能通过接口去暴露代理行为。如果你连接口都没有,JDK 动态代理就没有“形状”去描述代理对象到底该长什么样。
换句话说,JDK 动态代理的本质是“面向接口的代理”,它代理的是接口,而不是类。调用方接收到的类型只能是接口引用,而不是目标类的具体类型。这也能解释为什么你把代理对象强转成 UserServiceImpl 时会抛 ClassCastException——代理类压根不是 UserServiceImpl 的子类。
2.3 反编译代理类:它到底多了哪些方法?
如果你好奇运行时的代理类长什么样,可以加一个系统属性把生成的代理类保存到磁盘:
-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true更高版本的 JDK 也可以这样设置:-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true。
反编译后你会发现,代理类里有几个比较特别的地方:
- 它继承了 Proxy 父类,并且有一个以 InvocationHandler 为参数的构造器。
- 它实现了我们在 newProxyInstance 里传入的所有接口方法。
- 接口里的方法在代理类中并不是简单转发给目标对象,而是把 this、Method、参数列表组装成参数,调用父类 Proxy 持有的 InvocationHandler.invoke()。
- 除接口方法之外,它还重写了 equals、hashCode、toString,这三个方法同样会被转发到 InvocationHandler。
这个细节非常重要。很多人在 InvocationHandler 里对方法名做过滤,只拦截业务方法,结果发现代理对象的 equals() 和 hashCode() 也被拦截了。如果 Handler 里没有对特殊方法做区分,就可能会在集合操作中出现诡异行为。
2.4 newProxyInstance 的三个参数,每一个都可能埋雷
第一个参数是类加载器。很多人图省事直接传目标类的类加载器,这本身没有错,但如果接口是另一个类加载器加载的,你需要保证类加载器能同时看到目标类和接口。否则运行时会出现 ClassCastException 或 NoClassDefFoundError。
第二个参数是接口数组。这个数组的顺序其实无所谓,但接口数量不能为空,传一个空数组会直接抛 IllegalArgumentException。另外,如果接口中有重复的接口,或者接口不是接口而是普通类,也会抛异常。
第三个参数是 InvocationHandler。这个不能传 null,否则代理对象一旦调用方法就会抛 NullPointerException。不过网上那个“传 null 用来生成一个空代理”的玩法,我劝你不要在生产环境碰,毫无意义。
// 正确实例化,避免直接用 Proxy.newProxyInstance 返回 Object 时的强转问题 UserService proxy = (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class<?>[]{UserService.class}, handler );我看到不少新手在这段代码里翻车:强转没问题,问题是接口数组长度为零或者类加载器和接口不匹配。写代码前先想清楚这三件事,能省掉一大半的代理排错时间。
3. 没有接口时的解药:CGLIB 的工作原理与选择
3.1 CGLIB 实现“无接口代理”的最小示例
JDK 动态代理面向接口,但对那些没有接口的目标类,就得请出 CGLIB。CGLIB 不是一个 JDK 自带的库,它基于 ASM 字节码生成技术,在运行时生成目标类的子类。Spring、MyBatis 等框架都把 CGLIB 集成得很深,但在没有框架的场景下,你也能直接依赖cglib包来用。
public class UserService { public void save(String name) { System.out.println("保存用户: " + name); } } Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { System.out.println("[前置]"); Object result = proxy.invokeSuper(obj, args); System.out.println("[后置]"); return result; }); UserService proxy = (UserService) enhancer.create(); proxy.save("张三");关键点是 MethodInterceptor 里的 proxy.invokeSuper(obj, args)。它调用的不是目标对象的方法,而是生成子类中继承自父类的原始方法。CGLIB 把目标类的方法体复制进了子类,由子类去执行真正的业务逻辑,我们只是在这个执行过程前后插入增强。
3.2 继承能解决的问题,和不能解决的问题
CGLIB 用继承,天然就带上了继承的边界:final 类无法被代理,final 方法无法被覆写,private 方法不会参与代理逻辑。
如果目标类被 final 修饰,运行时会抛出 IllegalArgumentException,异常信息会直接告诉你“无法代理 final 类”。这一点很多人的第一反应是“换 JDK 动态代理”,但换的前提是目标类得有接口。假如一个老项目里的类既没有接口又是 final,这在设计上本身就是个定时炸弹,遍历一遍关键的 service 层,尽早把 final 去掉或补上接口,比纠结用什么代理机制更重要。
另外,CGLIB 生成的代理对象是目标类的子类,所以代理对象能直接赋给目标类类型。这也是很多人觉得 CGLIB 比 JDK 动态代理“更自由”的原因。但自由带来的坑是:父类内部的方法调用链中,如果某个方法被子类覆写并增加了增强逻辑,而另一个未被代理的方法内部调用了它,行为会变得很难追踪。
3.3 JDK 代理和 CGLIB 如何选择?我从 Spring 源码里看出的端倪
Spring 在早期版本里遵循一个原则:如果目标类实现了接口,优先使用 JDK 动态代理;如果没有接口,则退回到 CGLIB。后来 Spring Boot 2.x 开始,默认把proxyTargetClass设为 true,也就是在很多场景下直接用 CGLIB。但这个默认值的切换在社区里没少引发讨论,因为它直接改变了注入行为。
我个人的理解是:优先选择 JDK 动态代理,不仅是因为它能生成比 CGLIB 更“轻”的代理类,更因为面向接口的编程习惯对后续维护更友好。而 CGLIB 更适合那些没有接口、无法改造历史代码的场合。别看到 Spring Boot 默认 CGLIB 就觉得 CGLIB 一定更好,框架的默认值是为了开箱即用,不是让你放弃接口设计。
这两者的差异,我用一张表简单对比:
| 对比维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 代理目标 | 接口 | 类(非 final) |
| 生成方式 | 生成实现接口的代理类 | 生成目标类的子类 |
| 底层技术 | Proxy + InvocationHandler | ASM 字节码生成 |
| 对 final 限制 | 无影响(针对接口) | final 类/方法无法代理 |
| 调用方式 | 方法转发到 Handler | FastClass 机制直接调用 |
| 典型场景 | Spring AOP 的接口方案 | 第三方库无接口代理 |
4. 动态代理的常见坑:每个都是线上事故级别的教训
4.1 强转失败与 instanceof 判断错误
有人说“动态代理不就是返回生成的对象吗,直接强转不就完了?”真正动手后你会发现,JDK 动态代理返回的对象只能转成接口类型,不能转成目标实现类。
UserService proxy = (UserService) Proxy.newProxyInstance(...); UserServiceImpl impl = (UserServiceImpl) proxy; // ClassCastException根因是代理类与 UserServiceImpl 没有任何继承关系。这个问题初次遇到可能会让人蒙圈,觉得明明底层就是同一个 Handler、同一个目标对象,转个型怎么就不行?但理解了代理类是独立生成的类之后,这就顺理成章了。
排查思路也很直接:先看代理有没有实现目标类,如果没有,就别做这个强转。代码里建议始终面向接口编程,不要依赖具体实现类型。
4.2 类加载器不一致导致 NoClassDefFoundError
另一个高频事故是类加载器问题。假设接口由 A 类加载器加载,目标类由 B 类加载器加载,你用了 B 去加载代理类,结果代理类在实现接口方法时找不到接口定义,直接抛 NoClassDefFoundError。
实际案例里最典型的场景是发生在 Web 容器或插件化架构中。不同模块的类加载器是隔离的,你在一个模块里用另一个模块的接口做代理,传错类加载器就很容易翻车。
稳妥的做法是用“接口自己的类加载器”:
UserService proxy = (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class<?>[]{UserService.class}, handler );这样生成代理类的加载器一定能看到 UserService 接口,避免了常见的“目标类加载器看不到接口”的坑。
4.3 self-invocation:代理内部的“漏网之鱼”
这个是 Spring AOP 用户最常见的困惑,也是很多人的“代理认知分水岭”。
看下面这段代码:
public class OrderService { @Transactional public void createOrder() { ... this.updateStock(); } public void updateStock() { ... } }如果 updateStock 也被标注了 @Transactional,而它是在 createOrder 内部通过 this 调用的,那么事务不会在 updateStock 上生效。原因很简单:this 调用发生在目标对象内部,没有经过代理对象。代理对象只从“外部方法入口”进入,你从外部调 createOrder,代理生效;但 createOrder 内部调 this.updateStock,这个 this 是目标对象本身,代理根本不知道这回事。
知道这个坑之后,解决方式一般有两种:一是拆开调用,注入代理对象自身,通过代理去调方法;二是把内部方法挪到另一个 Bean 里,从外部注入再调用。无论哪种,都是为了让第二次调用也经过代理层。
4.4 反射异常的包装与误读
Handler 里调用目标方法用的是 method.invoke(target, args)。如果目标方法内部抛了异常,这个异常会被包装成 InvocationTargetException 抛出来。很多人只是看异常栈,看到顶部是 InvocationTargetException,往往误以为是代理的问题,实际上真正的业务异常躺在 cause 里。
try { proxy.save("张三"); } catch (InvocationTargetException e) { Throwable real = e.getCause(); // 这里才是业务异常的真实信息 }在排查日志时,记得打开 cause 链,别盯着表面的 InvocationTargetException 发愁。这个细节在动态代理的异常链路里非常常见,写日志或者接监控的时候建议直接输出 getCause。
5. 拿得出手的封装:一个可复用的通用代理工具类
5.1 泛型与缓存的落地写法
把动态代理封装成工具类,最忌讳的是把业务逻辑写死到工具类里。通用工具类只解决两件事:创建代理实例、缓存代理实例。增强逻辑应该由调用方以 Handler 的形式传入。
我给一个自己项目里用得很顺手的简化版:
public class ProxyFactory { private static final Map<Class<?>, Object> CACHE = new ConcurrentHashMap<>(); @SuppressWarnings("unchecked") public static <T> T createProxy(Class<T> interfaceType, InvocationHandler handler) { if (!interfaceType.isInterface()) { throw new IllegalArgumentException("必须传入接口类型"); } return (T) Proxy.newProxyInstance( interfaceType.getClassLoader(), new Class<?>[]{interfaceType}, handler ); } public static <T> T createCachedProxy(Class<T> interfaceType, InvocationHandler handler) { return (T) CACHE.computeIfAbsent(interfaceType, type -> createProxy(interfaceType, handler)); } }代码里那个缓存很关键。每次 newProxyInstance 都会生成新的代理对象,如果调用方反复创建,会造成不必要的对象和类元数据膨胀。缓存按接口维度来,保证同一接口下所有代理对象复用同一个 Handler。实际在写的时候,Handler 的创建也得考虑线程安全,不要把带状态的变量共享到线程不安全的 Handler 里。
5.2 多 Handler 代理链的简单实现
单个 Handler 负责一个维度,比如日志是一层、权限是一层、事务是一层。如果每个代理只能绑定一个 Handler,就需要设计链式调用。
最简单的实现方式是把多个 Handler 串成一条链,参考责任链模式:
public class CompositeInvocationHandler implements InvocationHandler { private final List<InvocationHandler> handlers; private final Object target; public CompositeInvocationHandler(Object target, List<InvocationHandler> handlers) { this.target = target; this.handlers = handlers; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { for (InvocationHandler handler : handlers) { handler.invoke(proxy, method, args); } return method.invoke(target, args); } }我这个简版看着简洁,但真实场景里往往需要更精细的“前、后置”定义,比如要求前置增强按顺序执行、后置增强逆序执行、异常时不继续执行后续增强。真要做得稳,建议参考 AOP 拦截器链的路子来设计,而不是只做一个简单的 for 循环。
5.3 性能优化:从反射到 MethodHandle 的取舍
JDK 动态代理的核心是反射调用 Method.invoke()。JDK 在后续版本里对反射做了大量优化,但反射调用的性能依然低于直接调用。如果真的处在高性能路径上,比如每秒调用几万次的场景,可以考虑把 Method.invoke 换成 MethodHandle。
MethodHandle 在 JDK 7 引入,设计目标就是提供一种轻量级、可被 JIT 优化的方法调用方式。用一个简单的例子感受一下:
MethodHandles.Lookup lookup = MethodHandles.lookup(); MethodHandle handle = lookup.findVirtual(UserService.class, "save", MethodType.methodType(void.class, String.class)); handle.invokeExact(target, "张三");这段代码运行后能明显感觉到 MethodHandle 比反射更轻量。但换取性能的同时,代码可读性下降、异常处理更麻烦,所以我的结论是:先用反射做正确性验证,性能瓶颈出现后再按热点方法逐批替换成 MethodHandle。不要在项目一开始就过度优化。
6. 我的选型建议与真正的实用心得
6.1 静态代理并非一无是处
写了两篇代理相关的内容,可能有人以为我在否定静态代理。实际情况恰恰相反,小规模项目里静态代理反而最可控、最好维护。尤其是你只有一两个业务接口、增强逻辑非常固定的时候,手写代理类比引一整套动态代理框架更省心,代码也更好读。
我见过有人一上来就在项目里引入 Spring AOP,只为了给两个方法加日志。结果是方法调用链路变复杂、调试变得更困难,原本一个简单的日志功能被迫理解代理、切面、自动代理配置。这种复杂度完全没必要。先算一下“代理的场景有多少”,再决定要不要用动态代理。
6.2 动态代理的三个适用信号
什么时候可以考虑动态代理?我自己的判断标准有三条:
- 增强逻辑会随着业务变化频繁调整,比如权限规则、埋点统计。
- 目标类型很多且会持续增加,手写代理类不可持续。
- 需要在运行时决定“哪些方法被拦截”,而不是编译期写死。
这三条只要满足一条,动态代理就是比静态代理更合适的选择。如果三条都不满足,还是老老实实手写吧,别给自己找“架构感”。
6.3 一个小众但有效的调试技巧
代理问题比起普通代码问题,难就难在你看到运行时类型和源代码类型不一样。分享一个我常用的小技巧:在 Handler.invoke 前打印这一次调用的类名、方法名、参数类型,会非常直观地帮你定位是不是代理没走到目标方法。
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("proxy class = " + proxy.getClass()); System.out.println("method = " + method.toGenericString()); System.out.println("args = " + Arrays.toString(args)); ... }这个方法一度帮我查清了 self-invocation 导致的代理失效问题:方法名确实出现在日志里,但调用方是目标对象内部而不是代理入口。看到 proxy class 是目标对象本身而非代理类后,很快就定位到了问题根源。
另外,给代理类生成文件的功能别一直开着,否则生产环境会留下一堆额外的类文件,反而增加排障难度。需要调试的时候再用,定位完马上关掉。
静态代理和动态代理没有那么神秘,无非是运行时“插入逻辑”这一件事的两种实现路径。真正拉开差距的,是你对这个插入过程的理解有多深:方法的转发链路、类加载器的约束、内部调用的盲区、异常包装的层次。把这些想清楚,再去碰 Spring AOP、MyBatis Mapper 代理之类的高级应用,你会发现自己不再只是“会用”,而是可以看清它们每一步在做什么。我在实际项目里用过很多次代理后回头再看这些基础概念,总要感慨一句:真要深入理解一个东西,还得是动手踩坑来得快。